@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/build/util.js CHANGED
@@ -1,12 +1,28 @@
1
- export function parseNodesFromText(text, config, prevNodes) {
2
- const allMatches = [];
1
+ const WHITESPACE = /\s/;
2
+ /**
3
+ * Pre-compile facet regexes once at init time. This avoids re-creating
4
+ * RegExp objects on every keystroke in parseNodesFromText. Each Tapper
5
+ * instance gets its own compiled copy so lastIndex state can't leak
6
+ * between instances.
7
+ */
8
+ export function compileFacetRegexes(config) {
9
+ const compiled = new Map();
3
10
  for (const [name, def] of Object.entries(config)) {
4
- const re = new RegExp(def.match.source, def.match.flags);
11
+ compiled.set(name, new RegExp(def.match.source, def.match.flags));
12
+ }
13
+ return compiled;
14
+ }
15
+ export function parseNodesFromText(text, regexes, prevNodes, cursor, triggers) {
16
+ const allMatches = [];
17
+ for (const [name, re] of regexes) {
18
+ // Reset lastIndex so stateful (global) regexes don't carry over
19
+ // match positions from the previous parse call.
20
+ re.lastIndex = 0;
5
21
  for (const m of text.matchAll(re)) {
6
22
  allMatches.push({
7
23
  facetName: name,
8
24
  fullMatch: m[0],
9
- capture: m[0],
25
+ capture: m[1] ?? m[0],
10
26
  index: m.index,
11
27
  });
12
28
  }
@@ -21,52 +37,108 @@ export function parseNodesFromText(text, config, prevNodes) {
21
37
  }
22
38
  }
23
39
  const nodes = [];
24
- let cursor = 0;
40
+ let pos = 0;
25
41
  for (const m of accepted) {
26
- if (m.index > cursor) {
42
+ if (m.index > pos) {
43
+ const raw = text.slice(pos, m.index);
27
44
  nodes.push({
28
45
  type: 'text',
29
- value: text.slice(cursor, m.index),
30
- start: cursor,
46
+ raw,
47
+ value: raw,
48
+ start: pos,
31
49
  end: m.index,
32
50
  });
33
51
  }
34
52
  nodes.push({
35
- type: m.facetName,
53
+ type: 'facet',
54
+ facetType: m.facetName,
55
+ raw: m.fullMatch,
36
56
  value: m.capture,
37
57
  start: m.index,
38
58
  end: m.index + m.fullMatch.length,
39
59
  });
40
- cursor = m.index + m.fullMatch.length;
60
+ pos = m.index + m.fullMatch.length;
41
61
  }
42
- if (cursor < text.length) {
62
+ if (pos < text.length) {
63
+ const raw = text.slice(pos);
43
64
  nodes.push({
44
65
  type: 'text',
45
- value: text.slice(cursor),
46
- start: cursor,
66
+ raw,
67
+ value: raw,
68
+ start: pos,
47
69
  end: text.length,
48
70
  });
49
71
  }
50
- // Transfer committed flags from previous nodes by type + occurrence index
72
+ // If the cursor is right after a trigger char that the regex didn't match,
73
+ // splice a 'trigger' node out of the containing text node.
74
+ if (cursor != null && triggers) {
75
+ for (let i = cursor - 1; i >= 0; i--) {
76
+ const ch = text[i];
77
+ if (WHITESPACE.test(ch))
78
+ break;
79
+ const facetType = triggers.get(ch);
80
+ if (facetType) {
81
+ // Only create a trigger node if the trigger is inside a text node
82
+ // (i.e. the regex didn't already match it as a facet)
83
+ const textNodeIdx = nodes.findIndex(n => n.type === 'text' && n.start <= i && n.end > i);
84
+ if (textNodeIdx !== -1) {
85
+ const node = nodes[textNodeIdx];
86
+ const spliced = [];
87
+ if (node.start < i) {
88
+ const raw = text.slice(node.start, i);
89
+ spliced.push({
90
+ type: 'text',
91
+ raw,
92
+ value: raw,
93
+ start: node.start,
94
+ end: i,
95
+ });
96
+ }
97
+ const triggerRaw = text.slice(i, cursor);
98
+ spliced.push({
99
+ type: 'trigger',
100
+ facetType,
101
+ raw: triggerRaw,
102
+ value: text.slice(i + ch.length, cursor),
103
+ start: i,
104
+ end: cursor,
105
+ });
106
+ if (cursor < node.end) {
107
+ const raw = text.slice(cursor, node.end);
108
+ spliced.push({
109
+ type: 'text',
110
+ raw,
111
+ value: raw,
112
+ start: cursor,
113
+ end: node.end,
114
+ });
115
+ }
116
+ nodes.splice(textNodeIdx, 1, ...spliced);
117
+ }
118
+ break;
119
+ }
120
+ }
121
+ }
122
+ // Transfer committed flags from previous nodes by facetType + occurrence index
51
123
  if (prevNodes) {
52
124
  const counts = new Map();
53
125
  const committedByTypeIndex = new Set();
54
126
  for (const node of prevNodes) {
55
- if (node.type === 'text')
127
+ if (node.type !== 'facet')
56
128
  continue;
57
- const idx = counts.get(node.type) ?? 0;
58
- counts.set(node.type, idx + 1);
129
+ const idx = counts.get(node.facetType) ?? 0;
130
+ counts.set(node.facetType, idx + 1);
59
131
  if (node.committed) {
60
- committedByTypeIndex.add(`${node.type}:${idx}`);
132
+ committedByTypeIndex.add(`${node.facetType}:${idx}`);
61
133
  }
62
134
  }
63
135
  counts.clear();
64
136
  for (const node of nodes) {
65
- if (node.type === 'text')
137
+ if (node.type !== 'facet')
66
138
  continue;
67
- const idx = counts.get(node.type) ?? 0;
68
- counts.set(node.type, idx + 1);
69
- if (committedByTypeIndex.has(`${node.type}:${idx}`)) {
139
+ const idx = counts.get(node.facetType) ?? 0;
140
+ counts.set(node.facetType, idx + 1);
141
+ if (committedByTypeIndex.has(`${node.facetType}:${idx}`)) {
70
142
  node.committed = true;
71
143
  }
72
144
  }
@@ -82,12 +154,26 @@ export function deriveTriggers(config) {
82
154
  }
83
155
  return triggers;
84
156
  }
157
+ export function nodeToFacet(node) {
158
+ return {
159
+ type: node.facetType,
160
+ value: node.value,
161
+ range: { start: node.start, end: node.end },
162
+ };
163
+ }
85
164
  export function detectActiveFacet(nodes, text, cursor, triggers) {
86
165
  for (const node of nodes) {
87
- if (node.type !== 'text' && node.start < cursor && cursor <= node.end) {
166
+ if (node.type === 'trigger' && node.start < cursor && cursor <= node.end) {
167
+ return {
168
+ type: node.facetType,
169
+ value: node.value,
170
+ range: { start: node.start, end: node.end },
171
+ };
172
+ }
173
+ if (node.type === 'facet' && node.start < cursor && cursor <= node.end) {
88
174
  return {
89
- facetType: node.type,
90
- query: node.value,
175
+ type: node.facetType,
176
+ value: node.value,
91
177
  range: { start: node.start, end: node.end },
92
178
  };
93
179
  }
@@ -96,13 +182,13 @@ export function detectActiveFacet(nodes, text, cursor, triggers) {
96
182
  for (let i = cursor - 1; i >= 0; i--) {
97
183
  const ch = text[i];
98
184
  // Stop at whitespace — triggers don't span across words
99
- if (/\s/.test(ch))
185
+ if (WHITESPACE.test(ch))
100
186
  break;
101
- const facetType = triggers.get(ch);
102
- if (facetType) {
187
+ const type = triggers.get(ch);
188
+ if (type) {
103
189
  return {
104
- facetType,
105
- query: text.slice(i + 1, cursor),
190
+ type,
191
+ value: text.slice(i + 1, cursor),
106
192
  range: { start: i, end: cursor },
107
193
  };
108
194
  }
package/build/util.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"util.js","sourceRoot":"","sources":["../src/util.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n TapperFacetConfigMap,\n TapperNode,\n TapperFacet,\n} from './types'\n\nexport function parseNodesFromText(\n text: string,\n config: TapperFacetConfigMap,\n prevNodes?: TapperNode[],\n): TapperNode[] {\n const allMatches: {\n facetName: string\n fullMatch: string\n capture: string\n index: number\n }[] = []\n\n for (const [name, def] of Object.entries(config)) {\n const re = new RegExp(def.match.source, def.match.flags)\n for (const m of text.matchAll(re)) {\n allMatches.push({\n facetName: name,\n fullMatch: m[0],\n capture: m[0],\n index: m.index,\n })\n }\n }\n\n allMatches.sort(\n (a, b) => a.index - b.index || b.fullMatch.length - a.fullMatch.length,\n )\n\n const accepted: typeof allMatches = []\n let lastEnd = 0\n for (const m of allMatches) {\n if (m.index >= lastEnd) {\n accepted.push(m)\n lastEnd = m.index + m.fullMatch.length\n }\n }\n\n const nodes: TapperNode[] = []\n let cursor = 0\n\n for (const m of accepted) {\n if (m.index > cursor) {\n nodes.push({\n type: 'text',\n value: text.slice(cursor, m.index),\n start: cursor,\n end: m.index,\n })\n }\n nodes.push({\n type: m.facetName,\n value: m.capture,\n start: m.index,\n end: m.index + m.fullMatch.length,\n })\n cursor = m.index + m.fullMatch.length\n }\n\n if (cursor < text.length) {\n nodes.push({\n type: 'text',\n value: text.slice(cursor),\n start: cursor,\n end: text.length,\n })\n }\n\n // Transfer committed flags from previous nodes by type + occurrence index\n if (prevNodes) {\n const counts = new Map<string, number>()\n const committedByTypeIndex = new Set<string>()\n\n for (const node of prevNodes) {\n if (node.type === 'text') continue\n const idx = counts.get(node.type) ?? 0\n counts.set(node.type, idx + 1)\n if (node.committed) {\n committedByTypeIndex.add(`${node.type}:${idx}`)\n }\n }\n\n counts.clear()\n for (const node of nodes) {\n if (node.type === 'text') continue\n const idx = counts.get(node.type) ?? 0\n counts.set(node.type, idx + 1)\n if (committedByTypeIndex.has(`${node.type}:${idx}`)) {\n node.committed = true\n }\n }\n }\n\n return nodes\n}\n\nexport function deriveTriggers(config: TapperFacetConfigMap): Map<string, string> {\n const triggers = new Map<string, string>()\n for (const [name, def] of Object.entries(config)) {\n const m = def.match.source.match(/^[^\\\\([\\]{}.*+?^$|]+/)\n if (m) triggers.set(m[0], name)\n }\n return triggers\n}\n\nexport function detectActiveFacet(\n nodes: TapperNode[],\n text: string,\n cursor: number,\n triggers: Map<string, string>,\n): TapperFacet | null {\n for (const node of nodes) {\n if (node.type !== 'text' && node.start < cursor && cursor <= node.end) {\n return {\n facetType: node.type,\n query: node.value,\n range: {start: node.start, end: node.end},\n }\n }\n }\n\n // Scan backward from cursor for a trigger char (partial facet not yet matched by regex)\n for (let i = cursor - 1; i >= 0; i--) {\n const ch = text[i]\n // Stop at whitespace — triggers don't span across words\n if (/\\s/.test(ch)) break\n const facetType = triggers.get(ch)\n if (facetType) {\n return {\n facetType,\n query: text.slice(i + 1, cursor),\n range: {start: i, end: cursor},\n }\n }\n }\n\n return null\n}\n"]}
1
+ {"version":3,"file":"util.js","sourceRoot":"","sources":["../src/util.ts"],"names":[],"mappings":"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","sourcesContent":["import {TapperFacetConfigMap, TapperNode, TapperFacet} from './types'\n\nconst WHITESPACE = /\\s/\n\nexport type CompiledFacetRegexes = Map<string, RegExp>\n\n/**\n * Pre-compile facet regexes once at init time. This avoids re-creating\n * RegExp objects on every keystroke in parseNodesFromText. Each Tapper\n * instance gets its own compiled copy so lastIndex state can't leak\n * between instances.\n */\nexport function compileFacetRegexes(\n config: TapperFacetConfigMap,\n): CompiledFacetRegexes {\n const compiled = new Map<string, RegExp>()\n for (const [name, def] of Object.entries(config)) {\n compiled.set(name, new RegExp(def.match.source, def.match.flags))\n }\n return compiled\n}\n\nexport function parseNodesFromText(\n text: string,\n regexes: CompiledFacetRegexes,\n prevNodes?: TapperNode[],\n cursor?: number,\n triggers?: Map<string, string>,\n): TapperNode[] {\n const allMatches: {\n facetName: string\n fullMatch: string\n capture: string\n index: number\n }[] = []\n\n for (const [name, re] of regexes) {\n // Reset lastIndex so stateful (global) regexes don't carry over\n // match positions from the previous parse call.\n re.lastIndex = 0\n for (const m of text.matchAll(re)) {\n allMatches.push({\n facetName: name,\n fullMatch: m[0],\n capture: m[1] ?? m[0],\n index: m.index,\n })\n }\n }\n\n allMatches.sort(\n (a, b) => a.index - b.index || b.fullMatch.length - a.fullMatch.length,\n )\n\n const accepted: typeof allMatches = []\n let lastEnd = 0\n for (const m of allMatches) {\n if (m.index >= lastEnd) {\n accepted.push(m)\n lastEnd = m.index + m.fullMatch.length\n }\n }\n\n const nodes: TapperNode[] = []\n let pos = 0\n\n for (const m of accepted) {\n if (m.index > pos) {\n const raw = text.slice(pos, m.index)\n nodes.push({\n type: 'text',\n raw,\n value: raw,\n start: pos,\n end: m.index,\n })\n }\n nodes.push({\n type: 'facet',\n facetType: m.facetName,\n raw: m.fullMatch,\n value: m.capture,\n start: m.index,\n end: m.index + m.fullMatch.length,\n })\n pos = m.index + m.fullMatch.length\n }\n\n if (pos < text.length) {\n const raw = text.slice(pos)\n nodes.push({\n type: 'text',\n raw,\n value: raw,\n start: pos,\n end: text.length,\n })\n }\n\n // If the cursor is right after a trigger char that the regex didn't match,\n // splice a 'trigger' node out of the containing text node.\n if (cursor != null && triggers) {\n for (let i = cursor - 1; i >= 0; i--) {\n const ch = text[i]\n if (WHITESPACE.test(ch)) break\n const facetType = triggers.get(ch)\n if (facetType) {\n // Only create a trigger node if the trigger is inside a text node\n // (i.e. the regex didn't already match it as a facet)\n const textNodeIdx = nodes.findIndex(\n n => n.type === 'text' && n.start <= i && n.end > i,\n )\n if (textNodeIdx !== -1) {\n const node = nodes[textNodeIdx]\n const spliced: TapperNode[] = []\n if (node.start < i) {\n const raw = text.slice(node.start, i)\n spliced.push({\n type: 'text',\n raw,\n value: raw,\n start: node.start,\n end: i,\n })\n }\n const triggerRaw = text.slice(i, cursor)\n spliced.push({\n type: 'trigger',\n facetType,\n raw: triggerRaw,\n value: text.slice(i + ch.length, cursor),\n start: i,\n end: cursor,\n })\n if (cursor < node.end) {\n const raw = text.slice(cursor, node.end)\n spliced.push({\n type: 'text',\n raw,\n value: raw,\n start: cursor,\n end: node.end,\n })\n }\n nodes.splice(textNodeIdx, 1, ...spliced)\n }\n break\n }\n }\n }\n\n // Transfer committed flags from previous nodes by facetType + occurrence index\n if (prevNodes) {\n const counts = new Map<string, number>()\n const committedByTypeIndex = new Set<string>()\n\n for (const node of prevNodes) {\n if (node.type !== 'facet') continue\n const idx = counts.get(node.facetType!) ?? 0\n counts.set(node.facetType!, idx + 1)\n if (node.committed) {\n committedByTypeIndex.add(`${node.facetType}:${idx}`)\n }\n }\n\n counts.clear()\n for (const node of nodes) {\n if (node.type !== 'facet') continue\n const idx = counts.get(node.facetType!) ?? 0\n counts.set(node.facetType!, idx + 1)\n if (committedByTypeIndex.has(`${node.facetType}:${idx}`)) {\n node.committed = true\n }\n }\n }\n\n return nodes\n}\n\nexport function deriveTriggers(\n config: TapperFacetConfigMap,\n): Map<string, string> {\n const triggers = new Map<string, string>()\n for (const [name, def] of Object.entries(config)) {\n const m = def.match.source.match(/^[^\\\\([\\]{}.*+?^$|]+/)\n if (m) triggers.set(m[0], name)\n }\n return triggers\n}\n\nexport function nodeToFacet(node: TapperNode): TapperFacet {\n return {\n type: node.facetType!,\n value: node.value,\n range: {start: node.start, end: node.end},\n }\n}\n\nexport function detectActiveFacet(\n nodes: TapperNode[],\n text: string,\n cursor: number,\n triggers: Map<string, string>,\n): TapperFacet | null {\n for (const node of nodes) {\n if (node.type === 'trigger' && node.start < cursor && cursor <= node.end) {\n return {\n type: node.facetType,\n value: node.value,\n range: {start: node.start, end: node.end},\n }\n }\n if (node.type === 'facet' && node.start < cursor && cursor <= node.end) {\n return {\n type: node.facetType,\n value: node.value,\n range: {start: node.start, end: node.end},\n }\n }\n }\n\n // Scan backward from cursor for a trigger char (partial facet not yet matched by regex)\n for (let i = cursor - 1; i >= 0; i--) {\n const ch = text[i]\n // Stop at whitespace — triggers don't span across words\n if (WHITESPACE.test(ch)) break\n const type = triggers.get(ch)\n if (type) {\n return {\n type,\n value: text.slice(i + 1, cursor),\n range: {start: i, end: cursor},\n }\n }\n }\n\n return null\n}\n"]}
package/package.json CHANGED
@@ -1,28 +1,19 @@
1
1
  {
2
2
  "name": "@bsky.app/tapper",
3
- "version": "0.1.1",
3
+ "version": "0.2.2",
4
+ "license": "MIT",
4
5
  "description": "A minimal rich text editor for React Native and web.",
6
+ "repository": "https://github.com/bluesky-social/toolbox",
7
+ "author": "Eric Bailey <git@esb.lol> (https://github.com/estrattonbailey)",
8
+ "homepage": "https://github.com/bluesky-social/toolbox/tree/main/packages/tapper",
5
9
  "main": "build/index.js",
6
10
  "types": "build/index.d.ts",
7
- "scripts": {
8
- "build": "expo-module build",
9
- "clean": "expo-module clean",
10
- "lint": "expo-module lint",
11
- "test": "vitest run",
12
- "prepare": "expo-module prepare",
13
- "prepublishOnly": "expo-module prepublishOnly",
14
- "expo-module": "expo-module",
15
- "open:ios": "xed example/ios",
16
- "open:android": "open -a \"Android Studio\" example/android"
17
- },
18
11
  "keywords": [
19
12
  "react-native",
20
13
  "expo",
21
14
  "tapper",
22
15
  "Tapper"
23
16
  ],
24
- "author": "Bluesky Social, PBLLC",
25
- "license": "MIT",
26
17
  "dependencies": {},
27
18
  "devDependencies": {
28
19
  "@types/react": "~19.1.1",
@@ -35,5 +26,14 @@
35
26
  "expo": "*",
36
27
  "react": "*",
37
28
  "react-native": "*"
29
+ },
30
+ "scripts": {
31
+ "build": "expo-module build",
32
+ "clean": "expo-module clean",
33
+ "lint": "expo-module lint",
34
+ "test": "vitest run",
35
+ "expo-module": "expo-module",
36
+ "open:ios": "xed example/ios",
37
+ "open:android": "open -a \"Android Studio\" example/android"
38
38
  }
39
- }
39
+ }
package/src/index.ts CHANGED
@@ -1,20 +1,267 @@
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(
100
+ newText,
101
+ this.facetRegexes,
102
+ this.nodes,
103
+ cursor,
104
+ this.triggers,
105
+ )
106
+ const prev = this.activeFacet
107
+ const detected = detectActiveFacet(nodes, newText, cursor, this.triggers)
108
+
109
+ // When cursor leaves a facet, commit it
110
+ let committedNode: TapperNode | null = null
111
+ if (prev && (!detected || detected.type !== prev.type)) {
112
+ for (const node of nodes) {
113
+ if (
114
+ node.type === 'facet' &&
115
+ node.facetType === prev.type &&
116
+ node.start === prev.range.start &&
117
+ node.end === prev.range.end
118
+ ) {
119
+ node.committed = true
120
+ committedNode = node
121
+ break
122
+ }
123
+ }
124
+ }
125
+
126
+ // Build activeFacet with commit() baked in
127
+ const active: TapperActiveFacet | null = detected
128
+ ? {...detected, insert: this.insert}
129
+ : null
130
+
131
+ this.text = newText
132
+ this.cursor = cursor
133
+ this.nodes = nodes
134
+ this.activeFacet = active
135
+
136
+ this.updating = false
137
+ this.notify()
138
+
139
+ // Fire events after state is finalized
140
+ const facetChanged =
141
+ active?.type !== prev?.type ||
142
+ active?.value !== prev?.value ||
143
+ active?.range.start !== prev?.range.start ||
144
+ active?.range.end !== prev?.range.end
145
+ if (facetChanged) {
146
+ this.emit('activeFacet', active)
147
+ }
148
+ if (committedNode) {
149
+ this.emit('facetCommitted', nodeToFacet(committedNode))
150
+ }
151
+ }
152
+
153
+ handleTextChange = (newText: string) => {
154
+ const diff = newText.length - this.text.length
155
+ let newCursor = Math.max(this.cursor + diff, 0)
156
+
157
+ // Atomic deletion: backspace into a facet from outside
158
+ if (diff === -1 && newCursor < this.cursor) {
159
+ for (const node of this.nodes) {
160
+ if (
161
+ node.type === 'facet' &&
162
+ node.committed &&
163
+ this.cursor === node.end
164
+ ) {
165
+ const remnant = node.end - node.start - 1
166
+ newText =
167
+ newText.slice(0, node.start) + newText.slice(node.start + remnant)
168
+ newCursor = node.start
169
+ this.pendingCursor = newCursor
170
+ break
171
+ }
172
+ }
173
+ }
174
+
175
+ this.update(newText, newCursor)
176
+ }
177
+
178
+ handleSelectionChange = (e: {nativeEvent: {selection: {start: number}}}) => {
179
+ const newCursor = e.nativeEvent.selection.start
180
+ // After atomic deletion, ignore stale cursor values from the DOM
181
+ if (this.pendingCursor !== null) {
182
+ if (newCursor !== this.pendingCursor) {
183
+ return
184
+ }
185
+ this.pendingCursor = null
186
+ }
187
+
188
+ if (newCursor === this.cursor) return
189
+
190
+ this.cursor = newCursor
191
+ this.update(this.text, newCursor)
192
+ }
193
+
194
+ replaceText = (text: string, cursor?: number) => {
195
+ const prevActive = this.activeFacet
196
+ const nodes = parseNodesFromText(text, this.facetRegexes)
197
+ // Mark all non-text nodes as committed (pre-existing facets)
198
+ const newlyCommitted: TapperNode[] = []
199
+ for (const node of nodes) {
200
+ if (node.type === 'facet') {
201
+ node.committed = true
202
+ newlyCommitted.push(node)
203
+ }
204
+ }
205
+
206
+ const pos = cursor ?? text.length
207
+ const detected = detectActiveFacet(nodes, text, pos, this.triggers)
208
+ const active: TapperActiveFacet | null = detected
209
+ ? {...detected, insert: this.insert}
210
+ : null
211
+
212
+ this.text = text
213
+ this.cursor = pos
214
+ this.nodes = nodes
215
+ this.activeFacet = active
216
+
217
+ this.notify()
218
+
219
+ if (active !== prevActive) {
220
+ this.emit('activeFacet', active)
221
+ }
222
+ for (const node of newlyCommitted) {
223
+ this.emit('facetCommitted', nodeToFacet(node))
224
+ }
225
+ }
226
+
227
+ private insert = (value: string, options?: {noTrailingSpace?: boolean}) => {
228
+ if (!this.activeFacet) return
229
+
230
+ const replacement = value + (options?.noTrailingSpace ? '' : ' ')
231
+ const newText =
232
+ this.text.slice(0, this.activeFacet.range.start) +
233
+ replacement +
234
+ this.text.slice(this.activeFacet.range.end)
235
+ const newCursor = this.activeFacet.range.start + replacement.length
236
+
237
+ if (this.activeFacet) {
238
+ this.activeFacet.value = value
239
+ this.activeFacet.range = {
240
+ start: this.activeFacet.range.start,
241
+ end: this.activeFacet.range.start + value.length,
242
+ }
243
+ this.emit('afterInsert', this.activeFacet)
244
+ }
245
+
246
+ this.update(newText, newCursor)
247
+ }
248
+ }
6
249
 
7
250
  export function useTapper(config: TapperConfig) {
8
- const tapper = useTapperCore(config)
251
+ const [store] = useState(() => new Tapper(config))
252
+ const state = useSyncExternalStore(store.subscribe, store.getSnapshot)
253
+ const inputRef = useRef<{focus(): void}>(null)
9
254
 
10
255
  return {
11
- ...tapper,
256
+ ...state,
257
+ on: store.on,
258
+ focus: () => inputRef.current?.focus(),
12
259
  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),
260
+ ref: inputRef as React.RefObject<any>,
261
+ value: state.text,
262
+ selection: {start: state.cursor, end: state.cursor},
263
+ onChangeText: store.handleTextChange,
264
+ onSelectionChange: store.handleSelectionChange,
18
265
  },
19
266
  }
20
267
  }
package/src/types.ts CHANGED
@@ -4,17 +4,38 @@ export type TapperFacetConfig = {
4
4
 
5
5
  export type TapperFacetConfigMap = Record<string, TapperFacetConfig>
6
6
 
7
- export type TapperNode = {
8
- type: string
9
- value: string
10
- start: number
11
- end: number
12
- committed?: boolean
13
- }
7
+ export type TapperNode =
8
+ | {
9
+ type: 'text'
10
+ raw: string
11
+ value: string
12
+ start: number
13
+ end: number
14
+ committed?: boolean
15
+ facetType?: undefined
16
+ }
17
+ | {
18
+ type: 'trigger'
19
+ raw: string
20
+ value: string
21
+ start: number
22
+ end: number
23
+ committed?: boolean
24
+ facetType: string
25
+ }
26
+ | {
27
+ type: 'facet'
28
+ raw: string
29
+ value: string
30
+ start: number
31
+ end: number
32
+ committed?: boolean
33
+ facetType: string
34
+ }
14
35
 
15
36
  export type TapperFacet = {
16
- facetType: string
17
- query: string
37
+ type: string
38
+ value: string
18
39
  range: {start: number; end: number}
19
40
  }
20
41
 
@@ -33,3 +54,9 @@ export type TapperConfig = {
33
54
  facets: TapperFacetConfigMap
34
55
  initialText?: string
35
56
  }
57
+
58
+ export type TapperEvents = {
59
+ activeFacet: TapperActiveFacet | null
60
+ facetCommitted: TapperFacet
61
+ afterInsert: TapperFacet
62
+ }