@cbcruk/highlight-kit 0.1.0 → 0.2.0

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.
@@ -0,0 +1,707 @@
1
+ //#region src/pattern.ts
2
+ /**
3
+ * Shared helpers for turning literal string patterns into RegExp sources.
4
+ */
5
+ /** Escape every RegExp metacharacter in `literal` so it matches itself. */
6
+ function escapeRegExp(literal) {
7
+ return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8
+ }
9
+ /**
10
+ * Build a RegExp source matching `literal` verbatim.
11
+ *
12
+ * @param literal - Text to match
13
+ * @param wholeWord - Wrap the source in `\b` so it only matches whole words
14
+ */
15
+ function literalSource(literal, wholeWord = false) {
16
+ const escaped = escapeRegExp(literal);
17
+ return wholeWord ? `\\b${escaped}\\b` : escaped;
18
+ }
19
+ /** Drop a flag from a RegExp flag string. */
20
+ function withoutFlag(flags, flag) {
21
+ return flags.replace(flag, "");
22
+ }
23
+ //#endregion
24
+ //#region src/core.ts
25
+ /** Stable empty snapshot — referentially constant for useSyncExternalStore */
26
+ const EMPTY_SNAPSHOT = Object.freeze({
27
+ active: false,
28
+ count: 0
29
+ });
30
+ /** Whether the CSS Custom Highlight API (`CSS.highlights` and `Highlight`) is available. */
31
+ function isHighlightSupported() {
32
+ return typeof CSS !== "undefined" && "highlights" in CSS && typeof Highlight !== "undefined";
33
+ }
34
+ /**
35
+ * Collect the text nodes under an element that contain non-whitespace text.
36
+ *
37
+ * Whitespace-only nodes (newlines, indentation between elements) are skipped,
38
+ * so this is suited to pattern matching, not to offset math against `textContent`.
39
+ */
40
+ function getTextNodes(root) {
41
+ const nodes = [];
42
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
43
+ let node;
44
+ while (node = walker.nextNode()) if (node.textContent && node.textContent.trim()) nodes.push(node);
45
+ return nodes;
46
+ }
47
+ function toRegExp(pattern, options) {
48
+ if (pattern instanceof RegExp) return pattern.flags.includes("g") ? pattern : new RegExp(pattern.source, pattern.flags + "g");
49
+ return new RegExp(literalSource(pattern, options.wholeWord), options.caseSensitive ? "g" : "gi");
50
+ }
51
+ /**
52
+ * Compute Range objects for every match of `pattern` within `root`.
53
+ * Pure: returns ranges, does not touch the registry.
54
+ */
55
+ function computeRanges(root, pattern, options = {}) {
56
+ if (!pattern) return [];
57
+ const regex = toRegExp(pattern, options);
58
+ const ranges = [];
59
+ for (const textNode of getTextNodes(root)) {
60
+ const text = textNode.textContent ?? "";
61
+ regex.lastIndex = 0;
62
+ let m;
63
+ while ((m = regex.exec(text)) !== null) {
64
+ if (m[0].length === 0) {
65
+ regex.lastIndex++;
66
+ continue;
67
+ }
68
+ try {
69
+ const range = new Range();
70
+ range.setStart(textNode, m.index);
71
+ range.setEnd(textNode, m.index + m[0].length);
72
+ ranges.push(range);
73
+ } catch {}
74
+ }
75
+ }
76
+ return ranges;
77
+ }
78
+ /** Collect every text node under `root`, whitespace-only ones included. */
79
+ function getAllTextNodes(root) {
80
+ const nodes = [];
81
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
82
+ let node;
83
+ while (node = walker.nextNode()) nodes.push(node);
84
+ return nodes;
85
+ }
86
+ /**
87
+ * Map flat character offsets onto Range objects. Useful when you already know positions.
88
+ *
89
+ * Offsets count over `root.textContent`, whitespace-only text nodes (newlines,
90
+ * indentation between elements) included, so positions computed from
91
+ * `textContent` or on a server against the same text line up. Unlike
92
+ * {@link computeRanges}, a span may cross text node boundaries; it yields one
93
+ * range per text node it touches. Out-of-bounds parts are ignored.
94
+ *
95
+ * @example
96
+ * ```ts
97
+ * import { rangesFromOffsets } from '@cbcruk/highlight-kit'
98
+ *
99
+ * const el = document.querySelector('#article')!
100
+ * const start = el.textContent!.indexOf('wisdom')
101
+ * rangesFromOffsets(el, [{ start, end: start + 'wisdom'.length }])
102
+ * ```
103
+ */
104
+ function rangesFromOffsets(root, spans) {
105
+ const nodes = getAllTextNodes(root);
106
+ const layout = [];
107
+ let offset = 0;
108
+ for (const node of nodes) {
109
+ const len = node.textContent?.length ?? 0;
110
+ if (len === 0) continue;
111
+ layout.push({
112
+ node,
113
+ start: offset,
114
+ end: offset + len
115
+ });
116
+ offset += len;
117
+ }
118
+ const ranges = [];
119
+ for (const { start, end } of spans) for (const { node, start: ns, end: ne } of layout) if (ns < end && ne > start) try {
120
+ const range = new Range();
121
+ range.setStart(node, Math.max(0, start - ns));
122
+ range.setEnd(node, Math.min(node.textContent?.length ?? 0, end - ns));
123
+ ranges.push(range);
124
+ } catch {}
125
+ return ranges;
126
+ }
127
+ /** Sink that writes to the document-global `CSS.highlights` registry. */
128
+ function createCssHighlightSink() {
129
+ return {
130
+ commit(name, ranges, priority) {
131
+ if (!isHighlightSupported()) return;
132
+ const highlight = new Highlight(...ranges);
133
+ highlight.priority = priority;
134
+ CSS.highlights.set(name, highlight);
135
+ },
136
+ remove(name) {
137
+ if (!isHighlightSupported()) return;
138
+ CSS.highlights.delete(name);
139
+ },
140
+ isSupported: isHighlightSupported
141
+ };
142
+ }
143
+ /** Side-effect-free sink: bookkeeping still runs, nothing is painted. */
144
+ function createNoopSink() {
145
+ return {
146
+ commit() {},
147
+ remove() {}
148
+ };
149
+ }
150
+ const EMPTY_SNAPSHOTS = Object.freeze({});
151
+ /**
152
+ * Tracks ranges per highlight name and source, and writes their union to a sink.
153
+ *
154
+ * Multiple sources may contribute to one name; each change reconciles the name
155
+ * into a single highlight and notifies `subscribe` listeners. Create instances
156
+ * with {@link createHighlightController} or use the shared {@link highlights}.
157
+ *
158
+ * @example
159
+ * ```ts
160
+ * import { computeRanges, highlights } from '@cbcruk/highlight-kit'
161
+ *
162
+ * const el = document.querySelector('#article')!
163
+ * highlights.set('search', 'my-source', computeRanges(el, 'wisdom'))
164
+ * highlights.getSnapshot('search') // { active: true, count: ... }
165
+ * highlights.remove('search', 'my-source')
166
+ * ```
167
+ */
168
+ var HighlightController = class {
169
+ #sink;
170
+ /** name -> (sourceId -> ranges). Multiple sources may share a name. */
171
+ #entries = /* @__PURE__ */ new Map();
172
+ /** Cached per-name snapshots; refs are stable until that name changes. */
173
+ #snapshots = /* @__PURE__ */ new Map();
174
+ /** Cached name -> snapshot record; rebuilt on every emit. */
175
+ #allSnapshots = EMPTY_SNAPSHOTS;
176
+ /** External-store listeners. */
177
+ #listeners = /* @__PURE__ */ new Set();
178
+ /** Create a controller that writes to `sink` (the CSS registry sink by default). */
179
+ constructor({ sink = createCssHighlightSink() } = {}) {
180
+ this.#sink = sink;
181
+ }
182
+ /**
183
+ * Whether the sink reports support. `true` when the sink has no `isSupported`.
184
+ * When `false`, {@link HighlightController.set} is a no-op.
185
+ */
186
+ get supported() {
187
+ return this.#sink.isSupported?.() ?? true;
188
+ }
189
+ /** useSyncExternalStore: stable identity (arrow field on the instance). */
190
+ subscribe = (listener) => {
191
+ this.#listeners.add(listener);
192
+ return () => this.#listeners.delete(listener);
193
+ };
194
+ /** Snapshot for a given highlight name (referentially stable). */
195
+ getSnapshot = (name) => {
196
+ return this.#snapshots.get(name) ?? EMPTY_SNAPSHOT;
197
+ };
198
+ /** Snapshots of every active name (referentially stable between changes). */
199
+ getSnapshots = () => {
200
+ return this.#allSnapshots;
201
+ };
202
+ /** SSR / unsupported: constant empty snapshot. */
203
+ getServerSnapshot = () => EMPTY_SNAPSHOT;
204
+ /** SSR / unsupported: constant empty record. */
205
+ getServerSnapshots = () => EMPTY_SNAPSHOTS;
206
+ /** Union of every source's ranges currently registered under `name`. */
207
+ getRanges(name) {
208
+ const entry = this.#entries.get(name);
209
+ return entry ? this.#merge(entry) : [];
210
+ }
211
+ /**
212
+ * Register/replace a source's ranges under a name, then reconcile.
213
+ * `priority` applies to the whole name; the most recent call wins.
214
+ */
215
+ set(name, sourceId, ranges, priority = 0) {
216
+ if (!this.supported) return;
217
+ let entry = this.#entries.get(name);
218
+ if (!entry) {
219
+ entry = {
220
+ priority,
221
+ sources: /* @__PURE__ */ new Map()
222
+ };
223
+ this.#entries.set(name, entry);
224
+ } else entry.priority = priority;
225
+ entry.sources.set(sourceId, ranges);
226
+ this.#reconcile(name);
227
+ this.#emit();
228
+ }
229
+ /** Remove a single source's contribution to a name. */
230
+ remove(name, sourceId) {
231
+ const entry = this.#entries.get(name);
232
+ if (!entry || !entry.sources.delete(sourceId)) return;
233
+ if (entry.sources.size === 0) this.#entries.delete(name);
234
+ this.#reconcile(name);
235
+ this.#emit();
236
+ }
237
+ /** Drop a name entirely, regardless of sources. */
238
+ clear(name) {
239
+ if (!this.#entries.delete(name)) return;
240
+ this.#reconcile(name);
241
+ this.#emit();
242
+ }
243
+ /** Drop everything this controller manages. */
244
+ clearAll() {
245
+ const names = [...this.#entries.keys()];
246
+ this.#entries.clear();
247
+ for (const name of names) this.#sink.remove(name);
248
+ this.#snapshots.clear();
249
+ this.#emit();
250
+ }
251
+ #merge(entry) {
252
+ const all = [];
253
+ for (const ranges of entry.sources.values()) all.push(...ranges);
254
+ return all;
255
+ }
256
+ /** Union all sources for `name` into one highlight and update the snapshot. */
257
+ #reconcile(name) {
258
+ const entry = this.#entries.get(name);
259
+ const all = entry ? this.#merge(entry) : [];
260
+ if (!entry || all.length === 0) {
261
+ this.#sink.remove(name);
262
+ this.#snapshots.set(name, EMPTY_SNAPSHOT);
263
+ return;
264
+ }
265
+ this.#sink.commit(name, all, entry.priority);
266
+ this.#snapshots.set(name, {
267
+ active: true,
268
+ count: all.length
269
+ });
270
+ }
271
+ #emit() {
272
+ const next = {};
273
+ for (const [name, snapshot] of this.#snapshots) if (snapshot.active) next[name] = snapshot;
274
+ this.#allSnapshots = next;
275
+ for (const listener of this.#listeners) listener();
276
+ }
277
+ };
278
+ /**
279
+ * Create an isolated controller. Useful for tests (with {@link createNoopSink})
280
+ * or for scoping subscriptions via the React `HighlightProvider`. Highlight
281
+ * *names* are still document-global once painted by a CSS sink.
282
+ */
283
+ function createHighlightController(options) {
284
+ return new HighlightController(options);
285
+ }
286
+ /** The shared singleton. */
287
+ const highlights = createHighlightController();
288
+ /**
289
+ * Build `::highlight(name)` CSS rules from a style map without injecting them.
290
+ *
291
+ * camelCase property names are converted to kebab-case; values are written as is.
292
+ *
293
+ * @example
294
+ * ```ts
295
+ * import { generateHighlightCSS } from '@cbcruk/highlight-kit'
296
+ *
297
+ * generateHighlightCSS({ search: { backgroundColor: 'yellow' } })
298
+ * // '::highlight(search) {\n background-color: yellow;\n}'
299
+ * ```
300
+ */
301
+ function generateHighlightCSS(styles) {
302
+ return Object.entries(styles).map(([name, style]) => {
303
+ return `::highlight(${name}) {\n${Object.entries(style).map(([prop, value]) => {
304
+ return ` ${prop.replace(/([A-Z])/g, "-$1").toLowerCase()}: ${value};`;
305
+ }).join("\n")}\n}`;
306
+ }).join("\n\n");
307
+ }
308
+ /**
309
+ * Append a `<style>` element with `::highlight()` rules to `document.head`.
310
+ *
311
+ * An existing element with the same `id` is removed first, so calling it again
312
+ * replaces the previous rules.
313
+ *
314
+ * @param styles - Style declarations keyed by highlight name
315
+ * @param id - `id` of the `<style>` element
316
+ * @returns The inserted `<style>` element
317
+ */
318
+ function injectHighlightStyles(styles, id = "highlight-kit-styles") {
319
+ document.getElementById(id)?.remove();
320
+ const el = document.createElement("style");
321
+ el.id = id;
322
+ el.textContent = generateHighlightCSS(styles);
323
+ document.head.appendChild(el);
324
+ return el;
325
+ }
326
+ //#endregion
327
+ //#region src/tokenize.ts
328
+ /**
329
+ * Pure tokenizer: a string plus an ordered rule list in, character spans out.
330
+ *
331
+ * Nothing here touches the DOM, the `CSS.highlights` registry, or the
332
+ * `OpaqueRange` API, so the overlap rules — the part that is easy to get wrong —
333
+ * are testable without a browser. {@link ./value-range.ts} turns the spans this
334
+ * produces into live ranges inside an `<input>` or `<textarea>`.
335
+ */
336
+ /**
337
+ * Compile a rule to a fresh RegExp carrying `flag`.
338
+ *
339
+ * Always clones, so a module-level `RegExp` literal passed as a pattern never
340
+ * has its `lastIndex` mutated by tokenizing.
341
+ */
342
+ function compile(rule, flag) {
343
+ const { pattern, caseSensitive, wholeWord } = rule;
344
+ if (pattern instanceof RegExp) {
345
+ const flags = withoutFlag(withoutFlag(pattern.flags, "y"), "g");
346
+ return new RegExp(pattern.source, flags + flag);
347
+ }
348
+ return new RegExp(literalSource(pattern, wholeWord), caseSensitive ? flag : `i${flag}`);
349
+ }
350
+ /**
351
+ * Left-to-right scan. At each position the first rule that matches there wins
352
+ * and the scan continues after its match, so tokens never overlap.
353
+ */
354
+ function tokenizeFirst(value, rules) {
355
+ const regexps = rules.map((rule) => compile(rule, "y"));
356
+ const tokens = [];
357
+ let position = 0;
358
+ while (position < value.length) {
359
+ let width = 0;
360
+ for (let i = 0; i < regexps.length; i++) {
361
+ const regex = regexps[i];
362
+ regex.lastIndex = position;
363
+ const match = regex.exec(value);
364
+ if (match && match[0].length > 0) {
365
+ width = match[0].length;
366
+ tokens.push({
367
+ name: rules[i].name,
368
+ start: position,
369
+ end: position + width,
370
+ rule: i
371
+ });
372
+ break;
373
+ }
374
+ }
375
+ position += width || 1;
376
+ }
377
+ return tokens;
378
+ }
379
+ /** Every rule scans the whole string; overlaps are kept. */
380
+ function tokenizeAll(value, rules) {
381
+ const tokens = [];
382
+ rules.forEach((rule, i) => {
383
+ const regex = compile(rule, "g");
384
+ let match;
385
+ while ((match = regex.exec(value)) !== null) {
386
+ if (match[0].length === 0) {
387
+ regex.lastIndex++;
388
+ continue;
389
+ }
390
+ tokens.push({
391
+ name: rule.name,
392
+ start: match.index,
393
+ end: match.index + match[0].length,
394
+ rule: i
395
+ });
396
+ }
397
+ });
398
+ return tokens.sort((a, b) => a.start - b.start || a.rule - b.rule);
399
+ }
400
+ /**
401
+ * Match `rules` against `value` and return the character spans of every match.
402
+ *
403
+ * Offsets are UTF-16 code unit indices into `value`, the same units
404
+ * `selectionStart` uses, so they can be handed straight to
405
+ * `createValueRange()` when `value` came from `element.value`.
406
+ *
407
+ * Rule order is precedence: see {@link TokenizeOptions.overlap}.
408
+ *
409
+ * @param value - Text to scan, typically `element.value`
410
+ * @param rules - Ordered rules; earlier rules win under `'first'`
411
+ * @returns Tokens sorted by start offset. Empty when `value` or `rules` is empty
412
+ *
413
+ * @example A comment rule swallowing keywords inside it
414
+ * ```ts
415
+ * import { tokenizeValue } from '@cbcruk/highlight-kit'
416
+ *
417
+ * tokenizeValue('// return 1', [
418
+ * { name: 'comment', pattern: /\/\/.*$/m },
419
+ * { name: 'keyword', pattern: /\breturn\b/ },
420
+ * ])
421
+ * // [{ name: 'comment', start: 0, end: 11, rule: 0 }]
422
+ * ```
423
+ *
424
+ * @example Independent patterns, overlaps kept
425
+ * ```ts
426
+ * import { tokenizeValue } from '@cbcruk/highlight-kit'
427
+ *
428
+ * tokenizeValue('status:open', [
429
+ * { name: 'field', pattern: /\w+:/ },
430
+ * { name: 'all', pattern: /\w+/ },
431
+ * ], { overlap: 'all' })
432
+ * ```
433
+ */
434
+ function tokenizeValue(value, rules, options = {}) {
435
+ if (!value || rules.length === 0) return [];
436
+ return options.overlap === "all" ? tokenizeAll(value, rules) : tokenizeFirst(value, rules);
437
+ }
438
+ /**
439
+ * Collapse tokens into one entry per highlight name.
440
+ *
441
+ * Several rules may share a name; the resulting `priority` is the highest one
442
+ * any contributing rule declared, since a controller tracks a single priority
443
+ * per name.
444
+ *
445
+ * @param tokens - Tokens from {@link tokenizeValue}
446
+ * @param rules - The same rule list those tokens were produced from
447
+ * @returns One entry per name that matched at least once, in first-match order
448
+ */
449
+ function groupTokens(tokens, rules) {
450
+ const grouped = /* @__PURE__ */ new Map();
451
+ for (const token of tokens) {
452
+ const priority = rules[token.rule]?.priority ?? 0;
453
+ const span = {
454
+ start: token.start,
455
+ end: token.end
456
+ };
457
+ const entry = grouped.get(token.name);
458
+ if (entry) {
459
+ entry.priority = Math.max(entry.priority, priority);
460
+ entry.spans.push(span);
461
+ } else grouped.set(token.name, {
462
+ name: token.name,
463
+ priority,
464
+ spans: [span]
465
+ });
466
+ }
467
+ return [...grouped.values()];
468
+ }
469
+ //#endregion
470
+ //#region src/value-range.ts
471
+ /**
472
+ * `OpaqueRange` adapter — highlighting text *inside* `<input>` and `<textarea>`.
473
+ *
474
+ * A DOM `Range` cannot point into a form control's value, so the rest of this
475
+ * package (`computeRanges`, `getTextNodes`) does nothing for one. Chromium 152
476
+ * added `element.createValueRange(start, end)`, which returns an `OpaqueRange`
477
+ * over the control's value. `Highlight` is setlike over `AbstractRange`, so
478
+ * those ranges can go into the same highlight as ordinary DOM ranges.
479
+ *
480
+ * The lifecycle is the part worth wrapping. Value ranges are *live*: the control
481
+ * keeps every range `createValueRange()` ever handed out and shifts all of their
482
+ * offsets on every edit. Re-matching a pattern after each keystroke therefore
483
+ * leaks a fresh generation of live ranges per keystroke unless the previous
484
+ * generation is explicitly released, which is what {@link ValueHighlighter}
485
+ * does and what hand-rolled `highlight.clear()` loops usually miss.
486
+ */
487
+ /**
488
+ * `input` types for which `createValueRange()` applies. Every other type throws
489
+ * `NotSupportedError`. Matches the types the Selection API applies to.
490
+ */
491
+ const SUPPORTED_INPUT_TYPES = /* @__PURE__ */ new Set([
492
+ "text",
493
+ "search",
494
+ "tel",
495
+ "url",
496
+ "password"
497
+ ]);
498
+ /**
499
+ * Whether this browser implements `createValueRange()`.
500
+ *
501
+ * True only says the *engine* supports the API. It does not say a given element
502
+ * does — the method exists on `HTMLInputElement.prototype` for every input
503
+ * type and throws at call time for unsupported ones. Use
504
+ * {@link supportsValueRange} for a specific element.
505
+ */
506
+ function isValueRangeSupported() {
507
+ return typeof HTMLInputElement !== "undefined" && typeof HTMLInputElement.prototype.createValueRange === "function";
508
+ }
509
+ /**
510
+ * Whether `element` can produce value ranges: a `<textarea>`, or an `<input>`
511
+ * whose `type` is `text`, `search`, `tel`, `url`, or `password`.
512
+ *
513
+ * @param element - Element to test; `null`/`undefined` returns false
514
+ */
515
+ function supportsValueRange(element) {
516
+ if (!element || !isValueRangeSupported()) return false;
517
+ if (element instanceof HTMLTextAreaElement) return true;
518
+ return element instanceof HTMLInputElement && SUPPORTED_INPUT_TYPES.has(element.type);
519
+ }
520
+ /**
521
+ * Narrow a highlight range to a value range.
522
+ *
523
+ * Tests for "not a DOM `Range`", so it also answers whether reading
524
+ * `startContainer` is safe — on a value range it is `undefined`.
525
+ */
526
+ function isValueRange(range) {
527
+ return !(range instanceof Range);
528
+ }
529
+ /**
530
+ * Create live ranges over `element`'s value for each of `spans`.
531
+ *
532
+ * Offsets are clamped into `[0, element.value.length]` rather than throwing the
533
+ * `IndexSizeError` the API raises for out-of-range offsets, and spans that
534
+ * clamp to nothing are dropped — a collapsed range is never painted.
535
+ *
536
+ * Every returned range holds a reference from `element` until
537
+ * {@link disconnectValueRanges} releases it.
538
+ *
539
+ * @param element - A control {@link supportsValueRange} accepts
540
+ * @param spans - Half-open `[start, end)` character spans
541
+ * @returns One range per non-empty span. Empty when the element is unsupported
542
+ *
543
+ * @example
544
+ * ```ts
545
+ * import { createValueRanges, highlights } from '@cbcruk/highlight-kit'
546
+ *
547
+ * const textarea = document.querySelector('textarea')!
548
+ * const ranges = createValueRanges(textarea, [{ start: 0, end: 5 }])
549
+ * highlights.set('note', 'my-source', ranges)
550
+ * ```
551
+ */
552
+ function createValueRanges(element, spans) {
553
+ if (!supportsValueRange(element)) return [];
554
+ const length = element.value.length;
555
+ const host = element;
556
+ const ranges = [];
557
+ for (const { start, end } of spans) {
558
+ const from = Math.max(0, Math.min(start, length));
559
+ const to = Math.max(from, Math.min(end, length));
560
+ if (from === to) continue;
561
+ try {
562
+ ranges.push(host.createValueRange(from, to));
563
+ } catch {}
564
+ }
565
+ return ranges;
566
+ }
567
+ /**
568
+ * Release `ranges` so their control stops tracking them.
569
+ *
570
+ * Safe to call twice, and on ranges already auto-disconnected by the element
571
+ * being removed or its `type` changing.
572
+ */
573
+ function disconnectValueRanges(ranges) {
574
+ for (const range of ranges) try {
575
+ range.disconnect?.();
576
+ } catch {}
577
+ }
578
+ /**
579
+ * Create a registry that keeps one generation of value ranges on `element`.
580
+ *
581
+ * Use it when you compute spans yourself; {@link createValueHighlighter} layers
582
+ * the tokenizer and an `input` listener on top.
583
+ *
584
+ * @example Marking a single span and replacing it later
585
+ * ```ts
586
+ * import { createValueRangeRegistry } from '@cbcruk/highlight-kit'
587
+ *
588
+ * const registry = createValueRangeRegistry({ element: textarea })
589
+ * registry.commit([{ name: 'note', priority: 0, spans: [{ start: 0, end: 4 }] }])
590
+ * registry.commit([{ name: 'note', priority: 0, spans: [{ start: 6, end: 9 }] }])
591
+ * registry.dispose()
592
+ * ```
593
+ */
594
+ function createValueRangeRegistry({ element, controller = highlights, sourceId = Symbol("value-range-registry") }) {
595
+ const supported = supportsValueRange(element);
596
+ let owned = [];
597
+ let names = [];
598
+ let disposed = false;
599
+ return {
600
+ supported,
601
+ get names() {
602
+ return names;
603
+ },
604
+ commit(groups) {
605
+ if (disposed || !supported) return;
606
+ const previous = owned;
607
+ const next = [];
608
+ const nextNames = [];
609
+ for (const group of groups) {
610
+ const ranges = createValueRanges(element, group.spans);
611
+ if (ranges.length === 0) continue;
612
+ controller.set(group.name, sourceId, ranges, group.priority);
613
+ next.push(...ranges);
614
+ nextNames.push(group.name);
615
+ }
616
+ for (const name of names) if (!nextNames.includes(name)) controller.remove(name, sourceId);
617
+ owned = next;
618
+ names = nextNames;
619
+ disconnectValueRanges(previous);
620
+ },
621
+ dispose() {
622
+ if (disposed) return;
623
+ disposed = true;
624
+ for (const name of names) controller.remove(name, sourceId);
625
+ disconnectValueRanges(owned);
626
+ owned = [];
627
+ names = [];
628
+ }
629
+ };
630
+ }
631
+ /**
632
+ * Keep a form control's value tokenized and highlighted.
633
+ *
634
+ * Tokenizes once on creation and then on every `input` event, registering one
635
+ * highlight name per rule name and releasing the previous generation of ranges.
636
+ *
637
+ * Does nothing when the browser or the element is unsupported, so calling it
638
+ * unconditionally is safe — read {@link ValueHighlighter.supported} to decide
639
+ * whether to render a fallback.
640
+ *
641
+ * @example Search-query syntax in a text input
642
+ * ```ts
643
+ * import {
644
+ * createValueHighlighter,
645
+ * injectHighlightStyles,
646
+ * } from '@cbcruk/highlight-kit'
647
+ *
648
+ * injectHighlightStyles({
649
+ * field: { color: '#2563eb' },
650
+ * quoted: { backgroundColor: '#dbeafe' },
651
+ * })
652
+ *
653
+ * const input = document.querySelector('input')!
654
+ * const highlighter = createValueHighlighter({
655
+ * element: input,
656
+ * rules: [
657
+ * { name: 'quoted', pattern: /"[^"]*"/ },
658
+ * { name: 'field', pattern: /\b\w+:/ },
659
+ * ],
660
+ * })
661
+ *
662
+ * highlighter.dispose()
663
+ * ```
664
+ */
665
+ function createValueHighlighter({ rules, overlap, observe = true, ...binding }) {
666
+ const registry = createValueRangeRegistry(binding);
667
+ const { element } = binding;
668
+ function refresh() {
669
+ const tokens = tokenizeValue(element.value, rules, { overlap });
670
+ registry.commit(groupTokens(tokens, rules));
671
+ }
672
+ const onInput = () => refresh();
673
+ refresh();
674
+ if (registry.supported && observe) element.addEventListener("input", onInput);
675
+ return {
676
+ supported: registry.supported,
677
+ get names() {
678
+ return registry.names;
679
+ },
680
+ refresh,
681
+ dispose() {
682
+ element.removeEventListener("input", onInput);
683
+ registry.dispose();
684
+ }
685
+ };
686
+ }
687
+ /**
688
+ * Scroll `element` just far enough for `range` to be visible inside it.
689
+ *
690
+ * `scrollIntoView()` is not an option: a value range has no node to call it on.
691
+ * This adjusts the control's own `scrollTop`/`scrollLeft` from the range's
692
+ * client rect, and unlike `setSelectionRange()` it leaves the user's selection
693
+ * and focus alone.
694
+ *
695
+ * No-op for a collapsed or disconnected range, whose rect is empty.
696
+ */
697
+ function scrollValueRangeIntoView(element, range) {
698
+ const target = range.getBoundingClientRect();
699
+ if (target.width === 0 && target.height === 0) return;
700
+ const host = element.getBoundingClientRect();
701
+ if (target.top < host.top) element.scrollTop -= host.top - target.top;
702
+ else if (target.bottom > host.bottom) element.scrollTop += target.bottom - host.bottom;
703
+ if (target.left < host.left) element.scrollLeft -= host.left - target.left;
704
+ else if (target.right > host.right) element.scrollLeft += target.right - host.right;
705
+ }
706
+ //#endregion
707
+ export { highlights as _, isValueRange as a, rangesFromOffsets as b, supportsValueRange as c, computeRanges as d, createCssHighlightSink as f, getTextNodes as g, generateHighlightCSS as h, disconnectValueRanges as i, groupTokens as l, createNoopSink as m, createValueRangeRegistry as n, isValueRangeSupported as o, createHighlightController as p, createValueRanges as r, scrollValueRangeIntoView as s, createValueHighlighter as t, tokenizeValue as u, injectHighlightStyles as v, isHighlightSupported as y };