@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 CHANGED
@@ -1,5 +1,241 @@
1
1
  # `@portabletext/plugin-input-rule`
2
2
 
3
- > Easily configure input rules in the Portable Text Editor
3
+ > Easily configure Input Rules in the Portable Text Editor
4
4
 
5
- This plugin is currently a work in progress. Docs to come with the APIs have settled.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@portabletext/plugin-input-rule",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Easily configure input rules in the Portable Text Editor",
5
5
  "keywords": [
6
6
  "portabletext",
@@ -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
+ }