@bsky.app/tapper 0.1.0 → 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/README.md +1 -30
- package/build/index.d.ts +36 -5
- package/build/index.d.ts.map +1 -1
- package/build/index.js +197 -8
- package/build/index.js.map +1 -1
- package/build/types.d.ts +8 -3
- package/build/types.d.ts.map +1 -1
- package/build/types.js.map +1 -1
- package/build/util.d.ts +10 -1
- package/build/util.d.ts.map +1 -1
- package/build/util.js +33 -10
- package/build/util.js.map +1 -1
- package/package.json +3 -8
- package/src/index.ts +251 -10
- package/src/types.ts +9 -3
- package/src/util.ts +44 -16
- package/build/core.d.ts +0 -30
- package/build/core.d.ts.map +0 -1
- package/build/core.js +0 -148
- package/build/core.js.map +0 -1
- package/build/index.web.d.ts +0 -17
- package/build/index.web.d.ts.map +0 -1
- package/build/index.web.js +0 -33
- package/build/index.web.js.map +0 -1
- package/src/core.ts +0 -185
- package/src/index.web.ts +0 -46
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
|
-
}
|