@cbcruk/highlight-kit 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +212 -8
- package/dist/core-DDCXN2b_.d.ts +527 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/react.d.ts +203 -1
- package/dist/react.js +300 -2
- package/dist/value-range-CH2vegQi.js +707 -0
- package/package.json +1 -1
- package/dist/core-B8g04jvB.d.ts +0 -168
- package/dist/core-LUnH63zG.js +0 -306
package/README.md
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
CSS Custom Highlight API 기반 텍스트 하이라이팅 라이브러리. DOM을 건드리지 않고
|
|
4
4
|
텍스트를 하이라이트합니다. 프레임워크 무관 core와 얇은 React 어댑터를 제공합니다.
|
|
5
5
|
|
|
6
|
+
DOM 텍스트는 `Range`로, `<input>`/`<textarea>` **안의 값**은 `OpaqueRange`로
|
|
7
|
+
하이라이트합니다. 두 종류를 한 `::highlight()` 이름에 섞어 쓸 수 있습니다.
|
|
8
|
+
|
|
6
9
|
## 설치
|
|
7
10
|
|
|
8
11
|
```bash
|
|
@@ -41,6 +44,70 @@ highlights.clear('search') // name 통째로 제거
|
|
|
41
44
|
reconcile합니다. 서로 다른 패널 두 곳에서 `'error'` 이름으로 하이라이트해도, CSS는
|
|
42
45
|
`::highlight(error)` 규칙 하나로 둘 다 스타일링됩니다.
|
|
43
46
|
|
|
47
|
+
### Form control 안의 텍스트 (`OpaqueRange`)
|
|
48
|
+
|
|
49
|
+
`Range`는 form control의 값을 가리킬 수 없어서 `computeRanges`로는 `<textarea>`
|
|
50
|
+
안을 칠할 수 없습니다. Chromium 152+가 추가한 `element.createValueRange(start, end)`가
|
|
51
|
+
이 틈을 메웁니다.
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
import {
|
|
55
|
+
createValueHighlighter,
|
|
56
|
+
injectHighlightStyles,
|
|
57
|
+
} from '@cbcruk/highlight-kit'
|
|
58
|
+
|
|
59
|
+
injectHighlightStyles({
|
|
60
|
+
comment: { color: '#6b7280' },
|
|
61
|
+
keyword: { color: '#7c3aed' },
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const textarea = document.querySelector('textarea')!
|
|
65
|
+
const highlighter = createValueHighlighter({
|
|
66
|
+
element: textarea,
|
|
67
|
+
rules: [
|
|
68
|
+
{ name: 'comment', pattern: /\/\/[^\n]*/ },
|
|
69
|
+
{ name: 'keyword', pattern: /\b(?:const|function|return)\b/ },
|
|
70
|
+
],
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
highlighter.dispose() // 등록 해제 + 모든 Range disconnect
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
규칙 순서가 우선순위입니다. 기본 전략(`overlap: 'first'`)은 좌→우로 스캔하며 각
|
|
77
|
+
위치에서 먼저 매칭된 규칙이 이기고 그만큼 건너뛰므로, `// const x`에서 comment
|
|
78
|
+
규칙이 keyword를 삼킵니다. 규칙마다 독립적으로 전체를 스캔해 겹침을 유지하려면
|
|
79
|
+
`overlap: 'all'`을 쓰고 `priority`로 층을 정합니다.
|
|
80
|
+
|
|
81
|
+
#### 왜 래퍼가 필요한가 — Range 누수
|
|
82
|
+
|
|
83
|
+
`OpaqueRange`는 **live**입니다. control은 `createValueRange()`로 넘겨준 Range를
|
|
84
|
+
모두 보관하면서 편집마다 offset을 갱신합니다. 그래서 키 입력마다 패턴을 다시
|
|
85
|
+
매칭하는 흔한 코드는 세대마다 live Range를 쌓습니다 — `Highlight`에서는 빠졌으니
|
|
86
|
+
보이지 않지만, control은 계속 갱신합니다.
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
// 누수: 이전 Range를 놓아주지 않는다
|
|
90
|
+
function onInput() {
|
|
91
|
+
highlight.clear()
|
|
92
|
+
for (const m of textarea.value.matchAll(/example/g)) {
|
|
93
|
+
highlight.add(textarea.createValueRange(m.index, m.index + 7))
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`createValueHighlighter` / `createValueRangeRegistry`는 새 세대를 commit한 **뒤**
|
|
99
|
+
이전 세대를 `disconnect()`합니다 (순서를 뒤집으면 한 프레임 동안 하이라이트가
|
|
100
|
+
사라집니다). span을 직접 계산한다면 registry를 쓰세요.
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
import { createValueRangeRegistry } from '@cbcruk/highlight-kit'
|
|
104
|
+
|
|
105
|
+
const registry = createValueRangeRegistry({ element: textarea })
|
|
106
|
+
registry.commit([{ name: 'note', priority: 0, spans: [{ start: 0, end: 4 }] }])
|
|
107
|
+
registry.commit([{ name: 'note', priority: 0, spans: [{ start: 6, end: 9 }] }])
|
|
108
|
+
registry.dispose()
|
|
109
|
+
```
|
|
110
|
+
|
|
44
111
|
### React
|
|
45
112
|
|
|
46
113
|
#### 선언적 컴포넌트
|
|
@@ -147,6 +214,85 @@ function Logs({ logs }: { logs: string }) {
|
|
|
147
214
|
`Highlight.Match`는 effect-only(`return null`)입니다. Root 컨테이너를 스캔해 하이라이트만
|
|
148
215
|
등록하며, 기본으로 DOM 변경을 `MutationObserver`로 추적합니다.
|
|
149
216
|
|
|
217
|
+
#### Form control 토크나이저
|
|
218
|
+
|
|
219
|
+
```tsx
|
|
220
|
+
import { HighlightStyles, useValueTokens } from '@cbcruk/highlight-kit/react'
|
|
221
|
+
|
|
222
|
+
const RULES = [
|
|
223
|
+
{ name: 'comment', pattern: /\/\/[^\n]*/ },
|
|
224
|
+
{ name: 'string', pattern: /'[^']*'|"[^"]*"/ },
|
|
225
|
+
{ name: 'keyword', pattern: /\b(?:const|function|return)\b/ },
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
function CodeArea() {
|
|
229
|
+
const { ref, supported, counts } = useValueTokens<HTMLTextAreaElement>(RULES)
|
|
230
|
+
|
|
231
|
+
return (
|
|
232
|
+
<>
|
|
233
|
+
<HighlightStyles
|
|
234
|
+
styles={{
|
|
235
|
+
comment: { color: '#6b7280' },
|
|
236
|
+
string: { color: '#16a34a' },
|
|
237
|
+
keyword: { color: '#7c3aed' },
|
|
238
|
+
}}
|
|
239
|
+
/>
|
|
240
|
+
<textarea ref={ref} defaultValue="const x = 1 // note" />
|
|
241
|
+
{!supported && <p>이 브라우저는 textarea 안을 칠할 수 없습니다.</p>}
|
|
242
|
+
<small>키워드 {counts.keyword ?? 0}개</small>
|
|
243
|
+
</>
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
rules는 **내용으로 비교**하므로 인라인 배열을 그대로 넘겨도 매 렌더마다
|
|
249
|
+
highlighter가 재생성되지 않습니다. 단일 패턴이면 `useValueHighlight`:
|
|
250
|
+
|
|
251
|
+
```tsx
|
|
252
|
+
const { ref, count } = useValueHighlight<HTMLTextAreaElement>({
|
|
253
|
+
query: /\bexample\b/gi,
|
|
254
|
+
name: 'flagged',
|
|
255
|
+
})
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
controlled 컴포넌트에서 값을 **프로그램적으로** 바꾸면(초기화 버튼, 템플릿
|
|
259
|
+
불러오기) 모든 live Range가 collapse되고 `input` 이벤트도 안 나므로, `value`를
|
|
260
|
+
넘겨 재계산을 트리거해야 합니다. 사용자 타이핑은 `input`으로 추적되니 필요 없습니다.
|
|
261
|
+
|
|
262
|
+
```tsx
|
|
263
|
+
const { ref } = useValueTokens<HTMLTextAreaElement>(RULES, { value })
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
#### Form control 안에서 검색 + 네비게이션
|
|
267
|
+
|
|
268
|
+
```tsx
|
|
269
|
+
import {
|
|
270
|
+
HighlightStyles,
|
|
271
|
+
useValueHighlightSearch,
|
|
272
|
+
} from '@cbcruk/highlight-kit/react'
|
|
273
|
+
|
|
274
|
+
function NoteSearch() {
|
|
275
|
+
const [query, setQuery] = useState('')
|
|
276
|
+
const { ref, count, active, next, prev } =
|
|
277
|
+
useValueHighlightSearch<HTMLTextAreaElement>(query)
|
|
278
|
+
|
|
279
|
+
return (
|
|
280
|
+
<>
|
|
281
|
+
<HighlightStyles />
|
|
282
|
+
<input value={query} onChange={(e) => setQuery(e.target.value)} />
|
|
283
|
+
<span>{count === 0 ? '0/0' : `${active + 1}/${count}`}</span>
|
|
284
|
+
<button onClick={prev}>↑</button>
|
|
285
|
+
<button onClick={next}>↓</button>
|
|
286
|
+
<textarea ref={ref} rows={10} />
|
|
287
|
+
</>
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
현재 매치는 `search-current`로 등록되고, control의 `scrollTop`/`scrollLeft`를
|
|
293
|
+
조절해 화면에 들어옵니다. `setSelectionRange()`를 쓰지 않으므로 사용자의 캐럿과
|
|
294
|
+
선택 영역을 건드리지 않습니다.
|
|
295
|
+
|
|
150
296
|
#### 상태만 구독 / 지원 여부
|
|
151
297
|
|
|
152
298
|
```tsx
|
|
@@ -198,6 +344,21 @@ expect(controller.getSnapshot('search').count).toBe(3)
|
|
|
198
344
|
| `generateHighlightCSS(styles)` | `::highlight()` CSS 문자열 생성 (주입 없이 반환) |
|
|
199
345
|
| `injectHighlightStyles(styles, id?)` | `::highlight()` CSS 동적 주입 (`<style>` 삽입) |
|
|
200
346
|
|
|
347
|
+
#### core — form control (`OpaqueRange`)
|
|
348
|
+
|
|
349
|
+
| export | 설명 |
|
|
350
|
+
| ------------------------------------------- | ---------------------------------------------------------------------------------- |
|
|
351
|
+
| `createValueHighlighter(opts)` | control의 값을 토큰화해 계속 하이라이트 (`refresh` / `dispose` / `names` / `supported`) |
|
|
352
|
+
| `createValueRangeRegistry(opts)` | span을 직접 계산할 때 쓰는 한 세대 관리자 (`commit` / `dispose`) |
|
|
353
|
+
| `tokenizeValue(value, rules, opts?)` | 순수 토크나이저: 문자열 + 규칙 → `Token[]` (`overlap: 'first' \| 'all'`) |
|
|
354
|
+
| `groupTokens(tokens, rules)` | `Token[]` → name별 `NamedSpans[]` (priority는 최댓값) |
|
|
355
|
+
| `createValueRanges(element, spans)` | 문자 offset → `ValueRange[]` (범위를 넘으면 throw 대신 clamp) |
|
|
356
|
+
| `disconnectValueRanges(ranges)` | Range를 놓아줘 control이 더는 추적하지 않게 함 |
|
|
357
|
+
| `scrollValueRangeIntoView(element, range)` | control의 스크롤만 조절해 Range를 노출 (선택·캐럿 보존) |
|
|
358
|
+
| `isValueRangeSupported()` | 브라우저 지원 여부 (요소별 지원과 다름) |
|
|
359
|
+
| `supportsValueRange(element)` | 이 요소가 value range를 만들 수 있는지 (type 검사 포함) |
|
|
360
|
+
| `isValueRange(range)` | DOM `Range`와 `ValueRange` 구분 |
|
|
361
|
+
|
|
201
362
|
### react (`@cbcruk/highlight-kit/react`)
|
|
202
363
|
|
|
203
364
|
| export | 설명 |
|
|
@@ -214,11 +375,20 @@ expect(controller.getSnapshot('search').count).toBe(3)
|
|
|
214
375
|
| `<Highlight query name as>` | 선언적 wrapper |
|
|
215
376
|
| `<Highlight.Root>` / `<Highlight.Match>` | 한 컨테이너에 여러 패턴 |
|
|
216
377
|
| `<HighlightStyles styles?>` | `::highlight()` 규칙 `<style>` (기본: `search` / `search-current`) |
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
`
|
|
220
|
-
|
|
221
|
-
`
|
|
378
|
+
| `useValueTokens(rules, opts?)` | form control 토크나이저. `ref` + `{ supported, counts }` |
|
|
379
|
+
| `useValueHighlight(opts)` | form control 단일 패턴. `ref` + `{ count, active, name, supported }` |
|
|
380
|
+
| `useValueHighlightSearch(query, opts?)` | form control 내 검색 + `next`/`prev` + 자동 스크롤 |
|
|
381
|
+
|
|
382
|
+
타입도 함께 export됩니다: `MatchOptions`, `HighlightRange`, `HighlightSnapshot`,
|
|
383
|
+
`HighlightController`, `HighlightSink`, `SourceId`, `TokenRule`, `Token`,
|
|
384
|
+
`TokenizeOptions`, `OverlapStrategy`, `NamedSpans`, `ValueRange`, `ValueRangeElement`,
|
|
385
|
+
`ValueHighlighter`, `ValueHighlighterOptions`, `ValueRangeRegistry`,
|
|
386
|
+
`ValueRangeBindingOptions`, `UseHighlightOptions`, `UseHighlightResult`,
|
|
387
|
+
`UseHighlightSearchOptions`, `UseHighlightSearchResult`, `TextMatchOptions`,
|
|
388
|
+
`HighlightProps`, `HighlightRootProps`, `HighlightMatchProps`, `HighlightStyleMap`,
|
|
389
|
+
`ValueHighlightOptions`, `UseValueTokensOptions`, `UseValueTokensResult`,
|
|
390
|
+
`UseValueHighlightOptions`, `UseValueHighlightResult`,
|
|
391
|
+
`UseValueHighlightSearchOptions`, `UseValueHighlightSearchResult`.
|
|
222
392
|
|
|
223
393
|
## 설계 노트 / 한계
|
|
224
394
|
|
|
@@ -233,12 +403,46 @@ expect(controller.getSnapshot('search').count).toBe(3)
|
|
|
233
403
|
- **Shadow DOM**: `TreeWalker`는 shadow 경계를 넘지 않습니다.
|
|
234
404
|
- **SSR**: `getServerSnapshot`이 항상 빈 스냅샷을 반환하고, 등록은 layout effect에서
|
|
235
405
|
일어나므로 hydration 불일치가 없습니다.
|
|
236
|
-
- **`::highlight()` 지원 속성**: `color`, `background-color`, `text-decoration`(
|
|
237
|
-
|
|
406
|
+
- **`::highlight()` 지원 속성**: `color`, `background-color`, `text-decoration`(과 관련
|
|
407
|
+
속성), `text-shadow`, `stroke-color`/`fill-color`/`stroke-width`, CSS 변수만
|
|
408
|
+
적용됩니다. `border-radius`나 `padding`은 **무시**됩니다. 실제로 전 브라우저에서
|
|
409
|
+
상호운용되는 건 `color`와 `background-color`뿐입니다.
|
|
410
|
+
- **`::selection`이 위에 그려짐**: custom highlight는 내장 highlight pseudo-element보다
|
|
411
|
+
아래 쌓입니다. `priority`는 custom highlight **끼리의** 순서일 뿐, `::selection`
|
|
412
|
+
위로 올릴 수는 없습니다.
|
|
413
|
+
|
|
414
|
+
### form control (`OpaqueRange`) 한계
|
|
415
|
+
|
|
416
|
+
- **지원 요소가 제한적**: `<textarea>`와 `<input type>`이 `text`/`search`/`tel`/`url`/
|
|
417
|
+
`password`인 경우만. 다른 type은 `NotSupportedError`를 던지므로
|
|
418
|
+
`supportsValueRange(element)`로 확인하세요. `contenteditable`과 커스텀 엘리먼트는
|
|
419
|
+
대상이 아닙니다.
|
|
420
|
+
- **Range 수명**: control이 자기가 발급한 Range를 전부 보관하며 편집마다 갱신합니다.
|
|
421
|
+
반드시 `disconnect()`해야 하고, 이 패키지의 `createValueHighlighter` /
|
|
422
|
+
`createValueRangeRegistry`가 대신 처리합니다.
|
|
423
|
+
- **자동 disconnect**: 요소 제거, 조상 제거, 다른 document로 adopt, `input`의 `type`
|
|
424
|
+
변경 시 Range가 죽습니다. `type`을 **지원되는 다른 type**으로 바꿀 때도(`text` →
|
|
425
|
+
`search`) 죽습니다. 전체 `value` 대입도 모든 Range를 offset 0으로 collapse시킵니다.
|
|
426
|
+
- **`startContainer`/`endContainer`가 `undefined`**: 이 API를 출하하면서 container
|
|
427
|
+
속성이 `AbstractRange`에서 새 `NodeRange`로 옮겨졌습니다. `null`이 아니라
|
|
428
|
+
`undefined`입니다 (익스플레이너와 Chrome 릴리스 노트의 설명이 틀렸습니다).
|
|
429
|
+
`controller.getRanges(name)`을 순회할 때는 `isValueRange(range)`로 좁히고, 위치는
|
|
430
|
+
`getBoundingClientRect()`로 읽으세요.
|
|
431
|
+
- **`Range` 전용 API에 못 넣음**: `Selection.addRange()`가 받지 않습니다. `toString()`도
|
|
432
|
+
없으니 텍스트는 `element.value`에서 잘라 쓰세요.
|
|
433
|
+
- **스펙 미확정**: DOM/HTML 스펙 PR이 아직 열려 있고, `disconnect()`는 Chromium에만
|
|
434
|
+
있고 스펙 PR에는 없습니다. 그래서 `ValueRange.disconnect`는 optional입니다.
|
|
238
435
|
|
|
239
436
|
## 브라우저 지원
|
|
240
437
|
|
|
241
|
-
|
|
438
|
+
| 기능 | 지원 |
|
|
439
|
+
| ---------------------------------- | --------------------------------------------------------------- |
|
|
440
|
+
| Custom Highlight API (DOM `Range`) | Chrome/Edge 105+, Safari 17.2+, Firefox 140+ — 전 메이저 커버 |
|
|
441
|
+
| `OpaqueRange` (form control 내부) | Chrome/Edge 152+ (2026-08-25)만. Safari·Firefox 미구현 |
|
|
442
|
+
|
|
443
|
+
`OpaqueRange`는 Chromium 전용이지만 WebKit과 Mozilla 모두 **positive/support**
|
|
444
|
+
표준 포지션을 냈습니다. 미지원 환경에서는 `supported`가 `false`가 되고 훅이 아무
|
|
445
|
+
일도 하지 않으므로, control은 평소대로 렌더링됩니다 — 조건 없이 호출해도 안전합니다.
|
|
242
446
|
|
|
243
447
|
## 개발
|
|
244
448
|
|