@bsky.app/tapper 0.1.1 → 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/src/index.ts CHANGED
@@ -1,20 +1,261 @@
1
- import {useTapperCore} from './core'
2
- import {type TapperConfig} from './types'
1
+ import {useRef, useState, useSyncExternalStore} from 'react'
2
+
3
+ import {
4
+ TapperConfig,
5
+ TapperEvents,
6
+ TapperNode,
7
+ TapperActiveFacet,
8
+ TapperSnapshot,
9
+ } from './types'
10
+ import {
11
+ parseNodesFromText,
12
+ detectActiveFacet,
13
+ deriveTriggers,
14
+ nodeToFacet,
15
+ compileFacetRegexes,
16
+ type CompiledFacetRegexes,
17
+ } from './util'
3
18
 
4
19
  export * from './types'
5
- export * from './core'
20
+
21
+ export class Tapper {
22
+ private facetRegexes: CompiledFacetRegexes
23
+ private triggers: Map<string, string>
24
+
25
+ // Event emitters
26
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
27
+ private listeners = new Map<keyof TapperEvents, Set<(data: any) => void>>()
28
+
29
+ // Public state
30
+ text = ''
31
+ cursor = 0
32
+ nodes: TapperNode[] = []
33
+ activeFacet: TapperActiveFacet | null = null
34
+
35
+ // Internal tracking
36
+ private updating = false
37
+ private pendingCursor: number | null = null
38
+
39
+ // useSyncExternalStore plumbing
40
+ private storeListeners = new Set<() => void>()
41
+ private snapshot: TapperSnapshot
42
+
43
+ constructor(config: TapperConfig) {
44
+ this.facetRegexes = compileFacetRegexes(config.facets)
45
+ this.triggers = deriveTriggers(config.facets)
46
+ this.snapshot = {
47
+ text: this.text,
48
+ cursor: this.cursor,
49
+ nodes: this.nodes,
50
+ activeFacet: this.activeFacet,
51
+ }
52
+ if (config.initialText) {
53
+ this.replaceText(config.initialText)
54
+ }
55
+ }
56
+
57
+ subscribe = (listener: () => void) => {
58
+ this.storeListeners.add(listener)
59
+ return () => this.storeListeners.delete(listener)
60
+ }
61
+
62
+ getSnapshot = (): TapperSnapshot => {
63
+ return this.snapshot
64
+ }
65
+
66
+ on = <K extends keyof TapperEvents>(
67
+ event: K,
68
+ cb: (data: TapperEvents[K]) => void,
69
+ ) => {
70
+ if (!this.listeners.has(event)) this.listeners.set(event, new Set())
71
+ this.listeners.get(event)!.add(cb)
72
+ return () => {
73
+ this.listeners.get(event)?.delete(cb)
74
+ }
75
+ }
76
+
77
+ private emit<K extends keyof TapperEvents>(event: K, data: TapperEvents[K]) {
78
+ this.listeners.get(event)?.forEach(cb => cb(data))
79
+ }
80
+
81
+ private notify() {
82
+ this.snapshot = {
83
+ text: this.text,
84
+ cursor: this.cursor,
85
+ nodes: this.nodes,
86
+ activeFacet: this.activeFacet,
87
+ }
88
+ this.storeListeners.forEach(l => l())
89
+ }
90
+
91
+ private update(newText: string, cursor: number) {
92
+ // guard against circular updates when commit() is called during an update cycle
93
+ if (this.updating) return
94
+ this.updating = true
95
+
96
+ const nodes =
97
+ newText === this.text
98
+ ? this.nodes
99
+ : parseNodesFromText(newText, this.facetRegexes, this.nodes)
100
+ const prev = this.activeFacet
101
+ const detected = detectActiveFacet(nodes, newText, cursor, this.triggers)
102
+
103
+ // When cursor leaves a facet, commit it
104
+ let committedNode: TapperNode | null = null
105
+ if (prev && (!detected || detected.type !== prev.type)) {
106
+ for (const node of nodes) {
107
+ if (
108
+ node.type === prev.type &&
109
+ node.value === prev.value &&
110
+ node.start === prev.range.start &&
111
+ node.end === prev.range.end
112
+ ) {
113
+ node.committed = true
114
+ committedNode = node
115
+ break
116
+ }
117
+ }
118
+ }
119
+
120
+ // Build activeFacet with commit() baked in
121
+ const active: TapperActiveFacet | null = detected
122
+ ? {...detected, commit: this.commit}
123
+ : null
124
+
125
+ this.text = newText
126
+ this.cursor = cursor
127
+ this.nodes = nodes
128
+ this.activeFacet = active
129
+
130
+ this.updating = false
131
+ this.notify()
132
+
133
+ // Fire events after state is finalized
134
+ const facetChanged =
135
+ active?.type !== prev?.type ||
136
+ active?.value !== prev?.value ||
137
+ active?.range.start !== prev?.range.start ||
138
+ active?.range.end !== prev?.range.end
139
+ if (facetChanged) {
140
+ this.emit('activeFacet', active)
141
+ }
142
+ if (committedNode) {
143
+ this.emit('facetCommitted', nodeToFacet(committedNode))
144
+ }
145
+ }
146
+
147
+ handleTextChange = (newText: string) => {
148
+ const diff = newText.length - this.text.length
149
+ let newCursor = Math.max(this.cursor + diff, 0)
150
+
151
+ // Atomic deletion: backspace into a facet from outside
152
+ if (diff === -1 && newCursor < this.cursor) {
153
+ for (const node of this.nodes) {
154
+ if (
155
+ node.type !== 'text' &&
156
+ node.committed &&
157
+ this.cursor === node.end
158
+ ) {
159
+ const remnant = node.end - node.start - 1
160
+ newText =
161
+ newText.slice(0, node.start) + newText.slice(node.start + remnant)
162
+ newCursor = node.start
163
+ this.pendingCursor = newCursor
164
+ break
165
+ }
166
+ }
167
+ }
168
+
169
+ this.update(newText, newCursor)
170
+ }
171
+
172
+ handleSelectionChange = (e: {nativeEvent: {selection: {start: number}}}) => {
173
+ const newCursor = e.nativeEvent.selection.start
174
+ // After atomic deletion, ignore stale cursor values from the DOM
175
+ if (this.pendingCursor !== null) {
176
+ if (newCursor !== this.pendingCursor) {
177
+ return
178
+ }
179
+ this.pendingCursor = null
180
+ }
181
+
182
+ if (newCursor === this.cursor) return
183
+
184
+ this.cursor = newCursor
185
+ this.update(this.text, newCursor)
186
+ }
187
+
188
+ replaceText = (text: string, cursor?: number) => {
189
+ const prevActive = this.activeFacet
190
+ const nodes = parseNodesFromText(text, this.facetRegexes)
191
+ // Mark all non-text nodes as committed (pre-existing facets)
192
+ const newlyCommitted: TapperNode[] = []
193
+ for (const node of nodes) {
194
+ if (node.type !== 'text') {
195
+ node.committed = true
196
+ newlyCommitted.push(node)
197
+ }
198
+ }
199
+
200
+ const pos = cursor ?? text.length
201
+ const detected = detectActiveFacet(nodes, text, pos, this.triggers)
202
+ const active: TapperActiveFacet | null = detected
203
+ ? {...detected, commit: this.commit}
204
+ : null
205
+
206
+ this.text = text
207
+ this.cursor = pos
208
+ this.nodes = nodes
209
+ this.activeFacet = active
210
+
211
+ this.notify()
212
+
213
+ if (active !== prevActive) {
214
+ this.emit('activeFacet', active)
215
+ }
216
+ for (const node of newlyCommitted) {
217
+ this.emit('facetCommitted', nodeToFacet(node))
218
+ }
219
+ }
220
+
221
+ private commit = (value: string, options?: {noTrailingSpace?: boolean}) => {
222
+ if (!this.activeFacet) return
223
+
224
+ const replacement = value + (options?.noTrailingSpace ? '' : ' ')
225
+ const newText =
226
+ this.text.slice(0, this.activeFacet.range.start) +
227
+ replacement +
228
+ this.text.slice(this.activeFacet.range.end)
229
+ const newCursor = this.activeFacet.range.start + replacement.length
230
+
231
+ if (this.activeFacet) {
232
+ this.activeFacet.value = value
233
+ this.activeFacet.range = {
234
+ start: this.activeFacet.range.start,
235
+ end: this.activeFacet.range.start + value.length,
236
+ }
237
+ this.emit('afterInsert', this.activeFacet)
238
+ }
239
+
240
+ this.update(newText, newCursor)
241
+ }
242
+ }
6
243
 
