@bsky.app/tapper 0.1.1 → 0.2.2
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/CHANGELOG.md +13 -0
- package/README.md +204 -1
- package/build/index.d.ts +36 -5
- package/build/index.d.ts.map +1 -1
- package/build/index.js +197 -8
- package/build/index.js.map +1 -1
- package/build/types.d.ts +26 -3
- package/build/types.d.ts.map +1 -1
- package/build/types.js.map +1 -1
- package/build/util.d.ts +10 -1
- package/build/util.d.ts.map +1 -1
- package/build/util.js +116 -30
- package/build/util.js.map +1 -1
- package/package.json +15 -15
- package/src/index.ts +257 -10
- package/src/types.ts +36 -9
- package/src/util.ts +131 -36
- package/build/core.d.ts +0 -30
- package/build/core.d.ts.map +0 -1
- package/build/core.js +0 -148
- package/build/core.js.map +0 -1
- package/build/index.web.d.ts +0 -17
- package/build/index.web.d.ts.map +0 -1
- package/build/index.web.js +0 -33
- package/build/index.web.js.map +0 -1
- package/src/core.ts +0 -185
- package/src/index.web.ts +0 -46
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# @bsky.app/tapper
|
|
2
|
+
|
|
3
|
+
## 0.2.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [`255ae92`](https://github.com/bluesky-social/toolbox/commit/255ae923d552c12b6ad36dedde37a9bf44d02866) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Bump sift/tapper for publish
|
|
8
|
+
|
|
9
|
+
## 0.2.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- [`06c8141`](https://github.com/bluesky-social/toolbox/commit/06c81411f8420240c8c794d41398df034c514fda) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Bump packages
|
package/README.md
CHANGED
|
@@ -1,3 +1,206 @@
|
|
|
1
1
|
# @bsky.app/tapper
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A facet-aware text input engine for React Native (including web). Parses
|
|
4
|
+
configurable patterns (mentions, emoji, tags, URLs) from text as the user types,
|
|
5
|
+
producing a node list you render as a colored preview overlay on top of a
|
|
6
|
+
transparent `TextInput`.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
pnpm add @bsky.app/tapper
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Peer dependencies: `react`, `react-native`.
|
|
15
|
+
|
|
16
|
+
## Basic example
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
import {useEffect} from 'react'
|
|
20
|
+
import {View, Text, TextInput} from 'react-native'
|
|
21
|
+
import {useTapper} from '@bsky.app/tapper'
|
|
22
|
+
|
|
23
|
+
function Composer() {
|
|
24
|
+
const {text, nodes, inputProps, focus, on} = useTapper({
|
|
25
|
+
facets: {
|
|
26
|
+
mention: {match: /@([a-zA-Z0-9._]+)/g},
|
|
27
|
+
emoji: {match: /:([a-zA-Z0-9_]+):/g},
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// Re-focus after an autocomplete insertion
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
return on('afterInsert', () => focus())
|
|
34
|
+
}, [on, focus])
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<View style={{position: 'relative'}}>
|
|
38
|
+
{/* Preview overlay — renders colored facets */}
|
|
39
|
+
<View
|
|
40
|
+
style={{position: 'absolute', top: 0, left: 0, right: 0, bottom: 0}}>
|
|
41
|
+
<Text>
|
|
42
|
+
{nodes.map((node, i) => (
|
|
43
|
+
<Text
|
|
44
|
+
key={i}
|
|
45
|
+
style={{
|
|
46
|
+
color:
|
|
47
|
+
node.type === 'facet'
|
|
48
|
+
? '#2e7de9'
|
|
49
|
+
: node.type === 'trigger'
|
|
50
|
+
? '#999'
|
|
51
|
+
: '#000',
|
|
52
|
+
}}>
|
|
53
|
+
{node.raw}
|
|
54
|
+
</Text>
|
|
55
|
+
))}
|
|
56
|
+
</Text>
|
|
57
|
+
</View>
|
|
58
|
+
|
|
59
|
+
{/* Actual input — transparent text, visible caret */}
|
|
60
|
+
<TextInput
|
|
61
|
+
{...inputProps}
|
|
62
|
+
multiline
|
|
63
|
+
style={{
|
|
64
|
+
color: 'transparent',
|
|
65
|
+
caretColor: '#000',
|
|
66
|
+
}}
|
|
67
|
+
placeholder="Type here..."
|
|
68
|
+
/>
|
|
69
|
+
</View>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The preview overlay and `TextInput` must share the same text styles (font
|
|
75
|
+
family, size, line height, padding) so they align exactly.
|
|
76
|
+
|
|
77
|
+
## How it works
|
|
78
|
+
|
|
79
|
+
`useTapper` returns:
|
|
80
|
+
|
|
81
|
+
| Property | Description |
|
|
82
|
+
| ------------- | --------------------------------------------------- |
|
|
83
|
+
| `text` | Current text value |
|
|
84
|
+
| `nodes` | Parsed node list for rendering |
|
|
85
|
+
| `activeFacet` | The facet the cursor is currently inside, or `null` |
|
|
86
|
+
| `inputProps` | Spread onto your `TextInput` |
|
|
87
|
+
| `on` | Subscribe to events |
|
|
88
|
+
| `focus` | Re-focus the input (useful after `commit`) |
|
|
89
|
+
|
|
90
|
+
### Nodes
|
|
91
|
+
|
|
92
|
+
Each node has a `type` discriminant:
|
|
93
|
+
|
|
94
|
+
- **`'text'`** — plain text, render as-is.
|
|
95
|
+
- **`'trigger'`** — the user just typed a trigger character (e.g. `@`) but
|
|
96
|
+
hasn't typed any content yet. Has `facetType` so you know which kind.
|
|
97
|
+
- **`'facet'`** — a matched pattern. Has `facetType` (e.g. `'mention'`,
|
|
98
|
+
`'emoji'`).
|
|
99
|
+
|
|
100
|
+
All nodes have `raw` (the full matched text including trigger) and `value`
|
|
101
|
+
(content only, trigger stripped). For display, use `node.raw`.
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
// Typing "@eric" produces:
|
|
105
|
+
{type: 'facet', facetType: 'mention', raw: '@eric', value: 'eric', start: 0, end: 5}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Active facet
|
|
109
|
+
|
|
110
|
+
When the cursor is inside a facet or right after a trigger character,
|
|
111
|
+
`activeFacet` is non-null:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
activeFacet: {
|
|
115
|
+
type: 'mention', // the facet type
|
|
116
|
+
value: 'er', // content after trigger (no @)
|
|
117
|
+
range: {start: 0, end: 3},
|
|
118
|
+
insert: (value, options?) => void,
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Call `activeFacet.insert('@eric')` to replace the in-progress facet with a
|
|
123
|
+
final value. A trailing space is appended by default; pass
|
|
124
|
+
`{noTrailingSpace: true}` to suppress it.
|
|
125
|
+
|
|
126
|
+
## Emoji auto-commit example
|
|
127
|
+
|
|
128
|
+
A common pattern: when the user types a closing `:` on an emoji shortcode
|
|
129
|
+
(e.g. `:wave:`), immediately replace it with the emoji character.
|
|
130
|
+
|
|
131
|
+
```tsx
|
|
132
|
+
const {on, activeFacet, focus} = useTapper({
|
|
133
|
+
facets: {
|
|
134
|
+
emoji: {match: /:([a-zA-Z0-9_]+):/g},
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
const EMOJI: Record<string, string> = {
|
|
139
|
+
wave: '👋',
|
|
140
|
+
tada: '🎉',
|
|
141
|
+
v: '✌️',
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
return on('activeFacet', facet => {
|
|
146
|
+
if (facet?.type === 'emoji' && facet.value.endsWith(':')) {
|
|
147
|
+
const name = facet.value.slice(0, -1) // strip closing ":"
|
|
148
|
+
const emoji = EMOJI[name]
|
|
149
|
+
if (emoji) {
|
|
150
|
+
facet.insert(emoji, {noTrailingSpace: true})
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
}, [on])
|
|
155
|
+
|
|
156
|
+
// Re-focus after insertion
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
return on('afterInsert', () => focus())
|
|
159
|
+
}, [on, focus])
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Events
|
|
163
|
+
|
|
164
|
+
Subscribe with `on(event, callback)`. Returns an unsubscribe function — use it
|
|
165
|
+
in a `useEffect` cleanup.
|
|
166
|
+
|
|
167
|
+
| Event | Data | When |
|
|
168
|
+
| ---------------- | --------------------------- | ----------------------------------------------- |
|
|
169
|
+
| `activeFacet` | `TapperActiveFacet \| null` | Cursor enters or leaves a facet |
|
|
170
|
+
| `facetCommitted` | `TapperFacet` | A facet is finalized (cursor left or committed) |
|
|
171
|
+
| `afterInsert` | `TapperFacet` | After `insert()` replaces text |
|
|
172
|
+
|
|
173
|
+
### `focus()`
|
|
174
|
+
|
|
175
|
+
After `insert()` replaces text, the input may lose focus on some platforms.
|
|
176
|
+
Listen for `afterInsert` and call `focus()` to restore it:
|
|
177
|
+
|
|
178
|
+
```tsx
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
return on('afterInsert', () => focus())
|
|
181
|
+
}, [on, focus])
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Atomic deletion
|
|
185
|
+
|
|
186
|
+
Committed facets are deleted atomically — backspacing into a committed facet
|
|
187
|
+
from outside removes the entire facet in one keystroke, rather than deleting
|
|
188
|
+
character by character.
|
|
189
|
+
|
|
190
|
+
## `initialText`
|
|
191
|
+
|
|
192
|
+
Pre-populate the input with text. Any matched facets are automatically marked
|
|
193
|
+
as committed.
|
|
194
|
+
|
|
195
|
+
```tsx
|
|
196
|
+
const {nodes} = useTapper({
|
|
197
|
+
facets: {mention: {match: /@([a-zA-Z0-9._]+)/g}},
|
|
198
|
+
initialText: 'Hello @eric',
|
|
199
|
+
})
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## `replaceText`
|
|
203
|
+
|
|
204
|
+
The `Tapper` class (used internally by `useTapper`) exposes `replaceText(text,
|
|
205
|
+
cursor?)` for programmatic text replacement. All matched facets in the new text
|
|
206
|
+
are marked as committed.
|
package/build/index.d.ts
CHANGED
|
@@ -1,8 +1,40 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { TapperConfig, TapperEvents, TapperNode, TapperActiveFacet, TapperSnapshot } from './types';
|
|
2
2
|
export * from './types';
|
|
3
|
-
export
|
|
3
|
+
export declare class Tapper {
|
|
4
|
+
private facetRegexes;
|
|
5
|
+
private triggers;
|
|
6
|
+
private listeners;
|
|
7
|
+
text: string;
|
|
8
|
+
cursor: number;
|
|
9
|
+
nodes: TapperNode[];
|
|
10
|
+
activeFacet: TapperActiveFacet | null;
|
|
11
|
+
private updating;
|
|
12
|
+
private pendingCursor;
|
|
13
|
+
private storeListeners;
|
|
14
|
+
private snapshot;
|
|
15
|
+
constructor(config: TapperConfig);
|
|
16
|
+
subscribe: (listener: () => void) => () => boolean;
|
|
17
|
+
getSnapshot: () => TapperSnapshot;
|
|
18
|
+
on: <K extends keyof TapperEvents>(event: K, cb: (data: TapperEvents[K]) => void) => () => void;
|
|
19
|
+
private emit;
|
|
20
|
+
private notify;
|
|
21
|
+
private update;
|
|
22
|
+
handleTextChange: (newText: string) => void;
|
|
23
|
+
handleSelectionChange: (e: {
|
|
24
|
+
nativeEvent: {
|
|
25
|
+
selection: {
|
|
26
|
+
start: number;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
}) => void;
|
|
30
|
+
replaceText: (text: string, cursor?: number) => void;
|
|
31
|
+
private insert;
|
|
32
|
+
}
|
|
4
33
|
export declare function useTapper(config: TapperConfig): {
|
|
34
|
+
on: <K extends keyof TapperEvents>(event: K, cb: (data: TapperEvents[K]) => void) => () => void;
|
|
35
|
+
focus: () => void | undefined;
|
|
5
36
|
inputProps: {
|
|
37
|
+
ref: React.RefObject<any>;
|
|
6
38
|
value: string;
|
|
7
39
|
selection: {
|
|
8
40
|
start: number;
|
|
@@ -17,10 +49,9 @@ export declare function useTapper(config: TapperConfig): {
|
|
|
17
49
|
};
|
|
18
50
|
}) => void;
|
|
19
51
|
};
|
|
20
|
-
store: import("./core").Tapper;
|
|
21
52
|
text: string;
|
|
22
53
|
cursor: number;
|
|
23
|
-
nodes:
|
|
24
|
-
activeFacet:
|
|
54
|
+
nodes: TapperNode[];
|
|
55
|
+
activeFacet: TapperActiveFacet | null;
|
|
25
56
|
};
|
|
26
57
|
//# sourceMappingURL=index.d.ts.map
|
package/build/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,iBAAiB,EACjB,cAAc,EACf,MAAM,SAAS,CAAA;AAUhB,cAAc,SAAS,CAAA;AAEvB,qBAAa,MAAM;IACjB,OAAO,CAAC,YAAY,CAAsB;IAC1C,OAAO,CAAC,QAAQ,CAAqB;IAIrC,OAAO,CAAC,SAAS,CAA0D;IAG3E,IAAI,SAAK;IACT,MAAM,SAAI;IACV,KAAK,EAAE,UAAU,EAAE,CAAK;IACxB,WAAW,EAAE,iBAAiB,GAAG,IAAI,CAAO;IAG5C,OAAO,CAAC,QAAQ,CAAQ;IACxB,OAAO,CAAC,aAAa,CAAsB;IAG3C,OAAO,CAAC,cAAc,CAAwB;IAC9C,OAAO,CAAC,QAAQ,CAAgB;gBAEpB,MAAM,EAAE,YAAY;IAchC,SAAS,GAAI,UAAU,MAAM,IAAI,mBAGhC;IAED,WAAW,QAAO,cAAc,CAE/B;IAED,EAAE,GAAI,CAAC,SAAS,MAAM,YAAY,EAChC,OAAO,CAAC,EACR,IAAI,CAAC,IAAI,EAAE,YAAY,CAAC,CAAC,CAAC,KAAK,IAAI,gBAOpC;IAED,OAAO,CAAC,IAAI;IAIZ,OAAO,CAAC,MAAM;IAUd,OAAO,CAAC,MAAM;IA8Dd,gBAAgB,GAAI,SAAS,MAAM,UAuBlC;IAED,qBAAqB,GAAI,GAAG;QAAC,WAAW,EAAE;YAAC,SAAS,EAAE;gBAAC,KAAK,EAAE,MAAM,CAAA;aAAC,CAAA;SAAC,CAAA;KAAC,UActE;IAED,WAAW,GAAI,MAAM,MAAM,EAAE,SAAS,MAAM,UA+B3C;IAED,OAAO,CAAC,MAAM,CAoBb;CACF;AAED,wBAAgB,SAAS,CAAC,MAAM,EAAE,YAAY;SAxLtC,CAAC,SAAS,MAAM,YAAY,2CAED,IAAI;;;aAgMhB,KAAK,CAAC,SAAS,CAAC,GAAG,CAAC;;;;;;gCA3GZ,MAAM;+BAyBP;YAAC,WAAW,EAAE;gBAAC,SAAS,EAAE;oBAAC,KAAK,EAAE,MAAM,CAAA;iBAAC,CAAA;aAAC,CAAA;SAAC;;;;;;EAyFxE"}
|
package/build/index.js
CHANGED
|
@@ -1,15 +1,204 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useRef, useState, useSyncExternalStore } from 'react';
|
|
2
|
+
import { parseNodesFromText, detectActiveFacet, deriveTriggers, nodeToFacet, compileFacetRegexes, } from './util';
|
|
2
3
|
export * from './types';
|
|
3
|
-
export
|
|
4
|
+
export class Tapper {
|
|
5
|
+
facetRegexes;
|
|
6
|
+
triggers;
|
|
7
|
+
// Event emitters
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
9
|
+
listeners = new Map();
|
|
10
|
+
// Public state
|
|
11
|
+
text = '';
|
|
12
|
+
cursor = 0;
|
|
13
|
+
nodes = [];
|
|
14
|
+
activeFacet = null;
|
|
15
|
+
// Internal tracking
|
|
16
|
+
updating = false;
|
|
17
|
+
pendingCursor = null;
|
|
18
|
+
// useSyncExternalStore plumbing
|
|
19
|
+
storeListeners = new Set();
|
|
20
|
+
snapshot;
|
|
21
|
+
constructor(config) {
|
|
22
|
+
this.facetRegexes = compileFacetRegexes(config.facets);
|
|
23
|
+
this.triggers = deriveTriggers(config.facets);
|
|
24
|
+
this.snapshot = {
|
|
25
|
+
text: this.text,
|
|
26
|
+
cursor: this.cursor,
|
|
27
|
+
nodes: this.nodes,
|
|
28
|
+
activeFacet: this.activeFacet,
|
|
29
|
+
};
|
|
30
|
+
if (config.initialText) {
|
|
31
|
+
this.replaceText(config.initialText);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
subscribe = (listener) => {
|
|
35
|
+
this.storeListeners.add(listener);
|
|
36
|
+
return () => this.storeListeners.delete(listener);
|
|
37
|
+
};
|
|
38
|
+
getSnapshot = () => {
|
|
39
|
+
return this.snapshot;
|
|
40
|
+
};
|
|
41
|
+
on = (event, cb) => {
|
|
42
|
+
if (!this.listeners.has(event))
|
|
43
|
+
this.listeners.set(event, new Set());
|
|
44
|
+
this.listeners.get(event).add(cb);
|
|
45
|
+
return () => {
|
|
46
|
+
this.listeners.get(event)?.delete(cb);
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
emit(event, data) {
|
|
50
|
+
this.listeners.get(event)?.forEach(cb => cb(data));
|
|
51
|
+
}
|
|
52
|
+
notify() {
|
|
53
|
+
this.snapshot = {
|
|
54
|
+
text: this.text,
|
|
55
|
+
cursor: this.cursor,
|
|
56
|
+
nodes: this.nodes,
|
|
57
|
+
activeFacet: this.activeFacet,
|
|
58
|
+
};
|
|
59
|
+
this.storeListeners.forEach(l => l());
|
|
60
|
+
}
|
|
61
|
+
update(newText, cursor) {
|
|
62
|
+
// guard against circular updates when commit() is called during an update cycle
|
|
63
|
+
if (this.updating)
|
|
64
|
+
return;
|
|
65
|
+
this.updating = true;
|
|
66
|
+
const nodes = newText === this.text
|
|
67
|
+
? this.nodes
|
|
68
|
+
: parseNodesFromText(newText, this.facetRegexes, this.nodes, cursor, this.triggers);
|
|
69
|
+
const prev = this.activeFacet;
|
|
70
|
+
const detected = detectActiveFacet(nodes, newText, cursor, this.triggers);
|
|
71
|
+
// When cursor leaves a facet, commit it
|
|
72
|
+
let committedNode = null;
|
|
73
|
+
if (prev && (!detected || detected.type !== prev.type)) {
|
|
74
|
+
for (const node of nodes) {
|
|
75
|
+
if (node.type === 'facet' &&
|
|
76
|
+
node.facetType === prev.type &&
|
|
77
|
+
node.start === prev.range.start &&
|
|
78
|
+
node.end === prev.range.end) {
|
|
79
|
+
node.committed = true;
|
|
80
|
+
committedNode = node;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Build activeFacet with commit() baked in
|
|
86
|
+
const active = detected
|
|
87
|
+
? { ...detected, insert: this.insert }
|
|
88
|
+
: null;
|
|
89
|
+
this.text = newText;
|
|
90
|
+
this.cursor = cursor;
|
|
91
|
+
this.nodes = nodes;
|
|
92
|
+
this.activeFacet = active;
|
|
93
|
+
this.updating = false;
|
|
94
|
+
this.notify();
|
|
95
|
+
// Fire events after state is finalized
|
|
96
|
+
const facetChanged = active?.type !== prev?.type ||
|
|
97
|
+
active?.value !== prev?.value ||
|
|
98
|
+
active?.range.start !== prev?.range.start ||
|
|
99
|
+
active?.range.end !== prev?.range.end;
|
|
100
|
+
if (facetChanged) {
|
|
101
|
+
this.emit('activeFacet', active);
|
|
102
|
+
}
|
|
103
|
+
if (committedNode) {
|
|
104
|
+
this.emit('facetCommitted', nodeToFacet(committedNode));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
handleTextChange = (newText) => {
|
|
108
|
+
const diff = newText.length - this.text.length;
|
|
109
|
+
let newCursor = Math.max(this.cursor + diff, 0);
|
|
110
|
+
// Atomic deletion: backspace into a facet from outside
|
|
111
|
+
if (diff === -1 && newCursor < this.cursor) {
|
|
112
|
+
for (const node of this.nodes) {
|
|
113
|
+
if (node.type === 'facet' &&
|
|
114
|
+
node.committed &&
|
|
115
|
+
this.cursor === node.end) {
|
|
116
|
+
const remnant = node.end - node.start - 1;
|
|
117
|
+
newText =
|
|
118
|
+
newText.slice(0, node.start) + newText.slice(node.start + remnant);
|
|
119
|
+
newCursor = node.start;
|
|
120
|
+
this.pendingCursor = newCursor;
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
this.update(newText, newCursor);
|
|
126
|
+
};
|
|
127
|
+
handleSelectionChange = (e) => {
|
|
128
|
+
const newCursor = e.nativeEvent.selection.start;
|
|
129
|
+
// After atomic deletion, ignore stale cursor values from the DOM
|
|
130
|
+
if (this.pendingCursor !== null) {
|
|
131
|
+
if (newCursor !== this.pendingCursor) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
this.pendingCursor = null;
|
|
135
|
+
}
|
|
136
|
+
if (newCursor === this.cursor)
|
|
137
|
+
return;
|
|
138
|
+
this.cursor = newCursor;
|
|
139
|
+
this.update(this.text, newCursor);
|
|
140
|
+
};
|
|
141
|
+
replaceText = (text, cursor) => {
|
|
142
|
+
const prevActive = this.activeFacet;
|
|
143
|
+
const nodes = parseNodesFromText(text, this.facetRegexes);
|
|
144
|
+
// Mark all non-text nodes as committed (pre-existing facets)
|
|
145
|
+
const newlyCommitted = [];
|
|
146
|
+
for (const node of nodes) {
|
|
147
|
+
if (node.type === 'facet') {
|
|
148
|
+
node.committed = true;
|
|
149
|
+
newlyCommitted.push(node);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const pos = cursor ?? text.length;
|
|
153
|
+
const detected = detectActiveFacet(nodes, text, pos, this.triggers);
|
|
154
|
+
const active = detected
|
|
155
|
+
? { ...detected, insert: this.insert }
|
|
156
|
+
: null;
|
|
157
|
+
this.text = text;
|
|
158
|
+
this.cursor = pos;
|
|
159
|
+
this.nodes = nodes;
|
|
160
|
+
this.activeFacet = active;
|
|
161
|
+
this.notify();
|
|
162
|
+
if (active !== prevActive) {
|
|
163
|
+
this.emit('activeFacet', active);
|
|
164
|
+
}
|
|
165
|
+
for (const node of newlyCommitted) {
|
|
166
|
+
this.emit('facetCommitted', nodeToFacet(node));
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
insert = (value, options) => {
|
|
170
|
+
if (!this.activeFacet)
|
|
171
|
+
return;
|
|
172
|
+
const replacement = value + (options?.noTrailingSpace ? '' : ' ');
|
|
173
|
+
const newText = this.text.slice(0, this.activeFacet.range.start) +
|
|
174
|
+
replacement +
|
|
175
|
+
this.text.slice(this.activeFacet.range.end);
|
|
176
|
+
const newCursor = this.activeFacet.range.start + replacement.length;
|
|
177
|
+
if (this.activeFacet) {
|
|
178
|
+
this.activeFacet.value = value;
|
|
179
|
+
this.activeFacet.range = {
|
|
180
|
+
start: this.activeFacet.range.start,
|
|
181
|
+
end: this.activeFacet.range.start + value.length,
|
|
182
|
+
};
|
|
183
|
+
this.emit('afterInsert', this.activeFacet);
|
|
184
|
+
}
|
|
185
|
+
this.update(newText, newCursor);
|
|
186
|
+
};
|
|
187
|
+
}
|
|
4
188
|
export function useTapper(config) {
|
|
5
|
-
const
|
|
189
|
+
const [store] = useState(() => new Tapper(config));
|
|
190
|
+
const state = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
|
191
|
+
const inputRef = useRef(null);
|
|
6
192
|
return {
|
|
7
|
-
...
|
|
193
|
+
...state,
|
|
194
|
+
on: store.on,
|
|
195
|
+
focus: () => inputRef.current?.focus(),
|
|
8
196
|
inputProps: {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
197
|
+
ref: inputRef,
|
|
198
|
+
value: state.text,
|
|
199
|
+
selection: { start: state.cursor, end: state.cursor },
|
|
200
|
+
onChangeText: store.handleTextChange,
|
|
201
|
+
onSelectionChange: store.handleSelectionChange,
|
|
13
202
|
},
|
|
14
203
|
};
|
|
15
204
|
}
|
package/build/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,aAAa,EAAC,MAAM,QAAQ,CAAA;AAGpC,cAAc,SAAS,CAAA;AACvB,cAAc,QAAQ,CAAA;AAEtB,MAAM,UAAU,SAAS,CAAC,MAAoB;IAC5C,MAAM,MAAM,GAAG,aAAa,CAAC,MAAM,CAAC,CAAA;IAEpC,OAAO;QACL,GAAG,MAAM;QACT,UAAU,EAAE;YACV,KAAK,EAAE,MAAM,CAAC,IAAI;YAClB,SAAS,EAAE,EAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAC;YACrD,YAAY,EAAE,MAAM,CAAC,KAAK,CAAC,gBAAgB;YAC3C,iBAAiB,EAAE,CAAC,CAA8C,EAAE,EAAE,CACpE,MAAM,CAAC,KAAK,CAAC,qBAAqB,CAAC,CAAC,CAAC,WAAW,CAAC,SAAS,CAAC,KAAK,CAAC;SACpE;KACF,CAAA;AACH,CAAC","sourcesContent":["import {useTapperCore} from './core'\nimport {type TapperConfig} from './types'\n\nexport * from './types'\nexport * from './core'\n\nexport function useTapper(config: TapperConfig) {\n const tapper = useTapperCore(config)\n\n return {\n ...tapper,\n inputProps: {\n value: tapper.text,\n selection: {start: tapper.cursor, end: tapper.cursor},\n onChangeText: tapper.store.handleTextChange,\n onSelectionChange: (e: {nativeEvent: {selection: {start: number}}}) =>\n tapper.store.handleSelectionChange(e.nativeEvent.selection.start),\n },\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["import {useRef, useState, useSyncExternalStore} from 'react'\n\nimport {\n TapperConfig,\n TapperEvents,\n TapperNode,\n TapperActiveFacet,\n TapperSnapshot,\n} from './types'\nimport {\n parseNodesFromText,\n detectActiveFacet,\n deriveTriggers,\n nodeToFacet,\n compileFacetRegexes,\n type CompiledFacetRegexes,\n} from './util'\n\nexport * from './types'\n\nexport class Tapper {\n private facetRegexes: CompiledFacetRegexes\n private triggers: Map<string, string>\n\n // Event emitters\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n private listeners = new Map<keyof TapperEvents, Set<(data: any) => void>>()\n\n // Public state\n text = ''\n cursor = 0\n nodes: TapperNode[] = []\n activeFacet: TapperActiveFacet | null = null\n\n // Internal tracking\n private updating = false\n private pendingCursor: number | null = null\n\n // useSyncExternalStore plumbing\n private storeListeners = new Set<() => void>()\n private snapshot: TapperSnapshot\n\n constructor(config: TapperConfig) {\n this.facetRegexes = compileFacetRegexes(config.facets)\n this.triggers = deriveTriggers(config.facets)\n this.snapshot = {\n text: this.text,\n cursor: this.cursor,\n nodes: this.nodes,\n activeFacet: this.activeFacet,\n }\n if (config.initialText) {\n this.replaceText(config.initialText)\n }\n }\n\n subscribe = (listener: () => void) => {\n this.storeListeners.add(listener)\n return () => this.storeListeners.delete(listener)\n }\n\n getSnapshot = (): TapperSnapshot => {\n return this.snapshot\n }\n\n on = <K extends keyof TapperEvents>(\n event: K,\n cb: (data: TapperEvents[K]) => void,\n ) => {\n if (!this.listeners.has(event)) this.listeners.set(event, new Set())\n this.listeners.get(event)!.add(cb)\n return () => {\n this.listeners.get(event)?.delete(cb)\n }\n }\n\n private emit<K extends keyof TapperEvents>(event: K, data: TapperEvents[K]) {\n this.listeners.get(event)?.forEach(cb => cb(data))\n }\n\n private notify() {\n this.snapshot = {\n text: this.text,\n cursor: this.cursor,\n nodes: this.nodes,\n activeFacet: this.activeFacet,\n }\n this.storeListeners.forEach(l => l())\n }\n\n private update(newText: string, cursor: number) {\n // guard against circular updates when commit() is called during an update cycle\n if (this.updating) return\n this.updating = true\n\n const nodes =\n newText === this.text\n ? this.nodes\n : parseNodesFromText(\n newText,\n this.facetRegexes,\n this.nodes,\n cursor,\n this.triggers,\n )\n const prev = this.activeFacet\n const detected = detectActiveFacet(nodes, newText, cursor, this.triggers)\n\n // When cursor leaves a facet, commit it\n let committedNode: TapperNode | null = null\n if (prev && (!detected || detected.type !== prev.type)) {\n for (const node of nodes) {\n if (\n node.type === 'facet' &&\n node.facetType === prev.type &&\n node.start === prev.range.start &&\n node.end === prev.range.end\n ) {\n node.committed = true\n committedNode = node\n break\n }\n }\n }\n\n // Build activeFacet with commit() baked in\n const active: TapperActiveFacet | null = detected\n ? {...detected, insert: this.insert}\n : null\n\n this.text = newText\n this.cursor = cursor\n this.nodes = nodes\n this.activeFacet = active\n\n this.updating = false\n this.notify()\n\n // Fire events after state is finalized\n const facetChanged =\n active?.type !== prev?.type ||\n active?.value !== prev?.value ||\n active?.range.start !== prev?.range.start ||\n active?.range.end !== prev?.range.end\n if (facetChanged) {\n this.emit('activeFacet', active)\n }\n if (committedNode) {\n this.emit('facetCommitted', nodeToFacet(committedNode))\n }\n }\n\n handleTextChange = (newText: string) => {\n const diff = newText.length - this.text.length\n let newCursor = Math.max(this.cursor + diff, 0)\n\n // Atomic deletion: backspace into a facet from outside\n if (diff === -1 && newCursor < this.cursor) {\n for (const node of this.nodes) {\n if (\n node.type === 'facet' &&\n node.committed &&\n this.cursor === node.end\n ) {\n const remnant = node.end - node.start - 1\n newText =\n newText.slice(0, node.start) + newText.slice(node.start + remnant)\n newCursor = node.start\n this.pendingCursor = newCursor\n break\n }\n }\n }\n\n this.update(newText, newCursor)\n }\n\n handleSelectionChange = (e: {nativeEvent: {selection: {start: number}}}) => {\n const newCursor = e.nativeEvent.selection.start\n // After atomic deletion, ignore stale cursor values from the DOM\n if (this.pendingCursor !== null) {\n if (newCursor !== this.pendingCursor) {\n return\n }\n this.pendingCursor = null\n }\n\n if (newCursor === this.cursor) return\n\n this.cursor = newCursor\n this.update(this.text, newCursor)\n }\n\n replaceText = (text: string, cursor?: number) => {\n const prevActive = this.activeFacet\n const nodes = parseNodesFromText(text, this.facetRegexes)\n // Mark all non-text nodes as committed (pre-existing facets)\n const newlyCommitted: TapperNode[] = []\n for (const node of nodes) {\n if (node.type === 'facet') {\n node.committed = true\n newlyCommitted.push(node)\n }\n }\n\n const pos = cursor ?? text.length\n const detected = detectActiveFacet(nodes, text, pos, this.triggers)\n const active: TapperActiveFacet | null = detected\n ? {...detected, insert: this.insert}\n : null\n\n this.text = text\n this.cursor = pos\n this.nodes = nodes\n this.activeFacet = active\n\n this.notify()\n\n if (active !== prevActive) {\n this.emit('activeFacet', active)\n }\n for (const node of newlyCommitted) {\n this.emit('facetCommitted', nodeToFacet(node))\n }\n }\n\n private insert = (value: string, options?: {noTrailingSpace?: boolean}) => {\n if (!this.activeFacet) return\n\n const replacement = value + (options?.noTrailingSpace ? '' : ' ')\n const newText =\n this.text.slice(0, this.activeFacet.range.start) +\n replacement +\n this.text.slice(this.activeFacet.range.end)\n const newCursor = this.activeFacet.range.start + replacement.length\n\n if (this.activeFacet) {\n this.activeFacet.value = value\n this.activeFacet.range = {\n start: this.activeFacet.range.start,\n end: this.activeFacet.range.start + value.length,\n }\n this.emit('afterInsert', this.activeFacet)\n }\n\n this.update(newText, newCursor)\n }\n}\n\nexport function useTapper(config: TapperConfig) {\n const [store] = useState(() => new Tapper(config))\n const state = useSyncExternalStore(store.subscribe, store.getSnapshot)\n const inputRef = useRef<{focus(): void}>(null)\n\n return {\n ...state,\n on: store.on,\n focus: () => inputRef.current?.focus(),\n inputProps: {\n ref: inputRef as React.RefObject<any>,\n value: state.text,\n selection: {start: state.cursor, end: state.cursor},\n onChangeText: store.handleTextChange,\n onSelectionChange: store.handleSelectionChange,\n },\n }\n}\n"]}
|
package/build/types.d.ts
CHANGED
|
@@ -3,15 +3,33 @@ export type TapperFacetConfig = {
|
|
|
3
3
|
};
|
|
4
4
|
export type TapperFacetConfigMap = Record<string, TapperFacetConfig>;
|
|
5
5
|
export type TapperNode = {
|
|
6
|
-
type:
|
|
6
|
+
type: 'text';
|
|
7
|
+
raw: string;
|
|
8
|
+
value: string;
|
|
9
|
+
start: number;
|
|
10
|
+
end: number;
|
|
11
|
+
committed?: boolean;
|
|
12
|
+
facetType?: undefined;
|
|
13
|
+
} | {
|
|
14
|
+
type: 'trigger';
|
|
15
|
+
raw: string;
|
|
16
|
+
value: string;
|
|
17
|
+
start: number;
|
|
18
|
+
end: number;
|
|
19
|
+
committed?: boolean;
|
|
20
|
+
facetType: string;
|
|
21
|
+
} | {
|
|
22
|
+
type: 'facet';
|
|
23
|
+
raw: string;
|
|
7
24
|
value: string;
|
|
8
25
|
start: number;
|
|
9
26
|
end: number;
|
|
10
27
|
committed?: boolean;
|
|
28
|
+
facetType: string;
|
|
11
29
|
};
|
|
12
30
|
export type TapperFacet = {
|
|
13
|
-
|
|
14
|
-
|
|
31
|
+
type: string;
|
|
32
|
+
value: string;
|
|
15
33
|
range: {
|
|
16
34
|
start: number;
|
|
17
35
|
end: number;
|
|
@@ -32,4 +50,9 @@ export type TapperConfig = {
|
|
|
32
50
|
facets: TapperFacetConfigMap;
|
|
33
51
|
initialText?: string;
|
|
34
52
|
};
|
|
53
|
+
export type TapperEvents = {
|
|
54
|
+
activeFacet: TapperActiveFacet | null;
|
|
55
|
+
facetCommitted: TapperFacet;
|
|
56
|
+
afterInsert: TapperFacet;
|
|
57
|
+
};
|
|
35
58
|
//# sourceMappingURL=types.d.ts.map
|
package/build/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,iBAAiB,GAAG;IAC9B,KAAK,EAAE,MAAM,CAAA;CACd,CAAA;AAED,MAAM,MAAM,oBAAoB,GAAG,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAA;AAEpE,MAAM,MAAM,UAAU,
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,iBAAiB,GAAG;IAC9B,KAAK,EAAE,MAAM,CAAA;CACd,CAAA;AAED,MAAM,MAAM,oBAAoB,GAAG,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAA;AAEpE,MAAM,MAAM,UAAU,GAClB;IACE,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,SAAS,CAAC,EAAE,SAAS,CAAA;CACtB,GACD;IACE,IAAI,EAAE,SAAS,CAAA;IACf,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;CAClB,GACD;IACE,IAAI,EAAE,OAAO,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;CAClB,CAAA;AAEL,MAAM,MAAM,WAAW,GAAG;IACxB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAC,CAAA;CACpC,CAAA;AAED,MAAM,MAAM,iBAAiB,GAAG,WAAW,GAAG;IAC5C,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAC,eAAe,CAAC,EAAE,OAAO,CAAA;KAAC,KAAK,IAAI,CAAA;CACvE,CAAA;AAED,MAAM,MAAM,cAAc,GAAG;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,UAAU,EAAE,CAAA;IACnB,WAAW,EAAE,iBAAiB,GAAG,IAAI,CAAA;CACtC,CAAA;AAED,MAAM,MAAM,YAAY,GAAG;IACzB,MAAM,EAAE,oBAAoB,CAAA;IAC5B,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB,CAAA;AAED,MAAM,MAAM,YAAY,GAAG;IACzB,WAAW,EAAE,iBAAiB,GAAG,IAAI,CAAA;IACrC,cAAc,EAAE,WAAW,CAAA;IAC3B,WAAW,EAAE,WAAW,CAAA;CACzB,CAAA"}
|
package/build/types.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"","sourcesContent":["export type TapperFacetConfig = {\n match: RegExp\n}\n\nexport type TapperFacetConfigMap = Record<string, TapperFacetConfig>\n\nexport type TapperNode
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"","sourcesContent":["export type TapperFacetConfig = {\n match: RegExp\n}\n\nexport type TapperFacetConfigMap = Record<string, TapperFacetConfig>\n\nexport type TapperNode =\n | {\n type: 'text'\n raw: string\n value: string\n start: number\n end: number\n committed?: boolean\n facetType?: undefined\n }\n | {\n type: 'trigger'\n raw: string\n value: string\n start: number\n end: number\n committed?: boolean\n facetType: string\n }\n | {\n type: 'facet'\n raw: string\n value: string\n start: number\n end: number\n committed?: boolean\n facetType: string\n }\n\nexport type TapperFacet = {\n type: string\n value: string\n range: {start: number; end: number}\n}\n\nexport type TapperActiveFacet = TapperFacet & {\n insert: (value: string, options?: {noTrailingSpace?: boolean}) => void\n}\n\nexport type TapperSnapshot = {\n text: string\n cursor: number\n nodes: TapperNode[]\n activeFacet: TapperActiveFacet | null\n}\n\nexport type TapperConfig = {\n facets: TapperFacetConfigMap\n initialText?: string\n}\n\nexport type TapperEvents = {\n activeFacet: TapperActiveFacet | null\n facetCommitted: TapperFacet\n afterInsert: TapperFacet\n}\n"]}
|
package/build/util.d.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { TapperFacetConfigMap, TapperNode, TapperFacet } from './types';
|
|
2
|
-
export
|
|
2
|
+
export type CompiledFacetRegexes = Map<string, RegExp>;
|
|
3
|
+
/**
|
|
4
|
+
* Pre-compile facet regexes once at init time. This avoids re-creating
|
|
5
|
+
* RegExp objects on every keystroke in parseNodesFromText. Each Tapper
|
|
6
|
+
* instance gets its own compiled copy so lastIndex state can't leak
|
|
7
|
+
* between instances.
|
|
8
|
+
*/
|
|
9
|
+
export declare function compileFacetRegexes(config: TapperFacetConfigMap): CompiledFacetRegexes;
|
|
10
|
+
export declare function parseNodesFromText(text: string, regexes: CompiledFacetRegexes, prevNodes?: TapperNode[], cursor?: number, triggers?: Map<string, string>): TapperNode[];
|
|
3
11
|
export declare function deriveTriggers(config: TapperFacetConfigMap): Map<string, string>;
|
|
12
|
+
export declare function nodeToFacet(node: TapperNode): TapperFacet;
|
|
4
13
|
export declare function detectActiveFacet(nodes: TapperNode[], text: string, cursor: number, triggers: Map<string, string>): TapperFacet | null;
|
|
5
14
|
//# sourceMappingURL=util.d.ts.map
|
package/build/util.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"util.d.ts","sourceRoot":"","sources":["../src/util.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"util.d.ts","sourceRoot":"","sources":["../src/util.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,oBAAoB,EAAE,UAAU,EAAE,WAAW,EAAC,MAAM,SAAS,CAAA;AAIrE,MAAM,MAAM,oBAAoB,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;AAEtD;;;;;GAKG;AACH,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,oBAAoB,GAC3B,oBAAoB,CAMtB;AAED,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,oBAAoB,EAC7B,SAAS,CAAC,EAAE,UAAU,EAAE,EACxB,MAAM,CAAC,EAAE,MAAM,EACf,QAAQ,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAC7B,UAAU,EAAE,CAqJd;AAED,wBAAgB,cAAc,CAC5B,MAAM,EAAE,oBAAoB,GAC3B,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAOrB;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,UAAU,GAAG,WAAW,CAMzD;AAED,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,UAAU,EAAE,EACnB,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAC5B,WAAW,GAAG,IAAI,CAkCpB"}
|