@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 +5 -0
- package/README.md +32 -0
- package/build/core.d.ts +30 -0
- package/build/core.d.ts.map +1 -0
- package/build/core.js +148 -0
- package/build/core.js.map +1 -0
- package/build/index.d.ts +26 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +16 -0
- package/build/index.js.map +1 -0
- package/build/index.web.d.ts +17 -0
- package/build/index.web.d.ts.map +1 -0
- package/build/index.web.js +33 -0
- package/build/index.web.js.map +1 -0
- package/build/types.d.ts +35 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/build/util.d.ts +5 -0
- package/build/util.d.ts.map +1 -0
- package/build/util.js +112 -0
- package/build/util.js.map +1 -0
- package/expo-module.config.json +5 -0
- package/package.json +44 -0
- package/src/core.ts +185 -0
- package/src/index.ts +20 -0
- package/src/index.web.ts +46 -0
- package/src/types.ts +35 -0
- package/src/util.ts +143 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +7 -0
package/.eslintrc.js
ADDED
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).
|
package/build/core.d.ts
ADDED
|
@@ -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"]}
|
package/build/index.d.ts
ADDED
|
@@ -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"]}
|
package/build/types.d.ts
ADDED
|
@@ -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 @@
|
|
|
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"]}
|
package/build/util.d.ts
ADDED
|
@@ -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":"AAMA,MAAM,UAAU,kBAAkB,CAChC,IAAY,EACZ,MAA4B,EAC5B,SAAwB;IAExB,MAAM,UAAU,GAKV,EAAE,CAAA;IAER,KAAK,MAAM,CAAC,IAAI,EAAE,GAAG,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;QACjD,MAAM,EAAE,GAAG,IAAI,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,CAAA;QACxD,KAAK,MAAM,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC,EAAE,CAAC;YAClC,UAAU,CAAC,IAAI,CAAC;gBACd,SAAS,EAAE,IAAI;gBACf,SAAS,EAAE,CAAC,CAAC,CAAC,CAAC;gBACf,OAAO,EAAE,CAAC,CAAC,CAAC,CAAC;gBACb,KAAK,EAAE,CAAC,CAAC,KAAK;aACf,CAAC,CAAA;QACJ,CAAC;IACH,CAAC;IAED,UAAU,CAAC,IAAI,CACb,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,SAAS,CAAC,MAAM,GAAG,CAAC,CAAC,SAAS,CAAC,MAAM,CACvE,CAAA;IAED,MAAM,QAAQ,GAAsB,EAAE,CAAA;IACtC,IAAI,OAAO,GAAG,CAAC,CAAA;IACf,KAAK,MAAM,CAAC,IAAI,UAAU,EAAE,CAAC;QAC3B,IAAI,CAAC,CAAC,KAAK,IAAI,OAAO,EAAE,CAAC;YACvB,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;YAChB,OAAO,GAAG,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,SAAS,CAAC,MAAM,CAAA;QACxC,CAAC;IACH,CAAC;IAED,MAAM,KAAK,GAAiB,EAAE,CAAA;IAC9B,IAAI,MAAM,GAAG,CAAC,CAAA;IAEd,KAAK,MAAM,CAAC,IAAI,QAAQ,EAAE,CAAC;QACzB,IAAI,CAAC,CAAC,KAAK,GAAG,MAAM,EAAE,CAAC;YACrB,KAAK,CAAC,IAAI,CAAC;gBACT,IAAI,EAAE,MAAM;gBACZ,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,KAAK,CAAC;gBAClC,KAAK,EAAE,MAAM;gBACb,GAAG,EAAE,CAAC,CAAC,KAAK;aACb,CAAC,CAAA;QACJ,CAAC;QACD,KAAK,CAAC,IAAI,CAAC;YACT,IAAI,EAAE,CAAC,CAAC,SAAS;YACjB,KAAK,EAAE,CAAC,CAAC,OAAO;YAChB,KAAK,EAAE,CAAC,CAAC,KAAK;YACd,GAAG,EAAE,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,SAAS,CAAC,MAAM;SAClC,CAAC,CAAA;QACF,MAAM,GAAG,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,SAAS,CAAC,MAAM,CAAA;IACvC,CAAC;IAED,IAAI,MAAM,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC;QACzB,KAAK,CAAC,IAAI,CAAC;YACT,IAAI,EAAE,MAAM;YACZ,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC;YACzB,KAAK,EAAE,MAAM;YACb,GAAG,EAAE,IAAI,CAAC,MAAM;SACjB,CAAC,CAAA;IACJ,CAAC;IAED,0EAA0E;IAC1E,IAAI,SAAS,EAAE,CAAC;QACd,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAA;QACxC,MAAM,oBAAoB,GAAG,IAAI,GAAG,EAAU,CAAA;QAE9C,KAAK,MAAM,IAAI,IAAI,SAAS,EAAE,CAAC;YAC7B,IAAI,IAAI,CAAC,IAAI,KAAK,MAAM;gBAAE,SAAQ;YAClC,MAAM,GAAG,GAAG,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;YACtC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,GAAG,GAAG,CAAC,CAAC,CAAA;YAC9B,IAAI,IAAI,CAAC,SAAS,EAAE,CAAC;gBACnB,oBAAoB,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,IAAI,IAAI,GAAG,EAAE,CAAC,CAAA;YACjD,CAAC;QACH,CAAC;QAED,MAAM,CAAC,KAAK,EAAE,CAAA;QACd,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACzB,IAAI,IAAI,CAAC,IAAI,KAAK,MAAM;gBAAE,SAAQ;YAClC,MAAM,GAAG,GAAG,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;YACtC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,GAAG,GAAG,CAAC,CAAC,CAAA;YAC9B,IAAI,oBAAoB,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,IAAI,IAAI,GAAG,EAAE,CAAC,EAAE,CAAC;gBACpD,IAAI,CAAC,SAAS,GAAG,IAAI,CAAA;YACvB,CAAC;QACH,CAAC;IACH,CAAC;IAED,OAAO,KAAK,CAAA;AACd,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,MAA4B;IACzD,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAkB,CAAA;IAC1C,KAAK,MAAM,CAAC,IAAI,EAAE,GAAG,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;QACjD,MAAM,CAAC,GAAG,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,sBAAsB,CAAC,CAAA;QACxD,IAAI,CAAC;YAAE,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAA;IACjC,CAAC;IACD,OAAO,QAAQ,CAAA;AACjB,CAAC;AAED,MAAM,UAAU,iBAAiB,CAC/B,KAAmB,EACnB,IAAY,EACZ,MAAc,EACd,QAA6B;IAE7B,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,IAAI,IAAI,CAAC,IAAI,KAAK,MAAM,IAAI,IAAI,CAAC,KAAK,GAAG,MAAM,IAAI,MAAM,IAAI,IAAI,CAAC,GAAG,EAAE,CAAC;YACtE,OAAO;gBACL,SAAS,EAAE,IAAI,CAAC,IAAI;gBACpB,KAAK,EAAE,IAAI,CAAC,KAAK;gBACjB,KAAK,EAAE,EAAC,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,EAAC;aAC1C,CAAA;QACH,CAAC;IACH,CAAC;IAED,wFAAwF;IACxF,KAAK,IAAI,CAAC,GAAG,MAAM,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;QACrC,MAAM,EAAE,GAAG,IAAI,CAAC,CAAC,CAAC,CAAA;QAClB,wDAAwD;QACxD,IAAI,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;YAAE,MAAK;QACxB,MAAM,SAAS,GAAG,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;QAClC,IAAI,SAAS,EAAE,CAAC;YACd,OAAO;gBACL,SAAS;gBACT,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;gBAChC,KAAK,EAAE,EAAC,KAAK,EAAE,CAAC,EAAE,GAAG,EAAE,MAAM,EAAC;aAC/B,CAAA;QACH,CAAC;IACH,CAAC;IAED,OAAO,IAAI,CAAA;AACb,CAAC","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"]}
|
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
|
+
}
|
package/src/index.web.ts
ADDED
|
@@ -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