7
244
  export function useTapper(config: TapperConfig) {
8
- const tapper = useTapperCore(config)
245
+ const [store] = useState(() => new Tapper(config))
246
+ const state = useSyncExternalStore(store.subscribe, store.getSnapshot)
247
+ const inputRef = useRef<{focus(): void}>(null)
9
248
 
10
249
  return {
11
- ...tapper,
250
+ ...state,
251
+ on: store.on,
252
+ focus: () => inputRef.current?.focus(),
12
253
  inputProps: {
13
- value: tapper.text,
14
- selection: {start: tapper.cursor, end: tapper.cursor},
15
- onChangeText: tapper.store.handleTextChange,
16
- onSelectionChange: (e: {nativeEvent: {selection: {start: number}}}) =>
17
- tapper.store.handleSelectionChange(e.nativeEvent.selection.start),
254
+ ref: inputRef as React.RefObject<any>,
255
+ value: state.text,
256
+ selection: {start: state.cursor, end: state.cursor},
257
+ onChangeText: store.handleTextChange,
258
+ onSelectionChange: store.handleSelectionChange,
18
259
  },
19
260
  }
20
261
  }
package/src/types.ts CHANGED
@@ -13,13 +13,13 @@ export type TapperNode = {
13
13
  }
14
14
 
15
15
  export type TapperFacet = {
16
- facetType: string
17
- query: string
16
+ type: string
17
+ value: string
18
18
  range: {start: number; end: number}
19
19
  }
