@cbcruk/highlight-kit 0.1.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 ADDED
@@ -0,0 +1,259 @@
1
+ # @cbcruk/highlight-kit
2
+
3
+ CSS Custom Highlight API 기반 텍스트 하이라이팅 라이브러리. DOM을 건드리지 않고
4
+ 텍스트를 하이라이트합니다. 프레임워크 무관 core와 얇은 React 어댑터를 제공합니다.
5
+
6
+ ## 설치
7
+
8
+ ```bash
9
+ pnpm add @cbcruk/highlight-kit
10
+ ```
11
+
12
+ ESM 전용 패키지이며 타입 정의가 함께 포함됩니다. React 어댑터(`@cbcruk/highlight-kit/react`)는
13
+ `react >= 18`을 필요로 하지만 **optional peer dependency**라, core만 쓰면 React 없이 동작합니다.
14
+
15
+ ## 사용법
16
+
17
+ ### Core (프레임워크 무관)
18
+
19
+ ```typescript
20
+ import {
21
+ highlights,
22
+ computeRanges,
23
+ injectHighlightStyles,
24
+ } from '@cbcruk/highlight-kit'
25
+
26
+ injectHighlightStyles({
27
+ search: { backgroundColor: '#fef08a', color: '#854d0e' },
28
+ })
29
+
30
+ const el = document.querySelector('#article')!
31
+ const ranges = computeRanges(el, 'wisdom', { caseSensitive: false })
32
+
33
+ // (name, sourceId, ranges) — 같은 name에 여러 source가 union 됨
34
+ highlights.set('search', 'my-source', ranges)
35
+
36
+ highlights.remove('search', 'my-source') // 한 source만 제거
37
+ highlights.clear('search') // name 통째로 제거
38
+ ```
39
+
40
+ 핵심: `set/remove/clear`는 name별로 여러 **source**의 기여를 합쳐 단일 `Highlight`로
41
+ reconcile합니다. 서로 다른 패널 두 곳에서 `'error'` 이름으로 하이라이트해도, CSS는
42
+ `::highlight(error)` 규칙 하나로 둘 다 스타일링됩니다.
43
+
44
+ ### React
45
+
46
+ #### 선언적 컴포넌트
47
+
48
+ ```tsx
49
+ import { Highlight } from '@cbcruk/highlight-kit/react'
50
+
51
+ function Article({ keyword }: { keyword: string }) {
52
+ return (
53
+ <Highlight query={keyword} name="search">
54
+ <article>{/* ...긴 본문... */}</article>
55
+ </Highlight>
56
+ )
57
+ }
58
+ ```
59
+
60
+ ```css
61
+ ::highlight(search) {
62
+ background: #fef08a;
63
+ color: #854d0e;
64
+ }
65
+ ```
66
+
67
+ `as` prop으로 wrapper 태그 변경, 레이아웃 영향을 없애려면 `display: contents`:
68
+
69
+ ```tsx
70
+ <Highlight query={q} name="search" as="section" style={{ display: 'contents' }}>
71
+ {children}
72
+ </Highlight>
73
+ ```
74
+
75
+ #### Headless 훅
76
+
77
+ ```tsx
78
+ import { useHighlight } from '@cbcruk/highlight-kit/react'
79
+
80
+ function SearchableText({ query }: { query: string }) {
81
+ const { ref, count, active } = useHighlight<HTMLDivElement>({
82
+ query,
83
+ name: 'search',
84
+ caseSensitive: false,
85
+ })
86
+
87
+ return (
88
+ <>
89
+ <span>{count}개 일치</span>
90
+ <div ref={ref}>{/* ...본문... */}</div>
91
+ </>
92
+ )
93
+ }
94
+ ```
95
+
96
+ `name`을 생략하면 `useId` 기반 고유 이름이 자동 생성됩니다(인스턴스별 격리).
97
+
98
+ #### 검색 + 네비게이션
99
+
100
+ ```tsx
101
+ import { useRef, useState } from 'react'
102
+ import {
103
+ HighlightStyles,
104
+ useHighlightSearch,
105
+ } from '@cbcruk/highlight-kit/react'
106
+
107
+ function Search() {
108
+ const ref = useRef<HTMLDivElement>(null)
109
+ const [query, setQuery] = useState('wisdom')
110
+ const { count, active, next, prev } = useHighlightSearch(ref, query)
111
+
112
+ return (
113
+ <>
114
+ <HighlightStyles />
115
+ <input value={query} onChange={(e) => setQuery(e.target.value)} />
116
+ <span>{count === 0 ? '0/0' : `${active + 1}/${count}`}</span>
117
+ <button onClick={prev}>↑</button>
118
+ <button onClick={next}>↓</button>
119
+ <div ref={ref}>...본문...</div>
120
+ </>
121
+ )
122
+ }
123
+ ```
124
+
125
+ 전체 매치는 `search`, 현재 항목은 더 높은 priority의 `search-current` 이름으로
126
+ 하이라이트되고 자동으로 `scrollIntoView`됩니다. `options.name`으로 기본 이름을 바꿀 수
127
+ 있습니다. `<HighlightStyles />`는 이 두 이름의 기본 색상을 넣어 줍니다.
128
+
129
+ #### 한 컨테이너에 여러 패턴: `Highlight.Root` + `Highlight.Match`
130
+
131
+ ```tsx
132
+ import { Highlight } from '@cbcruk/highlight-kit/react'
133
+
134
+ function Logs({ logs }: { logs: string }) {
135
+ return (
136
+ <Highlight.Root name="log-info" as="pre">
137
+ {logs}
138
+ <Highlight.Match name="log-error" pattern={/ERROR:[^\n]*/} />
139
+ <Highlight.Match name="log-warn" pattern={/WARN:[^\n]*/} />
140
+ {/* name 생략 → Root의 name */}
141
+ <Highlight.Match pattern={/INFO:[^\n]*/} />
142
+ </Highlight.Root>
143
+ )
144
+ }
145
+ ```
146
+
147
+ `Highlight.Match`는 effect-only(`return null`)입니다. Root 컨테이너를 스캔해 하이라이트만
148
+ 등록하며, 기본으로 DOM 변경을 `MutationObserver`로 추적합니다.
149
+
150
+ #### 상태만 구독 / 지원 여부
151
+
152
+ ```tsx
153
+ import {
154
+ useHighlightState,
155
+ useHighlightSupport,
156
+ } from '@cbcruk/highlight-kit/react'
157
+
158
+ const { count, active } = useHighlightState('search') // 읽기 전용 구독
159
+ const all = useHighlightSnapshots() // { [name]: { count, active } }
160
+ const supported = useHighlightSupport() // SSR 중엔 false
161
+ ```
162
+
163
+ #### 테스트 / controller 주입
164
+
165
+ jsdom에는 `Highlight`가 없으므로, 부수효과 없는 sink로 만든 controller를 주입해
166
+ bookkeeping만 검증합니다.
167
+
168
+ ```tsx
169
+ import {
170
+ createHighlightController,
171
+ createNoopSink,
172
+ } from '@cbcruk/highlight-kit'
173
+ import { HighlightProvider } from '@cbcruk/highlight-kit/react'
174
+
175
+ const controller = createHighlightController({ sink: createNoopSink() })
176
+ render(
177
+ <HighlightProvider controller={controller}>
178
+ <SearchUI />
179
+ </HighlightProvider>,
180
+ )
181
+ expect(controller.getSnapshot('search').count).toBe(3)
182
+ ```
183
+
184
+ ## API
185
+
186
+ ### core
187
+
188
+ | export | 설명 |
189
+ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
190
+ | `highlights` | singleton 컨트롤러 (`set` / `remove` / `clear` / `clearAll` / `getRanges` / `subscribe` / `getSnapshot` / `getSnapshots`) |
191
+ | `createHighlightController({ sink? })` | 격리된 컨트롤러 생성 (테스트·Provider용) |
192
+ | `createCssHighlightSink()` | `CSS.highlights`에 반영하는 기본 sink |
193
+ | `createNoopSink()` | 부수효과 없는 sink (SSR/테스트용, 미지원 환경에서도 bookkeeping 수행) |
194
+ | `computeRanges(root, pattern, opts?)` | 패턴(string/RegExp) 매칭 → `Range[]` (`caseSensitive` / `wholeWord` 옵션) |
195
+ | `rangesFromOffsets(root, spans)` | 문자 offset 배열 → `Range[]` (`root.textContent` 기준, 공백만 있는 텍스트 노드 포함) |
196
+ | `getTextNodes(root)` | 하위 텍스트 노드 수집 (공백만 있는 노드 제외) |
197
+ | `isHighlightSupported()` | API 지원 여부 |
198
+ | `generateHighlightCSS(styles)` | `::highlight()` CSS 문자열 생성 (주입 없이 반환) |
199
+ | `injectHighlightStyles(styles, id?)` | `::highlight()` CSS 동적 주입 (`<style>` 삽입) |
200
+
201
+ ### react (`@cbcruk/highlight-kit/react`)
202
+
203
+ | export | 설명 |
204
+ | --------------------------------------------- | ----------------------------------------------------------------------------- |
205
+ | `useHighlight(opts)` | headless. `ref` + `{ count, active, name }` 반환 (`priority`, `observe` 옵션) |
206
+ | `useHighlightSearch(ref, query, opts?)` | 검색 + `next`/`prev` 네비게이션 |
207
+ | `useTextMatches(target, pattern, opts?)` | 매칭된 `Range[]` 추적 (ref 또는 element, `observe` 기본 true) |
208
+ | `useHighlightRanges(name, ranges, priority?)` | 직접 계산한 `Range[]` 등록 (effect-only) |
209
+ | `useHighlightState(name)` | name의 `{ count, active }` 읽기 전용 구독 |
210
+ | `useHighlightSnapshots()` | 모든 활성 name의 스냅샷 구독 |
211
+ | `useHighlightSupport()` | 지원 여부 (SSR-safe) |
212
+ | `useHighlightController()` | 현재 scope의 컨트롤러 |
213
+ | `<HighlightProvider controller?>` | 컨트롤러 주입 (생략 시 격리된 컨트롤러 생성) |
214
+ | `<Highlight query name as>` | 선언적 wrapper |
215
+ | `<Highlight.Root>` / `<Highlight.Match>` | 한 컨테이너에 여러 패턴 |
216
+ | `<HighlightStyles styles?>` | `::highlight()` 규칙 `<style>` (기본: `search` / `search-current`) |
217
+
218
+ 타입도 함께 export됩니다: `MatchOptions`, `HighlightSnapshot`, `HighlightController`,
219
+ `HighlightSink`, `SourceId`, `UseHighlightOptions`, `UseHighlightResult`,
220
+ `UseHighlightSearchOptions`, `UseHighlightSearchResult`, `TextMatchOptions`, `HighlightProps`,
221
+ `HighlightRootProps`, `HighlightMatchProps`, `HighlightStyleMap`.
222
+
223
+ ## 설계 노트 / 한계
224
+
225
+ - **DOM 변경 추적**: `useHighlight` / `<Highlight>`는 기본적으로 `query`/`name`/옵션이
226
+ 바뀔 때만 재계산합니다. 동적 콘텐츠면 `observe`를 켜세요. `useTextMatches` /
227
+ `useHighlightSearch` / `<Highlight.Match>`는 기본으로 추적합니다.
228
+ - **box model 미지원**: 둥근 테두리 등이 필요하면 `controller.getRanges(name)` →
229
+ `Range.getClientRects()`로 overlay를 직접 그립니다 (`src/demo.tsx` 참고).
230
+ - **hit-test 불가**: 하이라이트는 클릭/hover 대상이 아닙니다.
231
+ - **이름은 document 전역**: `HighlightProvider` 격리는 구독 그래프/테스트용이며, 같은 DOM에서
232
+ 같은 이름을 두 컨트롤러가 칠하면 서로 덮어씁니다.
233
+ - **Shadow DOM**: `TreeWalker`는 shadow 경계를 넘지 않습니다.
234
+ - **SSR**: `getServerSnapshot`이 항상 빈 스냅샷을 반환하고, 등록은 layout effect에서
235
+ 일어나므로 hydration 불일치가 없습니다.
236
+ - **`::highlight()` 지원 속성**: `color`, `background-color`, `text-decoration`(브라우저
237
+ 차이 있음), `text-shadow`, `-webkit-text-stroke/fill` 등 제한적.
238
+
239
+ ## 브라우저 지원
240
+
241
+ Chrome/Edge 105+, Safari 17.2+, Firefox 140+ (2025-06~) — 전 메이저 브라우저 커버.
242
+
243
+ ## 개발
244
+
245
+ eunsoolib 모노레포 패키지로 통합되어 있습니다. 진입점은
246
+ [src/index.ts](src/index.ts)(core)와 [src/react.tsx](src/react.tsx)(React 어댑터)이며,
247
+ [src/demo.tsx](src/demo.tsx)에 검색·다중 이름·RegExp·range overlay 데모가 있습니다.
248
+
249
+ > `@cbcruk/use-highlight-search`는 이 패키지로 통합되었습니다. `HighlightStoreProvider` →
250
+ > `HighlightProvider`, `createHighlightStore` → `createHighlightController`,
251
+ > `useHighlight(name, ranges)` → `useHighlightRanges(name, ranges)`,
252
+ > `store.getSnapshot()[name]` → `controller.getSnapshot(name).count`로 옮기면 됩니다.
253
+
254
+ ```bash
255
+ pnpm test:run packages/highlight-kit # Vitest (jsdom)
256
+ ```
257
+
258
+ `src/highlight-api.d.ts`는 `lib.dom`이 아직 불완전하게 타입한 `HighlightRegistry`의
259
+ maplike 멤버(`set`/`get`/`delete` 등)를 보강하는 빌드 전용 선언입니다.
@@ -0,0 +1,168 @@
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 };
@@ -0,0 +1,306 @@
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 };
@@ -0,0 +1,2 @@
1
+ import { a as MatchOptions, c as createCssHighlightSink, d as generateHighlightCSS, f as getTextNodes, g as rangesFromOffsets, h as isHighlightSupported, i as HighlightSnapshot, l as createHighlightController, m as injectHighlightStyles, n as HighlightControllerOptions, o as SourceId, p as highlights, r as HighlightSink, s as computeRanges, t as HighlightController, u as createNoopSink } from "./core-B8g04jvB.js";
2
+ export { type HighlightController, HighlightControllerOptions, HighlightSink, HighlightSnapshot, MatchOptions, SourceId, computeRanges, createCssHighlightSink, createHighlightController, createNoopSink, generateHighlightCSS, getTextNodes, highlights, injectHighlightStyles, isHighlightSupported, rangesFromOffsets };
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ import { a as generateHighlightCSS, c as injectHighlightStyles, i as createNoopSink, l as isHighlightSupported, n as createCssHighlightSink, o as getTextNodes, r as createHighlightController, s as highlights, t as computeRanges, u as rangesFromOffsets } from "./core-LUnH63zG.js";
2
+ export { computeRanges, createCssHighlightSink, createHighlightController, createNoopSink, generateHighlightCSS, getTextNodes, highlights, injectHighlightStyles, isHighlightSupported, rangesFromOffsets };
@@ -0,0 +1,263 @@
1
+ import { a as MatchOptions, i as HighlightSnapshot, p as highlights, t as HighlightController } from "./core-B8g04jvB.js";
2
+ import { CSSProperties, ComponentPropsWithoutRef, ElementType, ReactNode, RefObject } from "react";
3
+ //#region src/react.d.ts
4
+ /** The controller in scope — the shared singleton unless a provider overrides it. */
5
+ export declare function useHighlightController(): HighlightController;
6
+ /** Props for {@link HighlightProvider}. */
7
+ export interface HighlightProviderProps {
8
+ /** Omit to create an isolated controller once for this provider. */
9
+ controller?: HighlightController;
10
+ /** Subtree whose hooks use this controller. */
11
+ children?: ReactNode;
12
+ }
13
+ /**
14
+ * Scope hooks below to a specific controller. Mostly for tests (inject one
15
+ * built with `createNoopSink()`); highlight *names* remain document-global.
16
+ */
17
+ export declare function HighlightProvider({ controller, children }: HighlightProviderProps): ReactNode;
18
+ /** Read-only reactive subscription to one highlight name's state. */
19
+ export declare function useHighlightState(name: string): HighlightSnapshot;
20
+ /** Read-only reactive subscription to every active name's state. */
21
+ export declare function useHighlightSnapshots(): Readonly<Record<string, HighlightSnapshot>>;
22
+ /** Whether the Custom Highlight API is available (false during SSR). */
23
+ export declare function useHighlightSupport(): boolean;
24
+ /**
25
+ * Register precomputed `ranges` under `name` for this component instance.
26
+ * Effect-only: re-registers whenever the `ranges` reference changes and removes
27
+ * its contribution on unmount.
28
+ */
29
+ export declare function useHighlightRanges(name: string, ranges: Range[], priority?: number): void;
30
+ /** Match options for {@link useTextMatches}, plus DOM change tracking. */
31
+ export interface TextMatchOptions extends MatchOptions {
32
+ /**
33
+ * Recompute when the container's DOM changes (MutationObserver).
34
+ * @default true
35
+ */
36
+ observe?: boolean;
37
+ }
38
+ /**
39
+ * Track the ranges matching `pattern` inside `target` (a ref or an element).
40
+ * With `observe` (default true) child/text mutations trigger a recompute.
41
+ *
42
+ * Pass the element itself when it is owned by a *parent* component: a parent's
43
+ * ref is attached only after its children's layout effects have run.
44
+ */
45
+ export declare function useTextMatches(target: RefObject<Element | null> | Element | null, pattern: string | RegExp, options?: TextMatchOptions): Range[];
46
+ /** Options for {@link useHighlight}. */
47
+ export interface UseHighlightOptions extends MatchOptions {
48
+ /** The text/regex to highlight. Falsy clears this source. */
49
+ query: string | RegExp;
50
+ /** CSS ::highlight() name. Defaults to a unique per-instance name. */
51
+ name?: string;
52
+ /**
53
+ * Stacking order against other highlight names.
54
+ * @default 0
55
+ */
56
+ priority?: number;
57
+ /**
58
+ * Recompute when the container's DOM changes (MutationObserver).
59
+ * @default false
60
+ */
61
+ observe?: boolean;
62
+ }
63
+ /**
64
+ * Return value of {@link useHighlight}: the container ref, the resolved name,
65
+ * and that name's current snapshot.
66
+ *
67
+ * @template T - Element type the `ref` is attached to
68
+ */
69
+ export interface UseHighlightResult<T extends Element = HTMLElement> extends HighlightSnapshot {
70
+ /** Attach to the element whose text you want to scan. */
71
+ ref: RefObject<T | null>;
72
+ /** The resolved highlight name (auto-generated if not provided). */
73
+ name: string;
74
+ }
75
+ /**
76
+ * Headless highlighting. Attach the returned `ref` to any container; the hook
77
+ * keeps that container's matches for `query` registered under `name`, and
78
+ * reconciles on every change. Cleans up its own contribution on unmount.
79
+ *
80
+ * @template T - Element type the `ref` is attached to
81
+ *
82
+ * @example
83
+ * ```tsx
84
+ * import { useHighlight } from '@cbcruk/highlight-kit/react'
85
+ *
86
+ * function SearchableText({ query }: { query: string }) {
87
+ * const { ref, count } = useHighlight<HTMLDivElement>({ query, name: 'search' })
88
+ * return (
89
+ * <>
90
+ * <span>{count} matches</span>
91
+ * <div ref={ref}>...</div>
92
+ * </>
93
+ * )
94
+ * }
95
+ * ```
96
+ */
97
+ export declare function useHighlight<T extends Element = HTMLElement>(options: UseHighlightOptions): UseHighlightResult<T>;
98
+ /** Options for {@link useHighlightSearch}. */
99
+ export interface UseHighlightSearchOptions extends TextMatchOptions {
100
+ /**
101
+ * Base highlight name. The active match is registered under
102
+ * `${name}-current` with a higher priority.
103
+ * @default 'search'
104
+ */
105
+ name?: string;
106
+ }
107
+ /** Return value of {@link useHighlightSearch}. */
108
+ export interface UseHighlightSearchResult {
109
+ /** Number of ranges registered under the base name. */
110
+ count: number;
111
+ /** Index of the active match, or -1 when there are no matches. */
112
+ active: number;
113
+ /** Move to the next match, wrapping to the first after the last. */
114
+ next(): void;
115
+ /** Move to the previous match, wrapping to the last before the first. */
116
+ prev(): void;
117
+ }
118
+ /**
119
+ * Search with next/prev navigation. All matches go to `name`, the active one to
120
+ * `${name}-current`, and the active match is scrolled into view.
121
+ *
122
+ * When the matches change, the active index is clamped to the new match count.
123
+ *
124
+ * @example
125
+ * ```tsx
126
+ * import { useRef, useState } from 'react'
127
+ * import { HighlightStyles, useHighlightSearch } from '@cbcruk/highlight-kit/react'
128
+ *
129
+ * function Search() {
130
+ * const ref = useRef<HTMLDivElement>(null)
131
+ * const [query, setQuery] = useState('wisdom')
132
+ * const { count, active, next, prev } = useHighlightSearch(ref, query)
133
+ * return (
134
+ * <>
135
+ * <HighlightStyles />
136
+ * <input value={query} onChange={(e) => setQuery(e.target.value)} />
137
+ * <span>{count === 0 ? '0/0' : `${active + 1}/${count}`}</span>
138
+ * <button onClick={prev}>Prev</button>
139
+ * <button onClick={next}>Next</button>
140
+ * <div ref={ref}>...</div>
141
+ * </>
142
+ * )
143
+ * }
144
+ * ```
145
+ */
146
+ export declare function useHighlightSearch(containerRef: RefObject<Element | null>, query: string | RegExp, options?: UseHighlightSearchOptions): UseHighlightSearchResult;
147
+ /** Props for the declarative {@link Highlight} component. */
148
+ export interface HighlightProps extends MatchOptions {
149
+ /** The text/regex to highlight. Falsy clears this source. */
150
+ query: string | RegExp;
151
+ /** CSS ::highlight() name. Defaults to a unique per-instance name. */
152
+ name?: string;
153
+ /**
154
+ * Stacking order against other highlight names.
155
+ * @default 0
156
+ */
157
+ priority?: number;
158
+ /**
159
+ * Recompute when the container's DOM changes (MutationObserver).
160
+ * @default false
161
+ */
162
+ observe?: boolean;
163
+ /**
164
+ * Element to render as the scan container.
165
+ * @default 'div'
166
+ */
167
+ as?: ElementType;
168
+ /** Content rendered inside the container and scanned for matches. */
169
+ children?: ReactNode;
170
+ /** Class name for the container element. */
171
+ className?: string;
172
+ /** Inline style for the container element. Tip: pass `{ display: 'contents' }` for zero layout impact. */
173
+ style?: CSSProperties;
174
+ }
175
+ declare function HighlightComponent({ query, name, priority, observe, as, caseSensitive, wholeWord, children, ...rest }: HighlightProps): ReactNode;
176
+ /** Props for {@link HighlightRoot}; other `div` props go to the rendered element. */
177
+ export interface HighlightRootProps extends Omit<ComponentPropsWithoutRef<'div'>, 'children'> {
178
+ /** Default highlight name for nested <Highlight.Match> without `name`. */
179
+ name: string;
180
+ /**
181
+ * Element to render as the scan container.
182
+ * @default 'div'
183
+ */
184
+ as?: ElementType;
185
+ /** Content rendered inside the container, including `<Highlight.Match>` elements. */
186
+ children?: ReactNode;
187
+ }
188
+ /** Scan container for one or more <Highlight.Match> children. */
189
+ export declare const HighlightRoot: import("react").ForwardRefExoticComponent<HighlightRootProps & import("react").RefAttributes<HTMLElement>>;
190
+ /** Props for {@link HighlightMatch}. */
191
+ export interface HighlightMatchProps extends TextMatchOptions {
192
+ /** The text/regex to find in the enclosing Root's container. */
193
+ pattern: string | RegExp;
194
+ /** Falls back to the enclosing Root's `name`. */
195
+ name?: string;
196
+ /**
197
+ * Stacking order against other highlight names.
198
+ * @default 0
199
+ */
200
+ priority?: number;
201
+ }
202
+ /**
203
+ * Effect-only: renders nothing, scans the enclosing Root's container and
204
+ * registers the matches. Tracks DOM changes by default (`observe`).
205
+ *
206
+ * @throws When rendered outside `<Highlight.Root>`.
207
+ */
208
+ export declare function HighlightMatch({ pattern, name, priority, ...matchOptions }: HighlightMatchProps): null;
209
+ /**
210
+ * Renders a single wrapper element and highlights matches of `query` within it.
211
+ *
212
+ * Multiple <Highlight> sharing the same `name` are unioned by the core, so one
213
+ * ::highlight(name) CSS rule styles them all.
214
+ *
215
+ * For several patterns over one container use the compound form:
216
+ * `Highlight.Root` with nested `Highlight.Match` elements.
217
+ *
218
+ * @example Single pattern
219
+ * ```tsx
220
+ * import { Highlight } from '@cbcruk/highlight-kit/react'
221
+ *
222
+ * function Article({ keyword }: { keyword: string }) {
223
+ * return (
224
+ * <Highlight query={keyword} name="search" style={{ display: 'contents' }}>
225
+ * <article>...</article>
226
+ * </Highlight>
227
+ * )
228
+ * }
229
+ * ```
230
+ *
231
+ * @example Several patterns over one container
232
+ * ```tsx
233
+ * import { Highlight } from '@cbcruk/highlight-kit/react'
234
+ *
235
+ * function Logs({ logs }: { logs: string }) {
236
+ * return (
237
+ * <Highlight.Root name="log-info" as="pre">
238
+ * {logs}
239
+ * <Highlight.Match name="log-error" pattern={/ERROR:[^\n]+/} />
240
+ * <Highlight.Match pattern={/INFO:[^\n]+/} />
241
+ * </Highlight.Root>
242
+ * )
243
+ * }
244
+ * ```
245
+ */
246
+ export declare const Highlight: typeof HighlightComponent & {
247
+ Root: import("react").ForwardRefExoticComponent<HighlightRootProps & import("react").RefAttributes<HTMLElement>>;
248
+ Match: typeof HighlightMatch;
249
+ };
250
+ /** `::highlight()` style declarations keyed by highlight name. */
251
+ export type HighlightStyleMap = Record<string, Partial<CSSStyleDeclaration>>;
252
+ /** Props for {@link HighlightStyles}. */
253
+ export interface HighlightStylesProps {
254
+ /** `::highlight()` rules by name (default: `search` / `search-current`). */
255
+ styles?: HighlightStyleMap;
256
+ }
257
+ /**
258
+ * Inline `<style>` with `::highlight()` rules. Defaults match the names used by
259
+ * {@link useHighlightSearch}.
260
+ */
261
+ export declare function HighlightStyles({ styles }: HighlightStylesProps): ReactNode;
262
+ //#endregion
263
+ export { highlights };
package/dist/react.js ADDED
@@ -0,0 +1,320 @@
1
+ import { a as generateHighlightCSS, l as isHighlightSupported, r as createHighlightController, s as highlights, t as computeRanges } from "./core-LUnH63zG.js";
2
+ import { createContext, createElement, forwardRef, useCallback, useContext, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
3
+ import { jsx } from "react/jsx-runtime";
4
+ //#region src/react.tsx
5
+ /**
6
+ * @highlight-kit/react
7
+ *
8
+ * Thin React adapter over the core controller.
9
+ * - reactive reads via useSyncExternalStore (SSR-safe)
10
+ * - each component instance is a distinct "source" via useId
11
+ * - writes happen in layout effects only; reads never trigger writes
12
+ * - declarative <Highlight> / <Highlight.Root> wrap the headless hooks
13
+ */
14
+ const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
15
+ const ControllerContext = createContext(highlights);
16
+ /** The controller in scope — the shared singleton unless a provider overrides it. */
17
+ function useHighlightController() {
18
+ return useContext(ControllerContext);
19
+ }
20
+ /**
21
+ * Scope hooks below to a specific controller. Mostly for tests (inject one
22
+ * built with `createNoopSink()`); highlight *names* remain document-global.
23
+ */
24
+ function HighlightProvider({ controller, children }) {
25
+ const fallback = useRef(null);
26
+ if (!controller && fallback.current === null) fallback.current = createHighlightController();
27
+ return /* @__PURE__ */ jsx(ControllerContext.Provider, {
28
+ value: controller ?? fallback.current,
29
+ children
30
+ });
31
+ }
32
+ /** Read-only reactive subscription to one highlight name's state. */
33
+ function useHighlightState(name) {
34
+ const controller = useHighlightController();
35
+ return useSyncExternalStore(controller.subscribe, () => controller.getSnapshot(name), controller.getServerSnapshot);
36
+ }
37
+ /** Read-only reactive subscription to every active name's state. */
38
+ function useHighlightSnapshots() {
39
+ const controller = useHighlightController();
40
+ return useSyncExternalStore(controller.subscribe, controller.getSnapshots, controller.getServerSnapshots);
41
+ }
42
+ /** Whether the Custom Highlight API is available (false during SSR). */
43
+ function useHighlightSupport() {
44
+ return useSyncExternalStore(() => () => {}, () => isHighlightSupported(), () => false);
45
+ }
46
+ /**
47
+ * Register precomputed `ranges` under `name` for this component instance.
48
+ * Effect-only: re-registers whenever the `ranges` reference changes and removes
49
+ * its contribution on unmount.
50
+ */
51
+ function useHighlightRanges(name, ranges, priority = 0) {
52
+ const controller = useHighlightController();
53
+ const sourceId = useId();
54
+ useIsomorphicLayoutEffect(() => {
55
+ controller.set(name, sourceId, ranges, priority);
56
+ return () => controller.remove(name, sourceId);
57
+ }, [
58
+ controller,
59
+ name,
60
+ sourceId,
61
+ ranges,
62
+ priority
63
+ ]);
64
+ }
65
+ /**
66
+ * Track the ranges matching `pattern` inside `target` (a ref or an element).
67
+ * With `observe` (default true) child/text mutations trigger a recompute.
68
+ *
69
+ * Pass the element itself when it is owned by a *parent* component: a parent's
70
+ * ref is attached only after its children's layout effects have run.
71
+ */
72
+ function useTextMatches(target, pattern, options = {}) {
73
+ const { caseSensitive, wholeWord, observe = true } = options;
74
+ const [ranges, setRanges] = useState([]);
75
+ useIsomorphicLayoutEffect(() => {
76
+ const el = target && "current" in target ? target.current : target;
77
+ if (!el) return;
78
+ const compute = () => setRanges(computeRanges(el, pattern, {
79
+ caseSensitive,
80
+ wholeWord
81
+ }));
82
+ compute();
83
+ if (!observe) return;
84
+ const observer = new MutationObserver(compute);
85
+ observer.observe(el, {
86
+ childList: true,
87
+ subtree: true,
88
+ characterData: true
89
+ });
90
+ return () => observer.disconnect();
91
+ }, [
92
+ target,
93
+ pattern,
94
+ caseSensitive,
95
+ wholeWord,
96
+ observe
97
+ ]);
98
+ return ranges;
99
+ }
100
+ /**
101
+ * Headless highlighting. Attach the returned `ref` to any container; the hook
102
+ * keeps that container's matches for `query` registered under `name`, and
103
+ * reconciles on every change. Cleans up its own contribution on unmount.
104
+ *
105
+ * @template T - Element type the `ref` is attached to
106
+ *
107
+ * @example
108
+ * ```tsx
109
+ * import { useHighlight } from '@cbcruk/highlight-kit/react'
110
+ *
111
+ * function SearchableText({ query }: { query: string }) {
112
+ * const { ref, count } = useHighlight<HTMLDivElement>({ query, name: 'search' })
113
+ * return (
114
+ * <>
115
+ * <span>{count} matches</span>
116
+ * <div ref={ref}>...</div>
117
+ * </>
118
+ * )
119
+ * }
120
+ * ```
121
+ */
122
+ function useHighlight(options) {
123
+ const { query, caseSensitive, wholeWord, priority = 0, observe } = options;
124
+ const controller = useHighlightController();
125
+ const autoId = useId();
126
+ const name = options.name ?? `hk-${autoId}`;
127
+ const sourceId = autoId;
128
+ const ref = useRef(null);
129
+ useIsomorphicLayoutEffect(() => {
130
+ const el = ref.current;
131
+ if (!el || !query) {
132
+ controller.remove(name, sourceId);
133
+ return;
134
+ }
135
+ const register = () => controller.set(name, sourceId, computeRanges(el, query, {
136
+ caseSensitive,
137
+ wholeWord
138
+ }), priority);
139
+ register();
140
+ const observer = observe ? new MutationObserver(register) : null;
141
+ observer?.observe(el, {
142
+ childList: true,
143
+ subtree: true,
144
+ characterData: true
145
+ });
146
+ return () => {
147
+ observer?.disconnect();
148
+ controller.remove(name, sourceId);
149
+ };
150
+ }, [
151
+ controller,
152
+ name,
153
+ sourceId,
154
+ query,
155
+ caseSensitive,
156
+ wholeWord,
157
+ priority,
158
+ observe
159
+ ]);
160
+ return {
161
+ ref,
162
+ name,
163
+ ...useHighlightState(name)
164
+ };
165
+ }
166
+ /**
167
+ * Search with next/prev navigation. All matches go to `name`, the active one to
168
+ * `${name}-current`, and the active match is scrolled into view.
169
+ *
170
+ * When the matches change, the active index is clamped to the new match count.
171
+ *
172
+ * @example
173
+ * ```tsx
174
+ * import { useRef, useState } from 'react'
175
+ * import { HighlightStyles, useHighlightSearch } from '@cbcruk/highlight-kit/react'
176
+ *
177
+ * function Search() {
178
+ * const ref = useRef<HTMLDivElement>(null)
179
+ * const [query, setQuery] = useState('wisdom')
180
+ * const { count, active, next, prev } = useHighlightSearch(ref, query)
181
+ * return (
182
+ * <>
183
+ * <HighlightStyles />
184
+ * <input value={query} onChange={(e) => setQuery(e.target.value)} />
185
+ * <span>{count === 0 ? '0/0' : `${active + 1}/${count}`}</span>
186
+ * <button onClick={prev}>Prev</button>
187
+ * <button onClick={next}>Next</button>
188
+ * <div ref={ref}>...</div>
189
+ * </>
190
+ * )
191
+ * }
192
+ * ```
193
+ */
194
+ function useHighlightSearch(containerRef, query, options = {}) {
195
+ const { name = "search", ...matchOptions } = options;
196
+ const matches = useTextMatches(containerRef, query, matchOptions);
197
+ const [active, setActive] = useState(0);
198
+ useIsomorphicLayoutEffect(() => {
199
+ setActive((a) => matches.length === 0 ? 0 : Math.min(a, matches.length - 1));
200
+ }, [matches]);
201
+ const activeRange = useMemo(() => matches[active] ? [matches[active]] : [], [matches, active]);
202
+ useHighlightRanges(name, matches, 0);
203
+ useHighlightRanges(`${name}-current`, activeRange, 1);
204
+ useEffect(() => {
205
+ (matches[active]?.startContainer?.parentElement)?.scrollIntoView?.({ block: "nearest" });
206
+ }, [matches, active]);
207
+ const { count } = useHighlightState(name);
208
+ const next = useCallback(() => setActive((a) => matches.length ? (a + 1) % matches.length : 0), [matches.length]);
209
+ const prev = useCallback(() => setActive((a) => matches.length ? (a - 1 + matches.length) % matches.length : 0), [matches.length]);
210
+ return {
211
+ count,
212
+ active: matches.length ? active : -1,
213
+ next,
214
+ prev
215
+ };
216
+ }
217
+ function HighlightComponent({ query, name, priority, observe, as = "div", caseSensitive, wholeWord, children, ...rest }) {
218
+ const { ref } = useHighlight({
219
+ query,
220
+ name,
221
+ priority,
222
+ observe,
223
+ caseSensitive,
224
+ wholeWord
225
+ });
226
+ return createElement(as, {
227
+ ref,
228
+ ...rest
229
+ }, children);
230
+ }
231
+ const HighlightRootContext = createContext(null);
232
+ /** Scan container for one or more <Highlight.Match> children. */
233
+ const HighlightRoot = forwardRef(function HighlightRoot({ name, as = "div", children, ...rest }, ref) {
234
+ const [container, setContainer] = useState(null);
235
+ useImperativeHandle(ref, () => container, [container]);
236
+ const ctx = useMemo(() => ({
237
+ name,
238
+ container
239
+ }), [name, container]);
240
+ return /* @__PURE__ */ jsx(HighlightRootContext.Provider, {
241
+ value: ctx,
242
+ children: createElement(as, {
243
+ ref: setContainer,
244
+ ...rest
245
+ }, children)
246
+ });
247
+ });
248
+ /**
249
+ * Effect-only: renders nothing, scans the enclosing Root's container and
250
+ * registers the matches. Tracks DOM changes by default (`observe`).
251
+ *
252
+ * @throws When rendered outside `<Highlight.Root>`.
253
+ */
254
+ function HighlightMatch({ pattern, name, priority = 0, ...matchOptions }) {
255
+ const ctx = useContext(HighlightRootContext);
256
+ if (!ctx) throw new Error("<Highlight.Match> must be used inside <Highlight.Root>");
257
+ const ranges = useTextMatches(ctx.container, pattern, matchOptions);
258
+ useHighlightRanges(name ?? ctx.name, ranges, priority);
259
+ return null;
260
+ }
261
+ /**
262
+ * Renders a single wrapper element and highlights matches of `query` within it.
263
+ *
264
+ * Multiple <Highlight> sharing the same `name` are unioned by the core, so one
265
+ * ::highlight(name) CSS rule styles them all.
266
+ *
267
+ * For several patterns over one container use the compound form:
268
+ * `Highlight.Root` with nested `Highlight.Match` elements.
269
+ *
270
+ * @example Single pattern
271
+ * ```tsx
272
+ * import { Highlight } from '@cbcruk/highlight-kit/react'
273
+ *
274
+ * function Article({ keyword }: { keyword: string }) {
275
+ * return (
276
+ * <Highlight query={keyword} name="search" style={{ display: 'contents' }}>
277
+ * <article>...</article>
278
+ * </Highlight>
279
+ * )
280
+ * }
281
+ * ```
282
+ *
283
+ * @example Several patterns over one container
284
+ * ```tsx
285
+ * import { Highlight } from '@cbcruk/highlight-kit/react'
286
+ *
287
+ * function Logs({ logs }: { logs: string }) {
288
+ * return (
289
+ * <Highlight.Root name="log-info" as="pre">
290
+ * {logs}
291
+ * <Highlight.Match name="log-error" pattern={/ERROR:[^\n]+/} />
292
+ * <Highlight.Match pattern={/INFO:[^\n]+/} />
293
+ * </Highlight.Root>
294
+ * )
295
+ * }
296
+ * ```
297
+ */
298
+ const Highlight = Object.assign(HighlightComponent, {
299
+ Root: HighlightRoot,
300
+ Match: HighlightMatch
301
+ });
302
+ const DEFAULT_SEARCH_STYLES = {
303
+ search: {
304
+ backgroundColor: "rgb(253 224 71)",
305
+ color: "rgb(113 63 18)"
306
+ },
307
+ "search-current": {
308
+ backgroundColor: "rgb(249 115 22)",
309
+ color: "white"
310
+ }
311
+ };
312
+ /**
313
+ * Inline `<style>` with `::highlight()` rules. Defaults match the names used by
314
+ * {@link useHighlightSearch}.
315
+ */
316
+ function HighlightStyles({ styles = DEFAULT_SEARCH_STYLES }) {
317
+ return /* @__PURE__ */ jsx("style", { children: generateHighlightCSS(styles) });
318
+ }
319
+ //#endregion
320
+ export { Highlight, HighlightMatch, HighlightProvider, HighlightRoot, HighlightStyles, highlights, useHighlight, useHighlightController, useHighlightRanges, useHighlightSearch, useHighlightSnapshots, useHighlightState, useHighlightSupport, useTextMatches };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@cbcruk/highlight-kit",
3
+ "description": "CSS Custom Highlight API 기반 텍스트 하이라이트 core와 React 어댑터",
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/cbcruk/eunsoolib.git",
10
+ "directory": "packages/highlight-kit"
11
+ },
12
+ "homepage": "https://cbcruk.github.io/eunsoolib/docs/dom/highlight-kit/",
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "exports": {
17
+ ".": "./dist/index.js",
18
+ "./react": "./dist/react.js",
19
+ "./package.json": "./package.json"
20
+ },
21
+ "peerDependencies": {
22
+ "react": ">=18.0.0"
23
+ },
24
+ "peerDependenciesMeta": {
25
+ "react": {
26
+ "optional": true
27
+ }
28
+ },
29
+ "eunsoolib": {
30
+ "category": "dom",
31
+ "runtime": [
32
+ "browser"
33
+ ]
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "scripts": {
39
+ "build": "tsdown --config ../../tsdown.config.ts"
40
+ }
41
+ }