@portabletext/plugin-input-rule 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +238 -2
- package/package.json +1 -1
- package/src/rule.stock-ticker.feature +16 -0
- package/src/rule.stock-ticker.test.tsx +46 -0
- package/src/rule.stock-ticker.ts +79 -0
package/README.md
CHANGED
|
@@ -1,5 +1,241 @@
|
|
|
1
1
|
# `@portabletext/plugin-input-rule`
|
|
2
2
|
|
|
3
|
-
> Easily configure
|
|
3
|
+
> Easily configure Input Rules in the Portable Text Editor
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
> ⚠️ Please note that `defineInputRule` and other APIs exposed by this plugin are still WIP and might change slightly. We are still ironing out some details before can cut a stable release.
|
|
6
|
+
|
|
7
|
+
Listening for an inserted text pattern to then perform a set of actions is incredibly common, but also carries many footguns:
|
|
8
|
+
|
|
9
|
+
1. How do you implement undo functionality correctly?
|
|
10
|
+
2. What about smart undo with <kbd>Backspace</kbd>?
|
|
11
|
+
3. And have you considered `insert.text` events that carry more than one character? (Android says hello.)
|
|
12
|
+
|
|
13
|
+
_This is why this plugin exists_. It brings the concept of "Input Rules" to the Portable Text Editor to allow you to write text transformation logic as if they were Behaviors, without having to worry about low-level details:
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
import type {EditorSchema} from '@portabletext/editor'
|
|
17
|
+
import {raise} from '@portabletext/editor/behaviors'
|
|
18
|
+
import {getPreviousInlineObject} from '@portabletext/editor/selectors'
|
|
19
|
+
import {defineInputRule} from '@portabletext/plugin-input-rule'
|
|
20
|
+
|
|
21
|
+
const unorderedListRule = defineInputRule({
|
|
22
|
+
// Instead of an event, we listen for a RegExp pattern
|
|
23
|
+
on: /^(-|\*) /,
|
|
24
|
+
// The `event` carries useful information like the offsets of RegExp matches
|
|
25
|
+
// as well as information about the focused text block.
|
|
26
|
+
guard: ({snapshot, event}) => {
|
|
27
|
+
// In theory, an Input Rule could return multiple matches. But in this
|
|
28
|
+
// case we only expect one match.
|
|
29
|
+
const match = event.matches.at(0)
|
|
30
|
+
|
|
31
|
+
if (!match) {
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {match}
|
|
36
|
+
},
|
|
37
|
+
actions: [
|
|
38
|
+
({event}, {match}) => [
|
|
39
|
+
// Turn the text block into a paragraph
|
|
40
|
+
raise({
|
|
41
|
+
type: 'block.unset',
|
|
42
|
+
props: ['style'],
|
|
43
|
+
at: event.focusTextBlock.path,
|
|
44
|
+
}),
|
|
45
|
+
// Then, turn it into a list ite
|
|
46
|
+
raise({
|
|
47
|
+
type: 'block.set',
|
|
48
|
+
props: {
|
|
49
|
+
listItem: 'bullet',
|
|
50
|
+
level: event.focusTextBlock.node.level ?? 1,
|
|
51
|
+
},
|
|
52
|
+
at: event.focusTextBlock.path,
|
|
53
|
+
}),
|
|
54
|
+
// Finally, delete the matched text
|
|
55
|
+
raise({
|
|
56
|
+
type: 'delete',
|
|
57
|
+
at: match.targetOffsets,
|
|
58
|
+
}),
|
|
59
|
+
],
|
|
60
|
+
],
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
export function MyMarkdownPlugin() {
|
|
64
|
+
return <InputRulePlugin rules={[unorderedListRule]} />
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
🤫 [`@portabletext/plugin-markdown-shortcuts`](../plugin-markdown-shortcuts/) already exists and is powered by Input Rules.
|
|
69
|
+
|
|
70
|
+
## Text Transformation Rules
|
|
71
|
+
|
|
72
|
+
Because text transformations are so common, the plugin exposes a high-level `defineTextTransformRule` to configure these without the need for any boilerplate:
|
|
73
|
+
|
|
74
|
+
```tsx
|
|
75
|
+
const emDashRule = defineTextTransformRule({
|
|
76
|
+
on: /--/,
|
|
77
|
+
transform: () => '—',
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
export function MyTypographyPlugin() {
|
|
81
|
+
return <InputRulePlugin rules={[emDasRule]} />
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
In fact, the production-ready [`@portabletext/plugin-typography`](../plugin-typography/) is built on top of Input Rules and comes packed with common text transformations like this.
|
|
86
|
+
|
|
87
|
+
## Other Examples
|
|
88
|
+
|
|
89
|
+
Other ideas that can easily be realized with Input Rules:
|
|
90
|
+
|
|
91
|
+
1. Automatically turning `"[Sanity](https://sanity.io)" into a link.
|
|
92
|
+
2. Listening for text patterns like `"{AAPL}"` and turn that into a stock ticker.
|
|
93
|
+
|
|
94
|
+
### Markdown Link
|
|
95
|
+
|
|
96
|
+
```tsx
|
|
97
|
+
const markdownLinkRule = defineInputRule({
|
|
98
|
+
on: /\[(.+)]\((.+)\)/,
|
|
99
|
+
actions: [
|
|
100
|
+
({snapshot, event}) => {
|
|
101
|
+
const newText = event.textBefore + event.textInserted
|
|
102
|
+
let textLengthDelta = 0
|
|
103
|
+
const actions: Array<BehaviorAction> = []
|
|
104
|
+
|
|
105
|
+
for (const match of event.matches.reverse()) {
|
|
106
|
+
const textMatch = match.groupMatches.at(0)
|
|
107
|
+
const hrefMatch = match.groupMatches.at(1)
|
|
108
|
+
|
|
109
|
+
if (textMatch === undefined || hrefMatch === undefined) {
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
textLengthDelta =
|
|
114
|
+
textLengthDelta -
|
|
115
|
+
(match.targetOffsets.focus.offset -
|
|
116
|
+
match.targetOffsets.anchor.offset -
|
|
117
|
+
textMatch.text.length)
|
|
118
|
+
|
|
119
|
+
const leftSideOffsets = {
|
|
120
|
+
anchor: match.targetOffsets.anchor,
|
|
121
|
+
focus: textMatch.targetOffsets.anchor,
|
|
122
|
+
}
|
|
123
|
+
const rightSideOffsets = {
|
|
124
|
+
anchor: textMatch.targetOffsets.focus,
|
|
125
|
+
focus: match.targetOffsets.focus,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
actions.push(
|
|
129
|
+
raise({
|
|
130
|
+
type: 'select',
|
|
131
|
+
at: textMatch.targetOffsets,
|
|
132
|
+
}),
|
|
133
|
+
)
|
|
134
|
+
actions.push(
|
|
135
|
+
raise({
|
|
136
|
+
type: 'annotation.add',
|
|
137
|
+
annotation: {
|
|
138
|
+
name: 'link',
|
|
139
|
+
value: {
|
|
140
|
+
href: hrefMatch.text,
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
)
|
|
145
|
+
actions.push(
|
|
146
|
+
raise({
|
|
147
|
+
type: 'delete',
|
|
148
|
+
at: rightSideOffsets,
|
|
149
|
+
}),
|
|
150
|
+
)
|
|
151
|
+
actions.push(
|
|
152
|
+
raise({
|
|
153
|
+
type: 'delete',
|
|
154
|
+
at: leftSideOffsets,
|
|
155
|
+
}),
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const endCaretPosition = {
|
|
160
|
+
path: event.focusTextBlock.path,
|
|
161
|
+
offset: newText.length - textLengthDelta * -1,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return [
|
|
165
|
+
...actions,
|
|
166
|
+
raise({
|
|
167
|
+
type: 'select',
|
|
168
|
+
at: {
|
|
169
|
+
anchor: endCaretPosition,
|
|
170
|
+
focus: endCaretPosition,
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
]
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
})
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Stock Ticker Rule
|
|
180
|
+
|
|
181
|
+
```tsx
|
|
182
|
+
const stockTickerRule = defineInputRule({
|
|
183
|
+
on: /\{(.+)\}/,
|
|
184
|
+
guard: ({snapshot, event}) => {
|
|
185
|
+
const match = event.matches.at(0)
|
|
186
|
+
|
|
187
|
+
if (!match) {
|
|
188
|
+
return false
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const symbolMatch = match.groupMatches.at(0)
|
|
192
|
+
|
|
193
|
+
if (symbolMatch === undefined) {
|
|
194
|
+
return false
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {match, symbolMatch}
|
|
198
|
+
},
|
|
199
|
+
actions: [
|
|
200
|
+
({snapshot, event}, {match, symbolMatch}) => {
|
|
201
|
+
const stockTickerKey = snapshot.context.keyGenerator()
|
|
202
|
+
|
|
203
|
+
return [
|
|
204
|
+
raise({
|
|
205
|
+
type: 'delete',
|
|
206
|
+
at: match.targetOffsets,
|
|
207
|
+
}),
|
|
208
|
+
raise({
|
|
209
|
+
type: 'insert.child',
|
|
210
|
+
child: {
|
|
211
|
+
_key: stockTickerKey,
|
|
212
|
+
_type: 'stock-ticker',
|
|
213
|
+
symbol: symbolMatch.text,
|
|
214
|
+
},
|
|
215
|
+
}),
|
|
216
|
+
raise({
|
|
217
|
+
type: 'select',
|
|
218
|
+
at: {
|
|
219
|
+
anchor: {
|
|
220
|
+
path: [
|
|
221
|
+
{_key: event.focusTextBlock.node._key},
|
|
222
|
+
'children',
|
|
223
|
+
{_key: stockTickerKey},
|
|
224
|
+
],
|
|
225
|
+
offset: 0,
|
|
226
|
+
},
|
|
227
|
+
focus: {
|
|
228
|
+
path: [
|
|
229
|
+
{_key: event.focusTextBlock.node._key},
|
|
230
|
+
'children',
|
|
231
|
+
{_key: stockTickerKey},
|
|
232
|
+
],
|
|
233
|
+
offset: 0,
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
}),
|
|
237
|
+
]
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
})
|
|
241
|
+
```
|
package/package.json
CHANGED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Feature: Stock Ticker Rule
|
|
2
|
+
|
|
3
|
+
Background:
|
|
4
|
+
Given the editor is focused
|
|
5
|
+
And a global keymap
|
|
6
|
+
|
|
7
|
+
Scenario Outline: Transforms plain text into stock ticker
|
|
8
|
+
Given the text <text>
|
|
9
|
+
When <inserted text> is inserted
|
|
10
|
+
And "{ArrowRight}" is pressed
|
|
11
|
+
And "new" is typed
|
|
12
|
+
Then the text is <new text>
|
|
13
|
+
|
|
14
|
+
Examples:
|
|
15
|
+
| text | inserted text | new text |
|
|
16
|
+
| "" | "{AAPL}" | ",{stock-ticker},new" |
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {parameterTypes} from '@portabletext/editor/test'
|
|
2
|
+
import {
|
|
3
|
+
createTestEditor,
|
|
4
|
+
stepDefinitions,
|
|
5
|
+
type Context,
|
|
6
|
+
} from '@portabletext/editor/test/vitest'
|
|
7
|
+
import {defineSchema} from '@portabletext/schema'
|
|
8
|
+
import {Before} from 'racejar'
|
|
9
|
+
import {Feature} from 'racejar/vitest'
|
|
10
|
+
import {InputRulePlugin} from './plugin.input-rule'
|
|
11
|
+
import {createStockTickerRule} from './rule.stock-ticker'
|
|
12
|
+
import stockTickerFeature from './rule.stock-ticker.feature?raw'
|
|
13
|
+
|
|
14
|
+
const stockTickerRule = createStockTickerRule({
|
|
15
|
+
stockTickerObject: (context) => ({
|
|
16
|
+
name: 'stock-ticker',
|
|
17
|
+
value: {
|
|
18
|
+
symbol: context.symbol,
|
|
19
|
+
},
|
|
20
|
+
}),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
Feature({
|
|
24
|
+
hooks: [
|
|
25
|
+
Before(async (context: Context) => {
|
|
26
|
+
const {editor, locator} = await createTestEditor({
|
|
27
|
+
children: (
|
|
28
|
+
<>
|
|
29
|
+
<InputRulePlugin rules={[stockTickerRule]} />
|
|
30
|
+
</>
|
|
31
|
+
),
|
|
32
|
+
schemaDefinition: defineSchema({
|
|
33
|
+
decorators: [{name: 'strong'}, {name: 'em'}],
|
|
34
|
+
annotations: [{name: 'link'}, {name: 'comment'}],
|
|
35
|
+
inlineObjects: [{name: 'stock-ticker'}],
|
|
36
|
+
}),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
context.locator = locator
|
|
40
|
+
context.editor = editor
|
|
41
|
+
}),
|
|
42
|
+
],
|
|
43
|
+
featureText: stockTickerFeature,
|
|
44
|
+
stepDefinitions,
|
|
45
|
+
parameterTypes,
|
|
46
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type {EditorSchema} from '@portabletext/editor'
|
|
2
|
+
import {raise} from '@portabletext/editor/behaviors'
|
|
3
|
+
import {defineInputRule} from './input-rule'
|
|
4
|
+
|
|
5
|
+
export function createStockTickerRule(config: {
|
|
6
|
+
stockTickerObject: (context: {
|
|
7
|
+
schema: EditorSchema
|
|
8
|
+
symbol: string
|
|
9
|
+
}) => {name: string; value?: {[prop: string]: unknown}} | undefined
|
|
10
|
+
}) {
|
|
11
|
+
return defineInputRule({
|
|
12
|
+
on: /\{(.+)\}/,
|
|
13
|
+
guard: ({snapshot, event}) => {
|
|
14
|
+
const match = event.matches.at(0)
|
|
15
|
+
|
|
16
|
+
if (!match) {
|
|
17
|
+
return false
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const symbolMatch = match.groupMatches.at(0)
|
|
21
|
+
|
|
22
|
+
if (symbolMatch === undefined) {
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const stockTickerObject = config.stockTickerObject({
|
|
27
|
+
schema: snapshot.context.schema,
|
|
28
|
+
symbol: symbolMatch.text,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
if (!stockTickerObject) {
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {match, stockTickerObject}
|
|
36
|
+
},
|
|
37
|
+
actions: [
|
|
38
|
+
({snapshot, event}, {match, stockTickerObject}) => {
|
|
39
|
+
const stockTickerKey = snapshot.context.keyGenerator()
|
|
40
|
+
|
|
41
|
+
return [
|
|
42
|
+
raise({
|
|
43
|
+
type: 'delete',
|
|
44
|
+
at: match.targetOffsets,
|
|
45
|
+
}),
|
|
46
|
+
raise({
|
|
47
|
+
type: 'insert.child',
|
|
48
|
+
child: {
|
|
49
|
+
...stockTickerObject.value,
|
|
50
|
+
_key: stockTickerKey,
|
|
51
|
+
_type: stockTickerObject.name,
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
raise({
|
|
55
|
+
type: 'select',
|
|
56
|
+
at: {
|
|
57
|
+
anchor: {
|
|
58
|
+
path: [
|
|
59
|
+
{_key: event.focusTextBlock.node._key},
|
|
60
|
+
'children',
|
|
61
|
+
{_key: stockTickerKey},
|
|
62
|
+
],
|
|
63
|
+
offset: 0,
|
|
64
|
+
},
|
|
65
|
+
focus: {
|
|
66
|
+
path: [
|
|
67
|
+
{_key: event.focusTextBlock.node._key},
|
|
68
|
+
'children',
|
|
69
|
+
{_key: stockTickerKey},
|
|
70
|
+
],
|
|
71
|
+
offset: 0,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
}),
|
|
75
|
+
]
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
})
|
|
79
|
+
}
|