20
20
 
21
21
  export type TapperActiveFacet = TapperFacet & {
22
- insert: (value: string, options?: {noTrailingSpace?: boolean}) => void
22
+ commit: (value: string, options?: {noTrailingSpace?: boolean}) => void
23
23
  }
24
24
 
25
25
  export type TapperSnapshot = {
@@ -33,3 +33,9 @@ export type TapperConfig = {
33
33
  facets: TapperFacetConfigMap
34
34
  initialText?: string
35
35
  }
36
+
37
+ export type TapperEvents = {
38
+ activeFacet: TapperActiveFacet | null
39
+ facetCommitted: TapperFacet
40
+ afterInsert: TapperFacet
41
+ }
package/src/util.ts CHANGED
@@ -1,12 +1,28 @@
1
- import {
2
- TapperFacetConfigMap,
3
- TapperNode,
4
- TapperFacet,
5
- } from './types'
1
+ import {TapperFacetConfigMap, TapperNode, TapperFacet} from './types'
2
+
3
+ const WHITESPACE = /\s/
4
+
5
+ export type CompiledFacetRegexes = Map<string, RegExp>
6
+
7
+ /**
8
+ * Pre-compile facet regexes once at init time. This avoids re-creating
9
+ * RegExp objects on every keystroke in parseNodesFromText. Each Tapper
10
+ * instance gets its own compiled copy so lastIndex state can't leak
11
+ * between instances.
12
+ */
13
+ export function compileFacetRegexes(
14
+ config: TapperFacetConfigMap,
15
+ ): CompiledFacetRegexes {
16
+ const compiled = new Map<string, RegExp>()
17
+ for (const [name, def] of Object.entries(config)) {
18
+ compiled.set(name, new RegExp(def.match.source, def.match.flags))
19
+ }
20
+ return compiled
21
+ }
6
22
 
7
23
  export function parseNodesFromText(
8
24
  text: string,
9
- config: TapperFacetConfigMap,
25
+ regexes: CompiledFacetRegexes,
10
26
  prevNodes?: TapperNode[],
11
27
  ): TapperNode[] {
12
28
  const allMatches: {
@@ -16,8 +32,10 @@ export function parseNodesFromText(
16
32
  index: number
17
33
  }[] = []
18
34
 
19
- for (const [name, def] of Object.entries(config)) {
20
- const re = new RegExp(def.match.source, def.match.flags)
35
+ for (const [name, re] of regexes) {
36
+ // Reset lastIndex so stateful (global) regexes don't carry over
37
+ // match positions from the previous parse call.
38
+ re.lastIndex = 0
21
39
  for (const m of text.matchAll(re)) {
22
40
  allMatches.push({
23
41
  facetName: name,
@@ -99,7 +117,9 @@ export function parseNodesFromText(
99
117
  return nodes
100
118
  }
101
119
 
102
- export function deriveTriggers(config: TapperFacetConfigMap): Map<string, string> {
120
+ export function deriveTriggers(
121
+ config: TapperFacetConfigMap,
122
+ ): Map<string, string> {
103
123
  const triggers = new Map<string, string>()
104
124
  for (const [name, def] of Object.entries(config)) {
105
125
  const m = def.match.source.match(/^[^\\([\]{}.*+?^$|]+/)
@@ -108,6 +128,14 @@ export function deriveTriggers(config: TapperFacetConfigMap): Map<string, string
108
128
  return triggers
109
129
  }
110
130
 
131
+ export function nodeToFacet(node: TapperNode): TapperFacet {
132
+ return {
133
+ type: node.type,
134
+ value: node.value,
135
+ range: {start: node.start, end: node.end},
136
+ }
137
+ }
138
+
111
139
  export function detectActiveFacet(
112
140
  nodes: TapperNode[],
113
141
  text: string,
@@ -117,8 +145,8 @@ export function detectActiveFacet(
117
145
  for (const node of nodes) {
118
146
  if (node.type !== 'text' && node.start < cursor && cursor <= node.end) {
119
147
  return {
120
- facetType: node.type,
121
- query: node.value,
148
+ type: node.type,
149
+ value: node.value,
122
150
  range: {start: node.start, end: node.end},
123
151
  }
124
152
  }
@@ -128,12 +156,12 @@ export function detectActiveFacet(
128
156
  for (let i = cursor - 1; i >= 0; i--) {
129
157
  const ch = text[i]
130
158
  // Stop at whitespace — triggers don't span across words
131
- if (/\s/.test(ch)) break
132
- const facetType = triggers.get(ch)
133
- if (facetType) {
159
+ if (WHITESPACE.test(ch)) break
160
+ const type = triggers.get(ch)
161
+ if (type) {
134
162
  return {
135
- facetType,
136
- query: text.slice(i + 1, cursor),
163
+ type,
164
+ value: text.slice(i + 1, cursor),
137
165
  range: {start: i, end: cursor},
138
166
  }
139
167
  }
package/build/core.d.ts DELETED
@@ -1,30 +0,0 @@
1
- import { TapperConfig, TapperNode, TapperActiveFacet, TapperSnapshot } from './types';
2
- export declare class Tapper {
3
- private facetConfig;
4
- private triggers;
5
- text: string;
6
- cursor: number;
7
- nodes: TapperNode[];
8
- activeFacet: TapperActiveFacet | null;
9
- private updating;
10
- private pendingCursor;
11
- private storeListeners;
12
- private snapshot;
13
- constructor(config: TapperConfig);
14
- subscribe: (listener: () => void) => () => boolean;
15
- getSnapshot: () => TapperSnapshot;
16
- private notify;
17
- private update;
18
- handleTextChange: (newText: string) => void;
19
- handleSelectionChange: (newCursor: number) => void;
20
- replaceText: (text: string, cursor?: number) => void;
21
- private insert;
22
- }
23
- export declare function useTapperCore(config: TapperConfig): {
24
- store: Tapper;
25
- text: string;
26
- cursor: number;
27
- nodes: TapperNode[];
28
- activeFacet: TapperActiveFacet | null;
29
- };
30
- //# sourceMappingURL=core.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"core.d.ts","sourceRoot":"","sources":["../src/core.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,YAAY,EAEZ,UAAU,EACV,iBAAiB,EACjB,cAAc,EACf,MAAM,SAAS,CAAA;AAIhB,qBAAa,MAAM;IACjB,OAAO,CAAC,WAAW,CAAsB;IACzC,OAAO,CAAC,QAAQ,CAAqB;IAGrC,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,OAAO,CAAC,MAAM;IAUd,OAAO,CAAC,MAAM;IAiCd,gBAAgB,GAAI,SAAS,MAAM,UAuBlC;IAED,qBAAqB,GAAI,WAAW,MAAM,UAWzC;IAED,WAAW,GAAI,MAAM,MAAM,EAAE,SAAS,MAAM,UAqB3C;IAED,OAAO,CAAC,MAAM,CAoBb;CACF;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,YAAY;;;;;;EAIjD"}
package/build/core.js DELETED
@@ -1,148 +0,0 @@
1
- import { useMemo, useSyncExternalStore } from 'react';
2
- import { parseNodesFromText, detectActiveFacet, deriveTriggers } from './util';
3
- export class Tapper {
4
- facetConfig;
5
- triggers;
6
- // Public state
7
- text = '';
8
- cursor = 0;
9
- nodes = [];
10
- activeFacet = null;
11
- // Internal tracking
12
- updating = false;
13
- pendingCursor = null;
14
- // useSyncExternalStore plumbing
15
- storeListeners = new Set();
16
- snapshot;
17
- constructor(config) {
18
- this.facetConfig = config.facets;
19
- this.triggers = deriveTriggers(config.facets);
20
- this.snapshot = {
21
- text: this.text,
22
- cursor: this.cursor,
23
- nodes: this.nodes,
24
- activeFacet: this.activeFacet,
25
- };
26
- if (config.initialText) {
27
- this.replaceText(config.initialText);
28
- }
29
- }
30
- subscribe = (listener) => {
31
- this.storeListeners.add(listener);
32
- return () => this.storeListeners.delete(listener);
33
- };
34
- getSnapshot = () => {
35
- return this.snapshot;
36
- };
37
- notify() {
38
- this.snapshot = {
39
- text: this.text,
40
- cursor: this.cursor,
41
- nodes: this.nodes,
42
- activeFacet: this.activeFacet,
43
- };
44
- this.storeListeners.forEach(l => l());
45
- }
46
- update(newText, cursor) {
47
- // guard against circular updates when insert is called during an update cycle
48
- if (this.updating)
49
- return;
50
- this.updating = true;
51
- const nodes = parseNodesFromText(newText, this.facetConfig, this.nodes);
52
- const prev = this.activeFacet;
53
- const detected = detectActiveFacet(nodes, newText, cursor, this.triggers);
54
- // When cursor leaves a facet, commit it
55
- if (prev && (!detected || detected.facetType !== prev.facetType)) {
56
- for (const node of nodes) {
57
- if (node.type === prev.facetType && node.value === prev.query) {
58
- node.committed = true;
59
- break;
60
- }
61
- }
62
- }
63
- // Build activeFacet with insert baked in
64
- const active = detected
65
- ? { ...detected, insert: this.insert }
66
- : null;
67
- this.text = newText;
68
- this.cursor = cursor;
69
- this.nodes = nodes;
70
- this.activeFacet = active;
71
- this.updating = false;
72
- this.notify();
73
- }
74
- handleTextChange = (newText) => {
75
- const diff = newText.length - this.text.length;
76
- let newCursor = Math.max(this.cursor + diff, 0);
77
- // Atomic deletion: backspace into a facet from outside
78
- if (diff === -1 && newCursor < this.cursor) {
79
- for (const node of this.nodes) {
80
- if (node.type !== 'text' &&
81
- node.committed &&
82
- this.cursor === node.end) {
83
- const remnant = node.end - node.start - 1;
84
- newText =
85
- newText.slice(0, node.start) + newText.slice(node.start + remnant);
86
- newCursor = node.start;
87
- this.pendingCursor = newCursor;
88
- break;
89
- }
90
- }
91
- }
92
- this.update(newText, newCursor);
93
- };
94
- handleSelectionChange = (newCursor) => {
95
- // After atomic deletion, ignore stale cursor values from the DOM
96
- if (this.pendingCursor !== null) {
97
- if (newCursor !== this.pendingCursor) {
98
- return;
99
- }
100
- this.pendingCursor = null;
101
- }
102
- this.cursor = newCursor;
103
- this.update(this.text, newCursor);
104
- };
105
- replaceText = (text, cursor) => {
106
- const nodes = parseNodesFromText(text, this.facetConfig);
107
- // Mark all non-text nodes as committed (pre-existing facets)
108
- for (const node of nodes) {
109
- if (node.type !== 'text') {
110
- node.committed = true;
111
- }
112
- }
113
- const pos = cursor ?? text.length;
114
- const detected = detectActiveFacet(nodes, text, pos, this.triggers);
115
- const active = detected
116
- ? { ...detected, insert: this.insert }
117
- : null;
118
- this.text = text;
119
- this.cursor = pos;
120
- this.nodes = nodes;
121
- this.activeFacet = active;
122
- this.notify();
123
- };
124
- insert = (value, options) => {
125
- const active = this.activeFacet;
126
- if (!active)
127
- return;
128
- const replacement = value + (options?.noTrailingSpace ? '' : ' ');
129
- const newText = this.text.slice(0, active.range.start) +
130
- replacement +
131
- this.text.slice(active.range.end);
132
- const newCursor = active.range.start + replacement.length;
133
- this.update(newText, newCursor);
134
- // Mark the inserted facet as committed
135
- for (const node of this.nodes) {
136
- if (node.type !== 'text' && node.start === active.range.start) {
137
- node.committed = true;
138
- break;
139
- }
140
- }
141
- };
142
- }
143
- export function useTapperCore(config) {
144
- const store = useMemo(() => new Tapper(config), [config]);
145
- const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot);
146
- return { ...snapshot, store };
147
- }
148
- //# sourceMappingURL=core.js.map
package/build/core.js.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"file":"core.js","sourceRoot":"","sources":["../src/core.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,OAAO,EAAE,oBAAoB,EAAC,MAAM,OAAO,CAAA;AAUnD,OAAO,EAAC,kBAAkB,EAAE,iBAAiB,EAAE,cAAc,EAAC,MAAM,QAAQ,CAAA;AAE5E,MAAM,OAAO,MAAM;IACT,WAAW,CAAsB;IACjC,QAAQ,CAAqB;IAErC,eAAe;IACf,IAAI,GAAG,EAAE,CAAA;IACT,MAAM,GAAG,CAAC,CAAA;IACV,KAAK,GAAiB,EAAE,CAAA;IACxB,WAAW,GAA6B,IAAI,CAAA;IAE5C,oBAAoB;IACZ,QAAQ,GAAG,KAAK,CAAA;IAChB,aAAa,GAAkB,IAAI,CAAA;IAE3C,gCAAgC;IACxB,cAAc,GAAG,IAAI,GAAG,EAAc,CAAA;IACtC,QAAQ,CAAgB;IAEhC,YAAY,MAAoB;QAC9B,IAAI,CAAC,WAAW,GAAG,MAAM,CAAC,MAAM,CAAA;QAChC,IAAI,CAAC,QAAQ,GAAG,cAAc,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;QAC7C,IAAI,CAAC,QAAQ,GAAG;YACd,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,MAAM,EAAE,IAAI,CAAC,MAAM;YACnB,KAAK,EAAE,IAAI,CAAC,KAAK;YACjB,WAAW,EAAE,IAAI,CAAC,WAAW;SAC9B,CAAA;QACD,IAAI,MAAM,CAAC,WAAW,EAAE,CAAC;YACvB,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC,WAAW,CAAC,CAAA;QACtC,CAAC;IACH,CAAC;IAED,SAAS,GAAG,CAAC,QAAoB,EAAE,EAAE;QACnC,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;QACjC,OAAO,GAAG,EAAE,CAAC,IAAI,CAAC,cAAc,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAA;IACnD,CAAC,CAAA;IAED,WAAW,GAAG,GAAmB,EAAE;QACjC,OAAO,IAAI,CAAC,QAAQ,CAAA;IACtB,CAAC,CAAA;IAEO,MAAM;QACZ,IAAI,CAAC,QAAQ,GAAG;YACd,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,MAAM,EAAE,IAAI,CAAC,MAAM;YACnB,KAAK,EAAE,IAAI,CAAC,KAAK;YACjB,WAAW,EAAE,IAAI,CAAC,WAAW;SAC9B,CAAA;QACD,IAAI,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,CAAA;IACvC,CAAC;IAEO,MAAM,CAAC,OAAe,EAAE,MAAc;QAC5C,8EAA8E;QAC9E,IAAI,IAAI,CAAC,QAAQ;YAAE,OAAM;QACzB,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAA;QAEpB,MAAM,KAAK,GAAG,kBAAkB,CAAC,OAAO,EAAE,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC,KAAK,CAAC,CAAA;QACvE,MAAM,IAAI,GAAG,IAAI,CAAC,WAAW,CAAA;QAC7B,MAAM,QAAQ,GAAG,iBAAiB,CAAC,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAA;QAEzE,wCAAwC;QACxC,IAAI,IAAI,IAAI,CAAC,CAAC,QAAQ,IAAI,QAAQ,CAAC,SAAS,KAAK,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC;YACjE,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;gBACzB,IAAI,IAAI,CAAC,IAAI,KAAK,IAAI,CAAC,SAAS,IAAI,IAAI,CAAC,KAAK,KAAK,IAAI,CAAC,KAAK,EAAE,CAAC;oBAC9D,IAAI,CAAC,SAAS,GAAG,IAAI,CAAA;oBACrB,MAAK;gBACP,CAAC;YACH,CAAC;QACH,CAAC;QAED,yCAAyC;QACzC,MAAM,MAAM,GAA6B,QAAQ;YAC/C,CAAC,CAAC,EAAC,GAAG,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAC;YACpC,CAAC,CAAC,IAAI,CAAA;QAER,IAAI,CAAC,IAAI,GAAG,OAAO,CAAA;QACnB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAA;QACpB,IAAI,CAAC,KAAK,GAAG,KAAK,CAAA;QAClB,IAAI,CAAC,WAAW,GAAG,MAAM,CAAA;QAEzB,IAAI,CAAC,QAAQ,GAAG,KAAK,CAAA;QACrB,IAAI,CAAC,MAAM,EAAE,CAAA;IACf,CAAC;IAED,gBAAgB,GAAG,CAAC,OAAe,EAAE,EAAE;QACrC,MAAM,IAAI,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,CAAA;QAC9C,IAAI,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,GAAG,IAAI,EAAE,CAAC,CAAC,CAAA;QAE/C,uDAAuD;QACvD,IAAI,IAAI,KAAK,CAAC,CAAC,IAAI,SAAS,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC;YAC3C,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC;gBAC9B,IACE,IAAI,CAAC,IAAI,KAAK,MAAM;oBACpB,IAAI,CAAC,SAAS;oBACd,IAAI,CAAC,MAAM,KAAK,IAAI,CAAC,GAAG,EACxB,CAAC;oBACD,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,GAAG,IAAI,CAAC,KAAK,GAAG,CAAC,CAAA;oBACzC,OAAO;wBACL,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,GAAG,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,GAAG,OAAO,CAAC,CAAA;oBACpE,SAAS,GAAG,IAAI,CAAC,KAAK,CAAA;oBACtB,IAAI,CAAC,aAAa,GAAG,SAAS,CAAA;oBAC9B,MAAK;gBACP,CAAC;YACH,CAAC;QACH,CAAC;QAED,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,SAAS,CAAC,CAAA;IACjC,CAAC,CAAA;IAED,qBAAqB,GAAG,CAAC,SAAiB,EAAE,EAAE;QAC5C,iEAAiE;QACjE,IAAI,IAAI,CAAC,aAAa,KAAK,IAAI,EAAE,CAAC;YAChC,IAAI,SAAS,KAAK,IAAI,CAAC,aAAa,EAAE,CAAC;gBACrC,OAAM;YACR,CAAC;YACD,IAAI,CAAC,aAAa,GAAG,IAAI,CAAA;QAC3B,CAAC;QAED,IAAI,CAAC,MAAM,GAAG,SAAS,CAAA;QACvB,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,SAAS,CAAC,CAAA;IACnC,CAAC,CAAA;IAED,WAAW,GAAG,CAAC,IAAY,EAAE,MAAe,EAAE,EAAE;QAC9C,MAAM,KAAK,GAAG,kBAAkB,CAAC,IAAI,EAAE,IAAI,CAAC,WAAW,CAAC,CAAA;QACxD,6DAA6D;QAC7D,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACzB,IAAI,IAAI,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;gBACzB,IAAI,CAAC,SAAS,GAAG,IAAI,CAAA;YACvB,CAAC;QACH,CAAC;QAED,MAAM,GAAG,GAAG,MAAM,IAAI,IAAI,CAAC,MAAM,CAAA;QACjC,MAAM,QAAQ,GAAG,iBAAiB,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAA;QACnE,MAAM,MAAM,GAA6B,QAAQ;YAC/C,CAAC,CAAC,EAAC,GAAG,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAC;YACpC,CAAC,CAAC,IAAI,CAAA;QAER,IAAI,CAAC,IAAI,GAAG,IAAI,CAAA;QAChB,IAAI,CAAC,MAAM,GAAG,GAAG,CAAA;QACjB,IAAI,CAAC,KAAK,GAAG,KAAK,CAAA;QAClB,IAAI,CAAC,WAAW,GAAG,MAAM,CAAA;QAEzB,IAAI,CAAC,MAAM,EAAE,CAAA;IACf,CAAC,CAAA;IAEO,MAAM,GAAG,CAAC,KAAa,EAAE,OAAqC,EAAE,EAAE;QACxE,MAAM,MAAM,GAAG,IAAI,CAAC,WAAW,CAAA;QAC/B,IAAI,CAAC,MAAM;YAAE,OAAM;QAEnB,MAAM,WAAW,GAAG,KAAK,GAAG,CAAC,OAAO,EAAE,eAAe,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAA;QACjE,MAAM,OAAO,GACX,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC;YACtC,WAAW;YACX,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;QACnC,MAAM,SAAS,GAAG,MAAM,CAAC,KAAK,CAAC,KAAK,GAAG,WAAW,CAAC,MAAM,CAAA;QAEzD,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,SAAS,CAAC,CAAA;QAE/B,uCAAuC;QACvC,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC;YAC9B,IAAI,IAAI,CAAC,IAAI,KAAK,MAAM,IAAI,IAAI,CAAC,KAAK,KAAK,MAAM,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC;gBAC9D,IAAI,CAAC,SAAS,GAAG,IAAI,CAAA;gBACrB,MAAK;YACP,CAAC;QACH,CAAC;IACH,CAAC,CAAA;CACF;AAED,MAAM,UAAU,aAAa,CAAC,MAAoB;IAChD,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC,IAAI,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,CAAA;IACzD,MAAM,QAAQ,GAAG,oBAAoB,CAAC,KAAK,CAAC,SAAS,EAAE,KAAK,CAAC,WAAW,CAAC,CAAA;IACzE,OAAO,EAAC,GAAG,QAAQ,EAAE,KAAK,EAAC,CAAA;AAC7B,CAAC","sourcesContent":["import {useMemo, useSyncExternalStore} from 'react'\n\nimport {\n TapperConfig,\n TapperFacetConfigMap,\n TapperNode,\n TapperActiveFacet,\n TapperSnapshot,\n} from './types'\n\nimport {parseNodesFromText, detectActiveFacet, deriveTriggers} from './util'\n\nexport class Tapper {\n private facetConfig: TapperFacetConfigMap\n private triggers: Map<string, string>\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.facetConfig = 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 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 insert is called during an update cycle\n if (this.updating) return\n this.updating = true\n\n const nodes = parseNodesFromText(newText, this.facetConfig, this.nodes)\n const prev = this.activeFacet\n const detected = detectActiveFacet(nodes, newText, cursor, this.triggers)\n\n // When cursor leaves a facet, commit it\n if (prev && (!detected || detected.facetType !== prev.facetType)) {\n for (const node of nodes) {\n if (node.type === prev.facetType && node.value === prev.query) {\n node.committed = true\n break\n }\n }\n }\n\n // Build activeFacet with insert 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\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 !== 'text' &&\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 = (newCursor: number) => {\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 this.cursor = newCursor\n this.update(this.text, newCursor)\n }\n\n replaceText = (text: string, cursor?: number) => {\n const nodes = parseNodesFromText(text, this.facetConfig)\n // Mark all non-text nodes as committed (pre-existing facets)\n for (const node of nodes) {\n if (node.type !== 'text') {\n node.committed = true\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\n private insert = (value: string, options?: {noTrailingSpace?: boolean}) => {\n const active = this.activeFacet\n if (!active) return\n\n const replacement = value + (options?.noTrailingSpace ? '' : ' ')\n const newText =\n this.text.slice(0, active.range.start) +\n replacement +\n this.text.slice(active.range.end)\n const newCursor = active.range.start + replacement.length\n\n this.update(newText, newCursor)\n\n // Mark the inserted facet as committed\n for (const node of this.nodes) {\n if (node.type !== 'text' && node.start === active.range.start) {\n node.committed = true\n break\n }\n }\n }\n}\n\nexport function useTapperCore(config: TapperConfig) {\n const store = useMemo(() => new Tapper(config), [config])\n const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot)\n return {...snapshot, store}\n}\n"]}
@@ -1,17 +0,0 @@
1
- import { type TapperConfig } from './types';
2
- export * from './types';
3
- export * from './core';
4
- export declare function useTapper(config: TapperConfig): {
5
- inputProps: {
6
- ref: import("react").RefObject<HTMLTextAreaElement | null>;
7
- value: string;
8
- onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
9
- onSelect: (e: React.SyntheticEvent<HTMLTextAreaElement>) => void;
10
- };
11
- store: import("./core").Tapper;
12
- text: string;
13
- cursor: number;
14
- nodes: import("./types").TapperNode[];
15
- activeFacet: import("./types").TapperActiveFacet | null;
16
- };
17
- //# sourceMappingURL=index.web.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.web.d.ts","sourceRoot":"","sources":["../src/index.web.ts"],"names":[],"mappings":"AAGA,OAAO,EAAC,KAAK,YAAY,EAAC,MAAM,SAAS,CAAA;AAEzC,cAAc,SAAS,CAAA;AACvB,cAAc,QAAQ,CAAA;AAEtB,wBAAgB,SAAS,CAAC,MAAM,EAAE,YAAY;;;;sBAatC,KAAK,CAAC,WAAW,CAAC,mBAAmB,CAAC;sBAQtC,KAAK,CAAC,cAAc,CAAC,mBAAmB,CAAC;;;;;;;EAgBhD"}
@@ -1,33 +0,0 @@
1
- import { useCallback, useLayoutEffect, useRef } from 'react';
2
- import { useTapperCore } from './core';
3
- export * from './types';
4
- export * from './core';
5
- export function useTapper(config) {
6
- const tapper = useTapperCore(config);
7
- const textareaRef = useRef(null);
8
- // After atomic deletion (or insert), restore cursor position in the DOM
9
- useLayoutEffect(() => {
10
- const ta = textareaRef.current;
11
- if (ta && ta.selectionStart !== tapper.cursor) {
12
- ta.setSelectionRange(tapper.cursor, tapper.cursor);
13
- }
14
- }, [tapper.cursor, tapper.text]);
15
- const onChange = useCallback((e) => {
16
- tapper.store.handleTextChange(e.target.value);
17
- tapper.store.handleSelectionChange(e.target.selectionStart ?? 0);
18
- }, [tapper.store]);
19
- const onSelect = useCallback((e) => {
20
- const target = e.target;
21
- tapper.store.handleSelectionChange(target.selectionStart ?? 0);
22
- }, [tapper.store]);
23
- return {
24
- ...tapper,
25
- inputProps: {
26
- ref: textareaRef,
27
- value: tapper.text,
28
- onChange,
29
- onSelect,
30
- },
31
- };
32
- }
33
- //# sourceMappingURL=index.web.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.web.js","sourceRoot":"","sources":["../src/index.web.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,WAAW,EAAE,eAAe,EAAE,MAAM,EAAC,MAAM,OAAO,CAAA;AAE1D,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;IACpC,MAAM,WAAW,GAAG,MAAM,CAAsB,IAAI,CAAC,CAAA;IAErD,wEAAwE;IACxE,eAAe,CAAC,GAAG,EAAE;QACnB,MAAM,EAAE,GAAG,WAAW,CAAC,OAAO,CAAA;QAC9B,IAAI,EAAE,IAAI,EAAE,CAAC,cAAc,KAAK,MAAM,CAAC,MAAM,EAAE,CAAC;YAC9C,EAAE,CAAC,iBAAiB,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,CAAA;QACpD,CAAC;IACH,CAAC,EAAE,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAA;IAEhC,MAAM,QAAQ,GAAG,WAAW,CAC1B,CAAC,CAAyC,EAAE,EAAE;QAC5C,MAAM,CAAC,KAAK,CAAC,gBAAgB,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;QAC7C,MAAM,CAAC,KAAK,CAAC,qBAAqB,CAAC,CAAC,CAAC,MAAM,CAAC,cAAc,IAAI,CAAC,CAAC,CAAA;IAClE,CAAC,EACD,CAAC,MAAM,CAAC,KAAK,CAAC,CACf,CAAA;IAED,MAAM,QAAQ,GAAG,WAAW,CAC1B,CAAC,CAA4C,EAAE,EAAE;QAC/C,MAAM,MAAM,GAAG,CAAC,CAAC,MAA6B,CAAA;QAC9C,MAAM,CAAC,KAAK,CAAC,qBAAqB,CAAC,MAAM,CAAC,cAAc,IAAI,CAAC,CAAC,CAAA;IAChE,CAAC,EACD,CAAC,MAAM,CAAC,KAAK,CAAC,CACf,CAAA;IAED,OAAO;QACL,GAAG,MAAM;QACT,UAAU,EAAE;YACV,GAAG,EAAE,WAAW;YAChB,KAAK,EAAE,MAAM,CAAC,IAAI;YAClB,QAAQ;YACR,QAAQ;SACT;KACF,CAAA;AACH,CAAC","sourcesContent":["import {useCallback, useLayoutEffect, useRef} from 'react'\n\nimport {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 const textareaRef = useRef<HTMLTextAreaElement>(null)\n\n // After atomic deletion (or insert), restore cursor position in the DOM\n useLayoutEffect(() => {\n const ta = textareaRef.current\n if (ta && ta.selectionStart !== tapper.cursor) {\n ta.setSelectionRange(tapper.cursor, tapper.cursor)\n }\n }, [tapper.cursor, tapper.text])\n\n const onChange = useCallback(\n (e: React.ChangeEvent<HTMLTextAreaElement>) => {\n tapper.store.handleTextChange(e.target.value)\n tapper.store.handleSelectionChange(e.target.selectionStart ?? 0)\n },\n [tapper.store],\n )\n\n const onSelect = useCallback(\n (e: React.SyntheticEvent<HTMLTextAreaElement>) => {\n const target = e.target as HTMLTextAreaElement\n tapper.store.handleSelectionChange(target.selectionStart ?? 0)\n },\n [tapper.store],\n )\n\n return {\n ...tapper,\n inputProps: {\n ref: textareaRef,\n value: tapper.text,\n onChange,\n onSelect,\n },\n }\n}\n"]}