@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.
- package/README.md +212 -8
- package/dist/core-DDCXN2b_.d.ts +527 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/react.d.ts +203 -1
- package/dist/react.js +300 -2
- package/dist/value-range-CH2vegQi.js +707 -0
- package/package.json +1 -1
- package/dist/core-B8g04jvB.d.ts +0 -168
- package/dist/core-LUnH63zG.js +0 -306
|
@@ -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 };
|