@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/src/util.ts CHANGED
@@ -1,13 +1,31 @@
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[],
27
+ cursor?: number,
28
+ triggers?: Map<string, string>,
11
29
  ): TapperNode[] {
12
30
  const allMatches: {
13
31
  facetName: string
@@ -16,13 +34,15 @@ export function parseNodesFromText(
16
34
  index: number
17
35
  }[] = []
18
36
 
19
- for (const [name, def] of Object.entries(config)) {
20
- const re = new RegExp(def.match.source, def.match.flags)
37
+ for (const [name, re] of regexes) {
38
+ // Reset lastIndex so stateful (global) regexes don't carry over
39
+ // match positions from the previous parse call.
40
+ re.lastIndex = 0
21
41
  for (const m of text.matchAll(re)) {
22
42
  allMatches.push({
23
43
  facetName: name,
24
44
  fullMatch: m[0],
25
- capture: m[0],
45
+ capture: m[1] ?? m[0],
26
46
  index: m.index,
27
47
  })
28
48
  }
@@ -42,55 +62,113 @@ export function parseNodesFromText(
42
62
  }
43
63
 
44
64
  const nodes: TapperNode[] = []
45
- let cursor = 0
65
+ let pos = 0
46
66
 
47
67
  for (const m of accepted) {
48
- if (m.index > cursor) {
68
+ if (m.index > pos) {
69
+ const raw = text.slice(pos, m.index)
49
70
  nodes.push({
50
71
  type: 'text',
51
- value: text.slice(cursor, m.index),
52
- start: cursor,
72
+ raw,
73
+ value: raw,
74
+ start: pos,
53
75
  end: m.index,
54
76
  })
55
77
  }
56
78
  nodes.push({
57
- type: m.facetName,
79
+ type: 'facet',
80
+ facetType: m.facetName,
81
+ raw: m.fullMatch,
58
82
  value: m.capture,
59
83
  start: m.index,
60
84
  end: m.index + m.fullMatch.length,
61
85
  })
62
- cursor = m.index + m.fullMatch.length
86
+ pos = m.index + m.fullMatch.length
63
87
  }
64
88
 
65
- if (cursor < text.length) {
89
+ if (pos < text.length) {
90
+ const raw = text.slice(pos)
66
91
  nodes.push({
67
92
  type: 'text',
68
- value: text.slice(cursor),
69
- start: cursor,
93
+ raw,
94
+ value: raw,
95
+ start: pos,
70
96
  end: text.length,
71
97
  })
72
98
  }
73
99
 
74
- // Transfer committed flags from previous nodes by type + occurrence index
100
+ // If the cursor is right after a trigger char that the regex didn't match,
101
+ // splice a 'trigger' node out of the containing text node.
102
+ if (cursor != null && triggers) {
103
+ for (let i = cursor - 1; i >= 0; i--) {
104
+ const ch = text[i]
105
+ if (WHITESPACE.test(ch)) break
106
+ const facetType = triggers.get(ch)
107
+ if (facetType) {
108
+ // Only create a trigger node if the trigger is inside a text node
109
+ // (i.e. the regex didn't already match it as a facet)
110
+ const textNodeIdx = nodes.findIndex(
111
+ n => n.type === 'text' && n.start <= i && n.end > i,
112
+ )
113
+ if (textNodeIdx !== -1) {
114
+ const node = nodes[textNodeIdx]
115
+ const spliced: TapperNode[] = []
116
+ if (node.start < i) {
117
+ const raw = text.slice(node.start, i)
118
+ spliced.push({
119
+ type: 'text',
120
+ raw,
121
+ value: raw,
122
+ start: node.start,
123
+ end: i,
124
+ })
125
+ }
126
+ const triggerRaw = text.slice(i, cursor)
127
+ spliced.push({
128
+ type: 'trigger',
129
+ facetType,
130
+ raw: triggerRaw,
131
+ value: text.slice(i + ch.length, cursor),
132
+ start: i,
133
+ end: cursor,
134
+ })
135
+ if (cursor < node.end) {
136
+ const raw = text.slice(cursor, node.end)
137
+ spliced.push({
138
+ type: 'text',
139
+ raw,
140
+ value: raw,
141
+ start: cursor,
142
+ end: node.end,
143
+ })
144
+ }
145
+ nodes.splice(textNodeIdx, 1, ...spliced)
146
+ }
147
+ break
148
+ }
149
+ }
150
+ }
151
+
152
+ // Transfer committed flags from previous nodes by facetType + occurrence index
75
153
  if (prevNodes) {
76
154
  const counts = new Map<string, number>()
77
155
  const committedByTypeIndex = new Set<string>()
78
156
 
79
157
  for (const node of prevNodes) {
80
- if (node.type === 'text') continue
81
- const idx = counts.get(node.type) ?? 0
82
- counts.set(node.type, idx + 1)
158
+ if (node.type !== 'facet') continue
159
+ const idx = counts.get(node.facetType!) ?? 0
160
+ counts.set(node.facetType!, idx + 1)
83
161
  if (node.committed) {
84
- committedByTypeIndex.add(`${node.type}:${idx}`)
162
+ committedByTypeIndex.add(`${node.facetType}:${idx}`)
85
163
  }
86
164
  }
87
165
 
88
166
  counts.clear()
89
167
  for (const node of nodes) {
90
- if (node.type === 'text') continue
91
- const idx = counts.get(node.type) ?? 0
92
- counts.set(node.type, idx + 1)
93
- if (committedByTypeIndex.has(`${node.type}:${idx}`)) {
168
+ if (node.type !== 'facet') continue
169
+ const idx = counts.get(node.facetType!) ?? 0
170
+ counts.set(node.facetType!, idx + 1)
171
+ if (committedByTypeIndex.has(`${node.facetType}:${idx}`)) {
94
172
  node.committed = true
95
173
  }
96
174
  }
@@ -99,7 +177,9 @@ export function parseNodesFromText(
99
177
  return nodes
100
178
  }
101
179
 
102
- export function deriveTriggers(config: TapperFacetConfigMap): Map<string, string> {
180
+ export function deriveTriggers(
181
+ config: TapperFacetConfigMap,
182
+ ): Map<string, string> {
103
183
  const triggers = new Map<string, string>()
104
184
  for (const [name, def] of Object.entries(config)) {
105
185
  const m = def.match.source.match(/^[^\\([\]{}.*+?^$|]+/)
@@ -108,6 +188,14 @@ export function deriveTriggers(config: TapperFacetConfigMap): Map<string, string
108
188
  return triggers
109
189
  }
110
190
 
191
+ export function nodeToFacet(node: TapperNode): TapperFacet {
192
+ return {
193
+ type: node.facetType!,
194
+ value: node.value,
195
+ range: {start: node.start, end: node.end},
196
+ }
197
+ }
198
+
111
199
  export function detectActiveFacet(
112
200
  nodes: TapperNode[],
113
201
  text: string,
@@ -115,10 +203,17 @@ export function detectActiveFacet(
115
203
  triggers: Map<string, string>,
116
204
  ): TapperFacet | null {
117
205
  for (const node of nodes) {
118
- if (node.type !== 'text' && node.start < cursor && cursor <= node.end) {
206
+ if (node.type === 'trigger' && node.start < cursor && cursor <= node.end) {
207
+ return {
208
+ type: node.facetType,
209
+ value: node.value,
210
+ range: {start: node.start, end: node.end},
211
+ }
212
+ }
213
+ if (node.type === 'facet' && node.start < cursor && cursor <= node.end) {
119
214
  return {
120
- facetType: node.type,
121
- query: node.value,
215
+ type: node.facetType,
216
+ value: node.value,
122
217
  range: {start: node.start, end: node.end},
123
218
  }
124
219
  }
@@ -128,12 +223,12 @@ export function detectActiveFacet(
128
223
  for (let i = cursor - 1; i >= 0; i--) {
129
224
  const ch = text[i]
130
225
  // Stop at whitespace — triggers don't span across words
131
- if (/\s/.test(ch)) break
132
- const facetType = triggers.get(ch)
133
- if (facetType) {
226
+ if (WHITESPACE.test(ch)) break
227
+ const type = triggers.get(ch)
228
+ if (type) {
134
229
  return {
135
- facetType,
136
- query: text.slice(i + 1, cursor),
230
+ type,
231
+ value: text.slice(i + 1, cursor),
137
232
  range: {start: i, end: cursor},
138
233
  }
139
234
  }
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":"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","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"]}