@bsky.app/tapper 0.1.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/.eslintrc.js ADDED
@@ -0,0 +1,5 @@
1
+ module.exports = {
2
+ root: true,
3
+ extends: ['universe/native', 'universe/web'],
4
+ ignorePatterns: ['build'],
5
+ };
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # @bsky.app/tapper
2
+
3
+ My new module
4
+
5
+ # API documentation
6
+
7
+ - [Documentation for the latest stable release](https://docs.expo.dev/versions/latest/sdk/@bsky.app/tapper/)
8
+ - [Documentation for the main branch](https://docs.expo.dev/versions/unversioned/sdk/@bsky.app/tapper/)
9
+
10
+ # Installation in managed Expo projects
11
+
12
+ For [managed](https://docs.expo.dev/archive/managed-vs-bare/) Expo projects, please follow the installation instructions in the [API documentation for the latest stable release](#api-documentation). If you follow the link and there is no documentation available then this library is not yet usable within managed projects — it is likely to be included in an upcoming Expo SDK release.
13
+
14
+ # Installation in bare React Native projects
15
+
16
+ For bare React Native projects, you must ensure that you have [installed and configured the `expo` package](https://docs.expo.dev/bare/installing-expo-modules/) before continuing.
17
+
18
+ ### Add the package to your npm dependencies
19
+
20
+ ```
21
+ npm install @bsky.app/tapper
22
+ ```
23
+
24
+ ### Configure for Android
25
+
26
+ ### Configure for iOS
27
+
28
+ Run `npx pod-install` after installing the npm package.
29
+
30
+ # Contributing
31
+
32
+ Contributions are very welcome! Please refer to guidelines described in the [contributing guide](https://github.com/expo/expo#contributing).
@@ -0,0 +1,30 @@
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
@@ -0,0 +1 @@
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 ADDED
@@ -0,0 +1,148 @@
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
@@ -0,0 +1 @@
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"]}
@@ -0,0 +1,26 @@
1
+ import { type TapperConfig } from './types';
2
+ export * from './types';
3
+ export * from './core';
4
+ export declare function useTapper(config: TapperConfig): {
5
+ inputProps: {
6
+ value: string;
7
+ selection: {
8
+ start: number;
9
+ end: number;
10
+ };
11
+ onChangeText: (newText: string) => void;
12
+ onSelectionChange: (e: {
13
+ nativeEvent: {
14
+ selection: {
15
+ start: number;
16
+ };
17
+ };
18
+ }) => void;
19
+ };
20
+ store: import("./core").Tapper;
21
+ text: string;
22
+ cursor: number;
23
+ nodes: import("./types").TapperNode[];
24
+ activeFacet: import("./types").TapperActiveFacet | null;
25
+ };
26
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAC,KAAK,YAAY,EAAC,MAAM,SAAS,CAAA;AAEzC,cAAc,SAAS,CAAA;AACvB,cAAc,QAAQ,CAAA;AAEtB,wBAAgB,SAAS,CAAC,MAAM,EAAE,YAAY;;;;;;;;+BASjB;YAAC,WAAW,EAAE;gBAAC,SAAS,EAAE;oBAAC,KAAK,EAAE,MAAM,CAAA;iBAAC,CAAA;aAAC,CAAA;SAAC;;;;;;;EAIvE"}
package/build/index.js ADDED
@@ -0,0 +1,16 @@
1
+ import { useTapperCore } from './core';
2
+ export * from './types';
3
+ export * from './core';
4
+ export function useTapper(config) {
5
+ const tapper = useTapperCore(config);
6
+ return {
7
+ ...tapper,
8
+ inputProps: {
9
+ value: tapper.text,
10
+ selection: { start: tapper.cursor, end: tapper.cursor },
11
+ onChangeText: tapper.store.handleTextChange,
12
+ onSelectionChange: (e) => tapper.store.handleSelectionChange(e.nativeEvent.selection.start),
13
+ },
14
+ };
15
+ }
16
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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"]}
@@ -0,0 +1,17 @@
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
@@ -0,0 +1 @@
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"}
@@ -0,0 +1,33 @@
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
@@ -0,0 +1 @@
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"]}
@@ -0,0 +1,35 @@
1
+ export type TapperFacetConfig = {
2
+ match: RegExp;
3
+ };
4
+ export type TapperFacetConfigMap = Record<string, TapperFacetConfig>;
5
+ export type TapperNode = {
6
+ type: string;
7
+ value: string;
8
+ start: number;
9
+ end: number;
10
+ committed?: boolean;
11
+ };
12
+ export type TapperFacet = {
13
+ facetType: string;
14
+ query: string;
15
+ range: {
16
+ start: number;
17
+ end: number;
18
+ };
19
+ };
20
+ export type TapperActiveFacet = TapperFacet & {
21
+ insert: (value: string, options?: {
22
+ noTrailingSpace?: boolean;
23
+ }) => void;
24
+ };
25
+ export type TapperSnapshot = {
26
+ text: string;
27
+ cursor: number;
28
+ nodes: TapperNode[];
29
+ activeFacet: TapperActiveFacet | null;
30
+ };
31
+ export type TapperConfig = {
32
+ facets: TapperFacetConfigMap;
33
+ initialText?: string;
34
+ };
35
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +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,GAAG;IACvB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB,CAAA;AAED,MAAM,MAAM,WAAW,GAAG;IACxB,SAAS,EAAE,MAAM,CAAA;IACjB,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"}
package/build/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +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 = {\n type: string\n value: string\n start: number\n end: number\n committed?: boolean\n}\n\nexport type TapperFacet = {\n facetType: string\n query: 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"]}
@@ -0,0 +1,5 @@
1
+ import { TapperFacetConfigMap, TapperNode, TapperFacet } from './types';
2
+ export declare function parseNodesFromText(text: string, config: TapperFacetConfigMap, prevNodes?: TapperNode[]): TapperNode[];
3
+ export declare function deriveTriggers(config: TapperFacetConfigMap): Map<string, string>;
4
+ export declare function detectActiveFacet(nodes: TapperNode[], text: string, cursor: number, triggers: Map<string, string>): TapperFacet | null;
5
+ //# sourceMappingURL=util.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"util.d.ts","sourceRoot":"","sources":["../src/util.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,oBAAoB,EACpB,UAAU,EACV,WAAW,EACZ,MAAM,SAAS,CAAA;AAEhB,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,oBAAoB,EAC5B,SAAS,CAAC,EAAE,UAAU,EAAE,GACvB,UAAU,EAAE,CAyFd;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,oBAAoB,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAOhF;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,CA2BpB"}
package/build/util.js ADDED
@@ -0,0 +1,112 @@
1
+ export function parseNodesFromText(text, config, prevNodes) {
2
+ const allMatches = [];
3
+ for (const [name, def] of Object.entries(config)) {
4
+ const re = new RegExp(def.match.source, def.match.flags);
5
+ for (const m of text.matchAll(re)) {
6
+ allMatches.push({
7
+ facetName: name,
8
+ fullMatch: m[0],
9
+ capture: m[0],
10
+ index: m.index,
11
+ });
12
+ }
13
+ }
14
+ allMatches.sort((a, b) => a.index - b.index || b.fullMatch.length - a.fullMatch.length);
15
+ const accepted = [];
16
+ let lastEnd = 0;
17
+ for (const m of allMatches) {
18
+ if (m.index >= lastEnd) {
19
+ accepted.push(m);
20
+ lastEnd = m.index + m.fullMatch.length;
21
+ }
22
+ }
23
+ const nodes = [];
24
+ let cursor = 0;
25
+ for (const m of accepted) {
26
+ if (m.index > cursor) {
27
+ nodes.push({
28
+ type: 'text',
29
+ value: text.slice(cursor, m.index),
30
+ start: cursor,
31
+ end: m.index,
32
+ });
33
+ }
34
+ nodes.push({
35
+ type: m.facetName,
36
+ value: m.capture,
37
+ start: m.index,
38
+ end: m.index + m.fullMatch.length,
39
+ });
40
+ cursor = m.index + m.fullMatch.length;
41
+ }
42
+ if (cursor < text.length) {
43
+ nodes.push({
44
+ type: 'text',
45
+ value: text.slice(cursor),
46
+ start: cursor,
47
+ end: text.length,
48
+ });
49
+ }
50
+ // Transfer committed flags from previous nodes by type + occurrence index
51
+ if (prevNodes) {
52
+ const counts = new Map();
53
+ const committedByTypeIndex = new Set();
54
+ for (const node of prevNodes) {
55
+ if (node.type === 'text')
56
+ continue;
57
+ const idx = counts.get(node.type) ?? 0;
58
+ counts.set(node.type, idx + 1);
59
+ if (node.committed) {
60
+ committedByTypeIndex.add(`${node.type}:${idx}`);
61
+ }
62
+ }
63
+ counts.clear();
64
+ for (const node of nodes) {
65
+ if (node.type === 'text')
66
+ continue;
67
+ const idx = counts.get(node.type) ?? 0;
68
+ counts.set(node.type, idx + 1);
69
+ if (committedByTypeIndex.has(`${node.type}:${idx}`)) {
70
+ node.committed = true;
71
+ }
72
+ }
73
+ }
74
+ return nodes;
75
+ }
76
+ export function deriveTriggers(config) {
77
+ const triggers = new Map();
78
+ for (const [name, def] of Object.entries(config)) {
79
+ const m = def.match.source.match(/^[^\\([\]{}.*+?^$|]+/);
80
+ if (m)
81
+ triggers.set(m[0], name);
82
+ }
83
+ return triggers;
84
+ }
85
+ export function detectActiveFacet(nodes, text, cursor, triggers) {
86
+ for (const node of nodes) {
87
+ if (node.type !== 'text' && node.start < cursor && cursor <= node.end) {
88
+ return {
89
+ facetType: node.type,
90
+ query: node.value,
91
+ range: { start: node.start, end: node.end },
92
+ };
93
+ }
94
+ }
95
+ // Scan backward from cursor for a trigger char (partial facet not yet matched by regex)
96
+ for (let i = cursor - 1; i >= 0; i--) {
97
+ const ch = text[i];
98
+ // Stop at whitespace — triggers don't span across words
99
+ if (/\s/.test(ch))
100
+ break;
101
+ const facetType = triggers.get(ch);
102
+ if (facetType) {
103
+ return {
104
+ facetType,
105
+ query: text.slice(i + 1, cursor),
106
+ range: { start: i, end: cursor },
107
+ };
108
+ }
109
+ }
110
+ return null;
111
+ }
112
+ //# sourceMappingURL=util.js.map
@@ -0,0 +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"]}
@@ -0,0 +1,5 @@
1
+ {
2
+ "platforms": ["apple", "android", "web"],
3
+ "apple": {},
4
+ "android": {}
5
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@bsky.app/tapper",
3
+ "version": "0.1.0",
4
+ "description": "My new module",
5
+ "main": "build/index.js",
6
+ "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
+ "keywords": [
19
+ "react-native",
20
+ "expo",
21
+ "tapper",
22
+ "Tapper"
23
+ ],
24
+ "repository": "https://github.com/estrattonbailey/tapper",
25
+ "bugs": {
26
+ "url": "https://github.com/estrattonbailey/tapper/issues"
27
+ },
28
+ "author": "Bluesky Social PBLLC <git@esb.lol> (https://github.com/estrattonbailey)",
29
+ "license": "MIT",
30
+ "homepage": "https://github.com/estrattonbailey/tapper#readme",
31
+ "dependencies": {},
32
+ "devDependencies": {
33
+ "@types/react": "~19.1.1",
34
+ "expo-module-scripts": "^55.0.2",
35
+ "vitest": "^3.1.1",
36
+ "expo": "^55.0.8",
37
+ "react-native": "0.82.1"
38
+ },
39
+ "peerDependencies": {
40
+ "expo": "*",
41
+ "react": "*",
42
+ "react-native": "*"
43
+ }
44
+ }
package/src/core.ts ADDED
@@ -0,0 +1,185 @@
1
+ import {useMemo, useSyncExternalStore} from 'react'
2
+
3
+ import {
4
+ TapperConfig,
5
+ TapperFacetConfigMap,
6
+ TapperNode,
7
+ TapperActiveFacet,
8
+ TapperSnapshot,
9
+ } from './types'
10
+
11
+ import {parseNodesFromText, detectActiveFacet, deriveTriggers} from './util'
12
+
13
+ export class Tapper {
14
+ private facetConfig: TapperFacetConfigMap
15
+ private triggers: Map<string, string>
16
+
17
+ // Public state
18
+ text = ''
19
+ cursor = 0
20
+ nodes: TapperNode[] = []
21
+ activeFacet: TapperActiveFacet | null = null
22
+
23
+ // Internal tracking
24
+ private updating = false
25
+ private pendingCursor: number | null = null
26
+
27
+ // useSyncExternalStore plumbing
28
+ private storeListeners = new Set<() => void>()
29
+ private snapshot: TapperSnapshot
30
+
31
+ constructor(config: TapperConfig) {
32
+ this.facetConfig = config.facets
33
+ this.triggers = deriveTriggers(config.facets)
34
+ this.snapshot = {
35
+ text: this.text,
36
+ cursor: this.cursor,
37
+ nodes: this.nodes,
38
+ activeFacet: this.activeFacet,
39
+ }
40
+ if (config.initialText) {
41
+ this.replaceText(config.initialText)
42
+ }
43
+ }
44
+
45
+ subscribe = (listener: () => void) => {
46
+ this.storeListeners.add(listener)
47
+ return () => this.storeListeners.delete(listener)
48
+ }
49
+
50
+ getSnapshot = (): TapperSnapshot => {
51
+ return this.snapshot
52
+ }
53
+
54
+ private notify() {
55
+ this.snapshot = {
56
+ text: this.text,
57
+ cursor: this.cursor,
58
+ nodes: this.nodes,
59
+ activeFacet: this.activeFacet,
60
+ }
61
+ this.storeListeners.forEach(l => l())
62
+ }
63
+
64
+ private update(newText: string, cursor: number) {
65
+ // guard against circular updates when insert is called during an update cycle
66
+ if (this.updating) return
67
+ this.updating = true
68
+
69
+ const nodes = parseNodesFromText(newText, this.facetConfig, this.nodes)
70
+ const prev = this.activeFacet
71
+ const detected = detectActiveFacet(nodes, newText, cursor, this.triggers)
72
+
73
+ // When cursor leaves a facet, commit it
74
+ if (prev && (!detected || detected.facetType !== prev.facetType)) {
75
+ for (const node of nodes) {
76
+ if (node.type === prev.facetType && node.value === prev.query) {
77
+ node.committed = true
78
+ break
79
+ }
80
+ }
81
+ }
82
+
83
+ // Build activeFacet with insert baked in
84
+ const active: TapperActiveFacet | null = detected
85
+ ? {...detected, insert: this.insert}
86
+ : null
87
+
88
+ this.text = newText
89
+ this.cursor = cursor
90
+ this.nodes = nodes
91
+ this.activeFacet = active
92
+
93
+ this.updating = false
94
+ this.notify()
95
+ }
96
+
97
+ handleTextChange = (newText: string) => {
98
+ const diff = newText.length - this.text.length
99
+ let newCursor = Math.max(this.cursor + diff, 0)
100
+
101
+ // Atomic deletion: backspace into a facet from outside
102
+ if (diff === -1 && newCursor < this.cursor) {
103
+ for (const node of this.nodes) {
104
+ if (
105
+ node.type !== 'text' &&
106
+ node.committed &&
107
+ this.cursor === node.end
108
+ ) {
109
+ const remnant = node.end - node.start - 1
110
+ newText =
111
+ newText.slice(0, node.start) + newText.slice(node.start + remnant)
112
+ newCursor = node.start
113
+ this.pendingCursor = newCursor
114
+ break
115
+ }
116
+ }
117
+ }
118
+
119
+ this.update(newText, newCursor)
120
+ }
121
+
122
+ handleSelectionChange = (newCursor: number) => {
123
+ // After atomic deletion, ignore stale cursor values from the DOM
124
+ if (this.pendingCursor !== null) {
125
+ if (newCursor !== this.pendingCursor) {
126
+ return
127
+ }
128
+ this.pendingCursor = null
129
+ }
130
+
131
+ this.cursor = newCursor
132
+ this.update(this.text, newCursor)
133
+ }
134
+
135
+ replaceText = (text: string, cursor?: number) => {
136
+ const nodes = parseNodesFromText(text, this.facetConfig)
137
+ // Mark all non-text nodes as committed (pre-existing facets)
138
+ for (const node of nodes) {
139
+ if (node.type !== 'text') {
140
+ node.committed = true
141
+ }
142
+ }
143
+
144
+ const pos = cursor ?? text.length
145
+ const detected = detectActiveFacet(nodes, text, pos, this.triggers)
146
+ const active: TapperActiveFacet | null = detected
147
+ ? {...detected, insert: this.insert}
148
+ : null
149
+
150
+ this.text = text
151
+ this.cursor = pos
152
+ this.nodes = nodes
153
+ this.activeFacet = active
154
+
155
+ this.notify()
156
+ }
157
+
158
+ private insert = (value: string, options?: {noTrailingSpace?: boolean}) => {
159
+ const active = this.activeFacet
160
+ if (!active) return
161
+
162
+ const replacement = value + (options?.noTrailingSpace ? '' : ' ')
163
+ const newText =
164
+ this.text.slice(0, active.range.start) +
165
+ replacement +
166
+ this.text.slice(active.range.end)
167
+ const newCursor = active.range.start + replacement.length
168
+
169
+ this.update(newText, newCursor)
170
+
171
+ // Mark the inserted facet as committed
172
+ for (const node of this.nodes) {
173
+ if (node.type !== 'text' && node.start === active.range.start) {
174
+ node.committed = true
175
+ break
176
+ }
177
+ }
178
+ }
179
+ }
180
+
181
+ export function useTapperCore(config: TapperConfig) {
182
+ const store = useMemo(() => new Tapper(config), [config])
183
+ const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot)
184
+ return {...snapshot, store}
185
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ import {useTapperCore} from './core'
2
+ import {type TapperConfig} from './types'
3
+
4
+ export * from './types'
5
+ export * from './core'
6
+
7
+ export function useTapper(config: TapperConfig) {
8
+ const tapper = useTapperCore(config)
9
+
10
+ return {
11
+ ...tapper,
12
+ 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),
18
+ },
19
+ }
20
+ }
@@ -0,0 +1,46 @@
1
+ import {useCallback, useLayoutEffect, useRef} from 'react'
2
+
3
+ import {useTapperCore} from './core'
4
+ import {type TapperConfig} from './types'
5
+
6
+ export * from './types'
7
+ export * from './core'
8
+
9
+ export function useTapper(config: TapperConfig) {
10
+ const tapper = useTapperCore(config)
11
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
12
+
13
+ // After atomic deletion (or insert), restore cursor position in the DOM
14
+ useLayoutEffect(() => {
15
+ const ta = textareaRef.current
16
+ if (ta && ta.selectionStart !== tapper.cursor) {
17
+ ta.setSelectionRange(tapper.cursor, tapper.cursor)
18
+ }
19
+ }, [tapper.cursor, tapper.text])
20
+
21
+ const onChange = useCallback(
22
+ (e: React.ChangeEvent<HTMLTextAreaElement>) => {
23
+ tapper.store.handleTextChange(e.target.value)
24
+ tapper.store.handleSelectionChange(e.target.selectionStart ?? 0)
25
+ },
26
+ [tapper.store],
27
+ )
28
+
29
+ const onSelect = useCallback(
30
+ (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
31
+ const target = e.target as HTMLTextAreaElement
32
+ tapper.store.handleSelectionChange(target.selectionStart ?? 0)
33
+ },
34
+ [tapper.store],
35
+ )
36
+
37
+ return {
38
+ ...tapper,
39
+ inputProps: {
40
+ ref: textareaRef,
41
+ value: tapper.text,
42
+ onChange,
43
+ onSelect,
44
+ },
45
+ }
46
+ }
package/src/types.ts ADDED
@@ -0,0 +1,35 @@
1
+ export type TapperFacetConfig = {
2
+ match: RegExp
3
+ }
4
+
5
+ export type TapperFacetConfigMap = Record<string, TapperFacetConfig>
6
+
7
+ export type TapperNode = {
8
+ type: string
9
+ value: string
10
+ start: number
11
+ end: number
12
+ committed?: boolean
13
+ }
14
+
15
+ export type TapperFacet = {
16
+ facetType: string
17
+ query: string
18
+ range: {start: number; end: number}
19
+ }
20
+
21
+ export type TapperActiveFacet = TapperFacet & {
22
+ insert: (value: string, options?: {noTrailingSpace?: boolean}) => void
23
+ }
24
+
25
+ export type TapperSnapshot = {
26
+ text: string
27
+ cursor: number
28
+ nodes: TapperNode[]
29
+ activeFacet: TapperActiveFacet | null
30
+ }
31
+
32
+ export type TapperConfig = {
33
+ facets: TapperFacetConfigMap
34
+ initialText?: string
35
+ }
package/src/util.ts ADDED
@@ -0,0 +1,143 @@
1
+ import {
2
+ TapperFacetConfigMap,
3
+ TapperNode,
4
+ TapperFacet,
5
+ } from './types'
6
+
7
+ export function parseNodesFromText(
8
+ text: string,
9
+ config: TapperFacetConfigMap,
10
+ prevNodes?: TapperNode[],
11
+ ): TapperNode[] {
12
+ const allMatches: {
13
+ facetName: string
14
+ fullMatch: string
15
+ capture: string
16
+ index: number
17
+ }[] = []
18
+
19
+ for (const [name, def] of Object.entries(config)) {
20
+ const re = new RegExp(def.match.source, def.match.flags)
21
+ for (const m of text.matchAll(re)) {
22
+ allMatches.push({
23
+ facetName: name,
24
+ fullMatch: m[0],
25
+ capture: m[0],
26
+ index: m.index,
27
+ })
28
+ }
29
+ }
30
+
31
+ allMatches.sort(
32
+ (a, b) => a.index - b.index || b.fullMatch.length - a.fullMatch.length,
33
+ )
34
+
35
+ const accepted: typeof allMatches = []
36
+ let lastEnd = 0
37
+ for (const m of allMatches) {
38
+ if (m.index >= lastEnd) {
39
+ accepted.push(m)
40
+ lastEnd = m.index + m.fullMatch.length
41
+ }
42
+ }
43
+
44
+ const nodes: TapperNode[] = []
45
+ let cursor = 0
46
+
47
+ for (const m of accepted) {
48
+ if (m.index > cursor) {
49
+ nodes.push({
50
+ type: 'text',
51
+ value: text.slice(cursor, m.index),
52
+ start: cursor,
53
+ end: m.index,
54
+ })
55
+ }
56
+ nodes.push({
57
+ type: m.facetName,
58
+ value: m.capture,
59
+ start: m.index,
60
+ end: m.index + m.fullMatch.length,
61
+ })
62
+ cursor = m.index + m.fullMatch.length
63
+ }
64
+
65
+ if (cursor < text.length) {
66
+ nodes.push({
67
+ type: 'text',
68
+ value: text.slice(cursor),
69
+ start: cursor,
70
+ end: text.length,
71
+ })
72
+ }
73
+
74
+ // Transfer committed flags from previous nodes by type + occurrence index
75
+ if (prevNodes) {
76
+ const counts = new Map<string, number>()
77
+ const committedByTypeIndex = new Set<string>()
78
+
79
+ 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)
83
+ if (node.committed) {
84
+ committedByTypeIndex.add(`${node.type}:${idx}`)
85
+ }
86
+ }
87
+
88
+ counts.clear()
89
+ 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}`)) {
94
+ node.committed = true
95
+ }
96
+ }
97
+ }
98
+
99
+ return nodes
100
+ }
101
+
102
+ export function deriveTriggers(config: TapperFacetConfigMap): Map<string, string> {
103
+ const triggers = new Map<string, string>()
104
+ for (const [name, def] of Object.entries(config)) {
105
+ const m = def.match.source.match(/^[^\\([\]{}.*+?^$|]+/)
106
+ if (m) triggers.set(m[0], name)
107
+ }
108
+ return triggers
109
+ }
110
+
111
+ export function detectActiveFacet(
112
+ nodes: TapperNode[],
113
+ text: string,
114
+ cursor: number,
115
+ triggers: Map<string, string>,
116
+ ): TapperFacet | null {
117
+ for (const node of nodes) {
118
+ if (node.type !== 'text' && node.start < cursor && cursor <= node.end) {
119
+ return {
120
+ facetType: node.type,
121
+ query: node.value,
122
+ range: {start: node.start, end: node.end},
123
+ }
124
+ }
125
+ }
126
+
127
+ // Scan backward from cursor for a trigger char (partial facet not yet matched by regex)
128
+ for (let i = cursor - 1; i >= 0; i--) {
129
+ const ch = text[i]
130
+ // Stop at whitespace — triggers don't span across words
131
+ if (/\s/.test(ch)) break
132
+ const facetType = triggers.get(ch)
133
+ if (facetType) {
134
+ return {
135
+ facetType,
136
+ query: text.slice(i + 1, cursor),
137
+ range: {start: i, end: cursor},
138
+ }
139
+ }
140
+ }
141
+
142
+ return null
143
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ // @generated by expo-module-scripts
2
+ {
3
+ "extends": "expo-module-scripts/tsconfig.base",
4
+ "compilerOptions": {
5
+ "outDir": "./build"
6
+ },
7
+ "include": ["./src"],
8
+ "exclude": ["**/__mocks__/*", "**/__tests__/*", "**/__rsc_tests__/*"]
9
+ }
@@ -0,0 +1,7 @@
1
+ import {defineConfig} from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ['src/__tests__/**/*.test.ts'],
6
+ },
7
+ })