@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 +259 -0
- package/dist/core-B8g04jvB.d.ts +168 -0
- package/dist/core-LUnH63zG.js +306 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/react.d.ts +263 -0
- package/dist/react.js +320 -0
- package/package.json +41 -0
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 };
|
package/dist/index.d.ts
ADDED
|
@@ -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 };
|
package/dist/react.d.ts
ADDED
|
@@ -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
|
+
}
|