@bsky.app/tapper 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core.ts DELETED
@@ -1,185 +0,0 @@
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.web.ts DELETED
@@ -1,46 +0,0 @@
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
- }