@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
package/package.json
CHANGED
package/dist/core-B8g04jvB.d.ts
DELETED
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
//#region src/core.d.ts
|
|
2
|
-
/** Options controlling how a string pattern is matched against text. */
|
|
3
|
-
interface MatchOptions {
|
|
4
|
-
/**
|
|
5
|
-
* Case sensitive match. Ignored for `RegExp` patterns, which keep their own flags.
|
|
6
|
-
* @default false
|
|
7
|
-
*/
|
|
8
|
-
caseSensitive?: boolean;
|
|
9
|
-
/**
|
|
10
|
-
* Match whole words only (wraps the pattern in `\b`). Ignored for `RegExp` patterns.
|
|
11
|
-
* @default false
|
|
12
|
-
*/
|
|
13
|
-
wholeWord?: boolean;
|
|
14
|
-
}
|
|
15
|
-
/** Reactive state of one highlight name, as exposed to subscribers. */
|
|
16
|
-
interface HighlightSnapshot {
|
|
17
|
-
/** Whether this name currently has any registered ranges */
|
|
18
|
-
readonly active: boolean;
|
|
19
|
-
/** Number of ranges registered under this name */
|
|
20
|
-
readonly count: number;
|
|
21
|
-
}
|
|
22
|
-
/** A source contributing ranges, keyed by a unique id (e.g. React's useId) */
|
|
23
|
-
type SourceId = string | symbol;
|
|
24
|
-
/**
|
|
25
|
-
* Where reconciled ranges are written. The controller's bookkeeping always
|
|
26
|
-
* runs; only this side effect is swappable (CSS registry, no-op for tests/SSR).
|
|
27
|
-
*/
|
|
28
|
-
interface HighlightSink {
|
|
29
|
-
/** Replace the registered highlight for `name` with `ranges`. */
|
|
30
|
-
commit(name: string, ranges: Range[], priority: number): void;
|
|
31
|
-
/** Drop the registered highlight for `name`. */
|
|
32
|
-
remove(name: string): void;
|
|
33
|
-
/**
|
|
34
|
-
* When this returns false the controller skips bookkeeping entirely, so
|
|
35
|
-
* snapshots stay empty. Omit it to always track ranges.
|
|
36
|
-
*/
|
|
37
|
-
isSupported?(): boolean;
|
|
38
|
-
}
|
|
39
|
-
/** Options for {@link createHighlightController}. */
|
|
40
|
-
interface HighlightControllerOptions {
|
|
41
|
-
/** Where reconciled ranges are written. Defaults to {@link createCssHighlightSink}. */
|
|
42
|
-
sink?: HighlightSink;
|
|
43
|
-
}
|
|
44
|
-
/** Whether the CSS Custom Highlight API (`CSS.highlights` and `Highlight`) is available. */
|
|
45
|
-
declare function isHighlightSupported(): boolean;
|
|
46
|
-
/**
|
|
47
|
-
* Collect the text nodes under an element that contain non-whitespace text.
|
|
48
|
-
*
|
|
49
|
-
* Whitespace-only nodes (newlines, indentation between elements) are skipped,
|
|
50
|
-
* so this is suited to pattern matching, not to offset math against `textContent`.
|
|
51
|
-
*/
|
|
52
|
-
declare function getTextNodes(root: Node): Text[];
|
|
53
|
-
/**
|
|
54
|
-
* Compute Range objects for every match of `pattern` within `root`.
|
|
55
|
-
* Pure: returns ranges, does not touch the registry.
|
|
56
|
-
*/
|
|
57
|
-
declare function computeRanges(root: Element, pattern: string | RegExp, options?: MatchOptions): Range[];
|
|
58
|
-
/**
|
|
59
|
-
* Map flat character offsets onto Range objects. Useful when you already know positions.
|
|
60
|
-
*
|
|
61
|
-
* Offsets count over `root.textContent`, whitespace-only text nodes (newlines,
|
|
62
|
-
* indentation between elements) included, so positions computed from
|
|
63
|
-
* `textContent` or on a server against the same text line up. Unlike
|
|
64
|
-
* {@link computeRanges}, a span may cross text node boundaries; it yields one
|
|
65
|
-
* range per text node it touches. Out-of-bounds parts are ignored.
|
|
66
|
-
*
|
|
67
|
-
* @example
|
|
68
|
-
* ```ts
|
|
69
|
-
* import { rangesFromOffsets } from '@cbcruk/highlight-kit'
|
|
70
|
-
*
|
|
71
|
-
* const el = document.querySelector('#article')!
|
|
72
|
-
* const start = el.textContent!.indexOf('wisdom')
|
|
73
|
-
* rangesFromOffsets(el, [{ start, end: start + 'wisdom'.length }])
|
|
74
|
-
* ```
|
|
75
|
-
*/
|
|
76
|
-
declare function rangesFromOffsets(root: Element, spans: ReadonlyArray<{
|
|
77
|
-
start: number;
|
|
78
|
-
end: number;
|
|
79
|
-
}>): Range[];
|
|
80
|
-
/** Sink that writes to the document-global `CSS.highlights` registry. */
|
|
81
|
-
declare function createCssHighlightSink(): HighlightSink;
|
|
82
|
-
/** Side-effect-free sink: bookkeeping still runs, nothing is painted. */
|
|
83
|
-
declare function createNoopSink(): HighlightSink;
|
|
84
|
-
/**
|
|
85
|
-
* Tracks ranges per highlight name and source, and writes their union to a sink.
|
|
86
|
-
*
|
|
87
|
-
* Multiple sources may contribute to one name; each change reconciles the name
|
|
88
|
-
* into a single highlight and notifies `subscribe` listeners. Create instances
|
|
89
|
-
* with {@link createHighlightController} or use the shared {@link highlights}.
|
|
90
|
-
*
|
|
91
|
-
* @example
|
|
92
|
-
* ```ts
|
|
93
|
-
* import { computeRanges, highlights } from '@cbcruk/highlight-kit'
|
|
94
|
-
*
|
|
95
|
-
* const el = document.querySelector('#article')!
|
|
96
|
-
* highlights.set('search', 'my-source', computeRanges(el, 'wisdom'))
|
|
97
|
-
* highlights.getSnapshot('search') // { active: true, count: ... }
|
|
98
|
-
* highlights.remove('search', 'my-source')
|
|
99
|
-
* ```
|
|
100
|
-
*/
|
|
101
|
-
declare class HighlightController {
|
|
102
|
-
#private;
|
|
103
|
-
/** Create a controller that writes to `sink` (the CSS registry sink by default). */
|
|
104
|
-
constructor({ sink }?: HighlightControllerOptions);
|
|
105
|
-
/**
|
|
106
|
-
* Whether the sink reports support. `true` when the sink has no `isSupported`.
|
|
107
|
-
* When `false`, {@link HighlightController.set} is a no-op.
|
|
108
|
-
*/
|
|
109
|
-
get supported(): boolean;
|
|
110
|
-
/** useSyncExternalStore: stable identity (arrow field on the instance). */
|
|
111
|
-
subscribe: (listener: () => void) => (() => void);
|
|
112
|
-
/** Snapshot for a given highlight name (referentially stable). */
|
|
113
|
-
getSnapshot: (name: string) => HighlightSnapshot;
|
|
114
|
-
/** Snapshots of every active name (referentially stable between changes). */
|
|
115
|
-
getSnapshots: () => Readonly<Record<string, HighlightSnapshot>>;
|
|
116
|
-
/** SSR / unsupported: constant empty snapshot. */
|
|
117
|
-
getServerSnapshot: () => HighlightSnapshot;
|
|
118
|
-
/** SSR / unsupported: constant empty record. */
|
|
119
|
-
getServerSnapshots: () => Readonly<Record<string, HighlightSnapshot>>;
|
|
120
|
-
/** Union of every source's ranges currently registered under `name`. */
|
|
121
|
-
getRanges(name: string): Range[];
|
|
122
|
-
/**
|
|
123
|
-
* Register/replace a source's ranges under a name, then reconcile.
|
|
124
|
-
* `priority` applies to the whole name; the most recent call wins.
|
|
125
|
-
*/
|
|
126
|
-
set(name: string, sourceId: SourceId, ranges: Range[], priority?: number): void;
|
|
127
|
-
/** Remove a single source's contribution to a name. */
|
|
128
|
-
remove(name: string, sourceId: SourceId): void;
|
|
129
|
-
/** Drop a name entirely, regardless of sources. */
|
|
130
|
-
clear(name: string): void;
|
|
131
|
-
/** Drop everything this controller manages. */
|
|
132
|
-
clearAll(): void;
|
|
133
|
-
}
|
|
134
|
-
/**
|
|
135
|
-
* Create an isolated controller. Useful for tests (with {@link createNoopSink})
|
|
136
|
-
* or for scoping subscriptions via the React `HighlightProvider`. Highlight
|
|
137
|
-
* *names* are still document-global once painted by a CSS sink.
|
|
138
|
-
*/
|
|
139
|
-
declare function createHighlightController(options?: HighlightControllerOptions): HighlightController;
|
|
140
|
-
/** The shared singleton. */
|
|
141
|
-
declare const highlights: HighlightController;
|
|
142
|
-
/**
|
|
143
|
-
* Build `::highlight(name)` CSS rules from a style map without injecting them.
|
|
144
|
-
*
|
|
145
|
-
* camelCase property names are converted to kebab-case; values are written as is.
|
|
146
|
-
*
|
|
147
|
-
* @example
|
|
148
|
-
* ```ts
|
|
149
|
-
* import { generateHighlightCSS } from '@cbcruk/highlight-kit'
|
|
150
|
-
*
|
|
151
|
-
* generateHighlightCSS({ search: { backgroundColor: 'yellow' } })
|
|
152
|
-
* // '::highlight(search) {\n background-color: yellow;\n}'
|
|
153
|
-
* ```
|
|
154
|
-
*/
|
|
155
|
-
declare function generateHighlightCSS(styles: Record<string, Partial<CSSStyleDeclaration>>): string;
|
|
156
|
-
/**
|
|
157
|
-
* Append a `<style>` element with `::highlight()` rules to `document.head`.
|
|
158
|
-
*
|
|
159
|
-
* An existing element with the same `id` is removed first, so calling it again
|
|
160
|
-
* replaces the previous rules.
|
|
161
|
-
*
|
|
162
|
-
* @param styles - Style declarations keyed by highlight name
|
|
163
|
-
* @param id - `id` of the `<style>` element
|
|
164
|
-
* @returns The inserted `<style>` element
|
|
165
|
-
*/
|
|
166
|
-
declare function injectHighlightStyles(styles: Record<string, Partial<CSSStyleDeclaration>>, id?: string): HTMLStyleElement;
|
|
167
|
-
//#endregion
|
|
168
|
-
export { MatchOptions as a, createCssHighlightSink as c, generateHighlightCSS as d, getTextNodes as f, rangesFromOffsets as g, isHighlightSupported as h, HighlightSnapshot as i, createHighlightController as l, injectHighlightStyles as m, HighlightControllerOptions as n, SourceId as o, highlights as p, HighlightSink as r, computeRanges as s, HighlightController as t, createNoopSink as u };
|
package/dist/core-LUnH63zG.js
DELETED
|
@@ -1,306 +0,0 @@
|
|
|
1
|
-
//#region src/core.ts
|
|
2
|
-
/** Stable empty snapshot — referentially constant for useSyncExternalStore */
|
|
3
|
-
const EMPTY_SNAPSHOT = Object.freeze({
|
|
4
|
-
active: false,
|
|
5
|
-
count: 0
|
|
6
|
-
});
|
|
7
|
-
/** Whether the CSS Custom Highlight API (`CSS.highlights` and `Highlight`) is available. */
|
|
8
|
-
function isHighlightSupported() {
|
|
9
|
-
return typeof CSS !== "undefined" && "highlights" in CSS && typeof Highlight !== "undefined";
|
|
10
|
-
}
|
|
11
|
-
/**
|
|
12
|
-
* Collect the text nodes under an element that contain non-whitespace text.
|
|
13
|
-
*
|
|
14
|
-
* Whitespace-only nodes (newlines, indentation between elements) are skipped,
|
|
15
|
-
* so this is suited to pattern matching, not to offset math against `textContent`.
|
|
16
|
-
*/
|
|
17
|
-
function getTextNodes(root) {
|
|
18
|
-
const nodes = [];
|
|
19
|
-
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
|
|
20
|
-
let node;
|
|
21
|
-
while (node = walker.nextNode()) if (node.textContent && node.textContent.trim()) nodes.push(node);
|
|
22
|
-
return nodes;
|
|
23
|
-
}
|
|
24
|
-
function toRegExp(pattern, options) {
|
|
25
|
-
if (pattern instanceof RegExp) return pattern.flags.includes("g") ? pattern : new RegExp(pattern.source, pattern.flags + "g");
|
|
26
|
-
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
27
|
-
const body = options.wholeWord ? `\\b${escaped}\\b` : escaped;
|
|
28
|
-
return new RegExp(body, options.caseSensitive ? "g" : "gi");
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* Compute Range objects for every match of `pattern` within `root`.
|
|
32
|
-
* Pure: returns ranges, does not touch the registry.
|
|
33
|
-
*/
|
|
34
|
-
function computeRanges(root, pattern, options = {}) {
|
|
35
|
-
if (!pattern) return [];
|
|
36
|
-
const regex = toRegExp(pattern, options);
|
|
37
|
-
const ranges = [];
|
|
38
|
-
for (const textNode of getTextNodes(root)) {
|
|
39
|
-
const text = textNode.textContent ?? "";
|
|
40
|
-
regex.lastIndex = 0;
|
|
41
|
-
let m;
|
|
42
|
-
while ((m = regex.exec(text)) !== null) {
|
|
43
|
-
if (m[0].length === 0) {
|
|
44
|
-
regex.lastIndex++;
|
|
45
|
-
continue;
|
|
46
|
-
}
|
|
47
|
-
try {
|
|
48
|
-
const range = new Range();
|
|
49
|
-
range.setStart(textNode, m.index);
|
|
50
|
-
range.setEnd(textNode, m.index + m[0].length);
|
|
51
|
-
ranges.push(range);
|
|
52
|
-
} catch {}
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
return ranges;
|
|
56
|
-
}
|
|
57
|
-
/** Collect every text node under `root`, whitespace-only ones included. */
|
|
58
|
-
function getAllTextNodes(root) {
|
|
59
|
-
const nodes = [];
|
|
60
|
-
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
|
|
61
|
-
let node;
|
|
62
|
-
while (node = walker.nextNode()) nodes.push(node);
|
|
63
|
-
return nodes;
|
|
64
|
-
}
|
|
65
|
-
/**
|
|
66
|
-
* Map flat character offsets onto Range objects. Useful when you already know positions.
|
|
67
|
-
*
|
|
68
|
-
* Offsets count over `root.textContent`, whitespace-only text nodes (newlines,
|
|
69
|
-
* indentation between elements) included, so positions computed from
|
|
70
|
-
* `textContent` or on a server against the same text line up. Unlike
|
|
71
|
-
* {@link computeRanges}, a span may cross text node boundaries; it yields one
|
|
72
|
-
* range per text node it touches. Out-of-bounds parts are ignored.
|
|
73
|
-
*
|
|
74
|
-
* @example
|
|
75
|
-
* ```ts
|
|
76
|
-
* import { rangesFromOffsets } from '@cbcruk/highlight-kit'
|
|
77
|
-
*
|
|
78
|
-
* const el = document.querySelector('#article')!
|
|
79
|
-
* const start = el.textContent!.indexOf('wisdom')
|
|
80
|
-
* rangesFromOffsets(el, [{ start, end: start + 'wisdom'.length }])
|
|
81
|
-
* ```
|
|
82
|
-
*/
|
|
83
|
-
function rangesFromOffsets(root, spans) {
|
|
84
|
-
const nodes = getAllTextNodes(root);
|
|
85
|
-
const layout = [];
|
|
86
|
-
let offset = 0;
|
|
87
|
-
for (const node of nodes) {
|
|
88
|
-
const len = node.textContent?.length ?? 0;
|
|
89
|
-
if (len === 0) continue;
|
|
90
|
-
layout.push({
|
|
91
|
-
node,
|
|
92
|
-
start: offset,
|
|
93
|
-
end: offset + len
|
|
94
|
-
});
|
|
95
|
-
offset += len;
|
|
96
|
-
}
|
|
97
|
-
const ranges = [];
|
|
98
|
-
for (const { start, end } of spans) for (const { node, start: ns, end: ne } of layout) if (ns < end && ne > start) try {
|
|
99
|
-
const range = new Range();
|
|
100
|
-
range.setStart(node, Math.max(0, start - ns));
|
|
101
|
-
range.setEnd(node, Math.min(node.textContent?.length ?? 0, end - ns));
|
|
102
|
-
ranges.push(range);
|
|
103
|
-
} catch {}
|
|
104
|
-
return ranges;
|
|
105
|
-
}
|
|
106
|
-
/** Sink that writes to the document-global `CSS.highlights` registry. */
|
|
107
|
-
function createCssHighlightSink() {
|
|
108
|
-
return {
|
|
109
|
-
commit(name, ranges, priority) {
|
|
110
|
-
if (!isHighlightSupported()) return;
|
|
111
|
-
const highlight = new Highlight(...ranges);
|
|
112
|
-
highlight.priority = priority;
|
|
113
|
-
CSS.highlights.set(name, highlight);
|
|
114
|
-
},
|
|
115
|
-
remove(name) {
|
|
116
|
-
if (!isHighlightSupported()) return;
|
|
117
|
-
CSS.highlights.delete(name);
|
|
118
|
-
},
|
|
119
|
-
isSupported: isHighlightSupported
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
/** Side-effect-free sink: bookkeeping still runs, nothing is painted. */
|
|
123
|
-
function createNoopSink() {
|
|
124
|
-
return {
|
|
125
|
-
commit() {},
|
|
126
|
-
remove() {}
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
const EMPTY_SNAPSHOTS = Object.freeze({});
|
|
130
|
-
/**
|
|
131
|
-
* Tracks ranges per highlight name and source, and writes their union to a sink.
|
|
132
|
-
*
|
|
133
|
-
* Multiple sources may contribute to one name; each change reconciles the name
|
|
134
|
-
* into a single highlight and notifies `subscribe` listeners. Create instances
|
|
135
|
-
* with {@link createHighlightController} or use the shared {@link highlights}.
|
|
136
|
-
*
|
|
137
|
-
* @example
|
|
138
|
-
* ```ts
|
|
139
|
-
* import { computeRanges, highlights } from '@cbcruk/highlight-kit'
|
|
140
|
-
*
|
|
141
|
-
* const el = document.querySelector('#article')!
|
|
142
|
-
* highlights.set('search', 'my-source', computeRanges(el, 'wisdom'))
|
|
143
|
-
* highlights.getSnapshot('search') // { active: true, count: ... }
|
|
144
|
-
* highlights.remove('search', 'my-source')
|
|
145
|
-
* ```
|
|
146
|
-
*/
|
|
147
|
-
var HighlightController = class {
|
|
148
|
-
#sink;
|
|
149
|
-
/** name -> (sourceId -> ranges). Multiple sources may share a name. */
|
|
150
|
-
#entries = /* @__PURE__ */ new Map();
|
|
151
|
-
/** Cached per-name snapshots; refs are stable until that name changes. */
|
|
152
|
-
#snapshots = /* @__PURE__ */ new Map();
|
|
153
|
-
/** Cached name -> snapshot record; rebuilt on every emit. */
|
|
154
|
-
#allSnapshots = EMPTY_SNAPSHOTS;
|
|
155
|
-
/** External-store listeners. */
|
|
156
|
-
#listeners = /* @__PURE__ */ new Set();
|
|
157
|
-
/** Create a controller that writes to `sink` (the CSS registry sink by default). */
|
|
158
|
-
constructor({ sink = createCssHighlightSink() } = {}) {
|
|
159
|
-
this.#sink = sink;
|
|
160
|
-
}
|
|
161
|
-
/**
|
|
162
|
-
* Whether the sink reports support. `true` when the sink has no `isSupported`.
|
|
163
|
-
* When `false`, {@link HighlightController.set} is a no-op.
|
|
164
|
-
*/
|
|
165
|
-
get supported() {
|
|
166
|
-
return this.#sink.isSupported?.() ?? true;
|
|
167
|
-
}
|
|
168
|
-
/** useSyncExternalStore: stable identity (arrow field on the instance). */
|
|
169
|
-
subscribe = (listener) => {
|
|
170
|
-
this.#listeners.add(listener);
|
|
171
|
-
return () => this.#listeners.delete(listener);
|
|
172
|
-
};
|
|
173
|
-
/** Snapshot for a given highlight name (referentially stable). */
|
|
174
|
-
getSnapshot = (name) => {
|
|
175
|
-
return this.#snapshots.get(name) ?? EMPTY_SNAPSHOT;
|
|
176
|
-
};
|
|
177
|
-
/** Snapshots of every active name (referentially stable between changes). */
|
|
178
|
-
getSnapshots = () => {
|
|
179
|
-
return this.#allSnapshots;
|
|
180
|
-
};
|
|
181
|
-
/** SSR / unsupported: constant empty snapshot. */
|
|
182
|
-
getServerSnapshot = () => EMPTY_SNAPSHOT;
|
|
183
|
-
/** SSR / unsupported: constant empty record. */
|
|
184
|
-
getServerSnapshots = () => EMPTY_SNAPSHOTS;
|
|
185
|
-
/** Union of every source's ranges currently registered under `name`. */
|
|
186
|
-
getRanges(name) {
|
|
187
|
-
const entry = this.#entries.get(name);
|
|
188
|
-
return entry ? this.#merge(entry) : [];
|
|
189
|
-
}
|
|
190
|
-
/**
|
|
191
|
-
* Register/replace a source's ranges under a name, then reconcile.
|
|
192
|
-
* `priority` applies to the whole name; the most recent call wins.
|
|
193
|
-
*/
|
|
194
|
-
set(name, sourceId, ranges, priority = 0) {
|
|
195
|
-
if (!this.supported) return;
|
|
196
|
-
let entry = this.#entries.get(name);
|
|
197
|
-
if (!entry) {
|
|
198
|
-
entry = {
|
|
199
|
-
priority,
|
|
200
|
-
sources: /* @__PURE__ */ new Map()
|
|
201
|
-
};
|
|
202
|
-
this.#entries.set(name, entry);
|
|
203
|
-
} else entry.priority = priority;
|
|
204
|
-
entry.sources.set(sourceId, ranges);
|
|
205
|
-
this.#reconcile(name);
|
|
206
|
-
this.#emit();
|
|
207
|
-
}
|
|
208
|
-
/** Remove a single source's contribution to a name. */
|
|
209
|
-
remove(name, sourceId) {
|
|
210
|
-
const entry = this.#entries.get(name);
|
|
211
|
-
if (!entry || !entry.sources.delete(sourceId)) return;
|
|
212
|
-
if (entry.sources.size === 0) this.#entries.delete(name);
|
|
213
|
-
this.#reconcile(name);
|
|
214
|
-
this.#emit();
|
|
215
|
-
}
|
|
216
|
-
/** Drop a name entirely, regardless of sources. */
|
|
217
|
-
clear(name) {
|
|
218
|
-
if (!this.#entries.delete(name)) return;
|
|
219
|
-
this.#reconcile(name);
|
|
220
|
-
this.#emit();
|
|
221
|
-
}
|
|
222
|
-
/** Drop everything this controller manages. */
|
|
223
|
-
clearAll() {
|
|
224
|
-
const names = [...this.#entries.keys()];
|
|
225
|
-
this.#entries.clear();
|
|
226
|
-
for (const name of names) this.#sink.remove(name);
|
|
227
|
-
this.#snapshots.clear();
|
|
228
|
-
this.#emit();
|
|
229
|
-
}
|
|
230
|
-
#merge(entry) {
|
|
231
|
-
const all = [];
|
|
232
|
-
for (const ranges of entry.sources.values()) all.push(...ranges);
|
|
233
|
-
return all;
|
|
234
|
-
}
|
|
235
|
-
/** Union all sources for `name` into one highlight and update the snapshot. */
|
|
236
|
-
#reconcile(name) {
|
|
237
|
-
const entry = this.#entries.get(name);
|
|
238
|
-
const all = entry ? this.#merge(entry) : [];
|
|
239
|
-
if (!entry || all.length === 0) {
|
|
240
|
-
this.#sink.remove(name);
|
|
241
|
-
this.#snapshots.set(name, EMPTY_SNAPSHOT);
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
this.#sink.commit(name, all, entry.priority);
|
|
245
|
-
this.#snapshots.set(name, {
|
|
246
|
-
active: true,
|
|
247
|
-
count: all.length
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
#emit() {
|
|
251
|
-
const next = {};
|
|
252
|
-
for (const [name, snapshot] of this.#snapshots) if (snapshot.active) next[name] = snapshot;
|
|
253
|
-
this.#allSnapshots = next;
|
|
254
|
-
for (const listener of this.#listeners) listener();
|
|
255
|
-
}
|
|
256
|
-
};
|
|
257
|
-
/**
|
|
258
|
-
* Create an isolated controller. Useful for tests (with {@link createNoopSink})
|
|
259
|
-
* or for scoping subscriptions via the React `HighlightProvider`. Highlight
|
|
260
|
-
* *names* are still document-global once painted by a CSS sink.
|
|
261
|
-
*/
|
|
262
|
-
function createHighlightController(options) {
|
|
263
|
-
return new HighlightController(options);
|
|
264
|
-
}
|
|
265
|
-
/** The shared singleton. */
|
|
266
|
-
const highlights = createHighlightController();
|
|
267
|
-
/**
|
|
268
|
-
* Build `::highlight(name)` CSS rules from a style map without injecting them.
|
|
269
|
-
*
|
|
270
|
-
* camelCase property names are converted to kebab-case; values are written as is.
|
|
271
|
-
*
|
|
272
|
-
* @example
|
|
273
|
-
* ```ts
|
|
274
|
-
* import { generateHighlightCSS } from '@cbcruk/highlight-kit'
|
|
275
|
-
*
|
|
276
|
-
* generateHighlightCSS({ search: { backgroundColor: 'yellow' } })
|
|
277
|
-
* // '::highlight(search) {\n background-color: yellow;\n}'
|
|
278
|
-
* ```
|
|
279
|
-
*/
|
|
280
|
-
function generateHighlightCSS(styles) {
|
|
281
|
-
return Object.entries(styles).map(([name, style]) => {
|
|
282
|
-
return `::highlight(${name}) {\n${Object.entries(style).map(([prop, value]) => {
|
|
283
|
-
return ` ${prop.replace(/([A-Z])/g, "-$1").toLowerCase()}: ${value};`;
|
|
284
|
-
}).join("\n")}\n}`;
|
|
285
|
-
}).join("\n\n");
|
|
286
|
-
}
|
|
287
|
-
/**
|
|
288
|
-
* Append a `<style>` element with `::highlight()` rules to `document.head`.
|
|
289
|
-
*
|
|
290
|
-
* An existing element with the same `id` is removed first, so calling it again
|
|
291
|
-
* replaces the previous rules.
|
|
292
|
-
*
|
|
293
|
-
* @param styles - Style declarations keyed by highlight name
|
|
294
|
-
* @param id - `id` of the `<style>` element
|
|
295
|
-
* @returns The inserted `<style>` element
|
|
296
|
-
*/
|
|
297
|
-
function injectHighlightStyles(styles, id = "highlight-kit-styles") {
|
|
298
|
-
document.getElementById(id)?.remove();
|
|
299
|
-
const el = document.createElement("style");
|
|
300
|
-
el.id = id;
|
|
301
|
-
el.textContent = generateHighlightCSS(styles);
|
|
302
|
-
document.head.appendChild(el);
|
|
303
|
-
return el;
|
|
304
|
-
}
|
|
305
|
-
//#endregion
|
|
306
|
-
export { generateHighlightCSS as a, injectHighlightStyles as c, createNoopSink as i, isHighlightSupported as l, createCssHighlightSink as n, getTextNodes as o, createHighlightController as r, highlights as s, computeRanges as t, rangesFromOffsets as u };
|