@portabletext/plugin-input-rule 1.0.22 → 1.0.24
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/package.json +9 -10
- package/src/edge-cases.feature +0 -207
- package/src/edge-cases.test.tsx +0 -96
- package/src/emoji-picker-rules.feature +0 -27
- package/src/emoji-picker-rules.test.tsx +0 -124
- package/src/global.d.ts +0 -4
- package/src/index.ts +0 -3
- package/src/input-rule-match-location.ts +0 -123
- package/src/input-rule.ts +0 -66
- package/src/plugin.input-rule.tsx +0 -433
- package/src/rule.stock-ticker.feature +0 -12
- package/src/rule.stock-ticker.test.tsx +0 -46
- package/src/rule.stock-ticker.ts +0 -79
- package/src/text-transform-rule.ts +0 -105
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@portabletext/plugin-input-rule",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.24",
|
|
4
4
|
"description": "Easily configure input rules in the Portable Text Editor",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"portabletext",
|
|
@@ -30,12 +30,11 @@
|
|
|
30
30
|
"main": "./dist/index.js",
|
|
31
31
|
"types": "./dist/index.d.ts",
|
|
32
32
|
"files": [
|
|
33
|
-
"dist"
|
|
34
|
-
"src"
|
|
33
|
+
"dist"
|
|
35
34
|
],
|
|
36
35
|
"dependencies": {
|
|
37
36
|
"@xstate/react": "^6.0.0",
|
|
38
|
-
"react-compiler-runtime": "1.0.0",
|
|
37
|
+
"react-compiler-runtime": "^1.0.0",
|
|
39
38
|
"xstate": "^5.24.0"
|
|
40
39
|
},
|
|
41
40
|
"devDependencies": {
|
|
@@ -45,20 +44,20 @@
|
|
|
45
44
|
"@vitejs/plugin-react": "^5.0.4",
|
|
46
45
|
"@vitest/browser": "^4.0.14",
|
|
47
46
|
"@vitest/browser-playwright": "^4.0.14",
|
|
48
|
-
"babel-plugin-react-compiler": "1.0.0",
|
|
47
|
+
"babel-plugin-react-compiler": "^1.0.0",
|
|
49
48
|
"eslint": "^9.39.1",
|
|
50
49
|
"eslint-formatter-gha": "^1.6.0",
|
|
51
|
-
"eslint-plugin-react-hooks": "7.0.1",
|
|
50
|
+
"eslint-plugin-react-hooks": "^7.0.1",
|
|
52
51
|
"react": "^19.2.1",
|
|
53
52
|
"typescript": "5.9.3",
|
|
54
53
|
"typescript-eslint": "^8.48.0",
|
|
55
54
|
"vitest": "^4.0.14",
|
|
56
|
-
"@portabletext/editor": "3.3.
|
|
57
|
-
"@portabletext/schema": "2.0.
|
|
58
|
-
"racejar": "2.0.
|
|
55
|
+
"@portabletext/editor": "3.3.4",
|
|
56
|
+
"@portabletext/schema": "2.0.1",
|
|
57
|
+
"racejar": "2.0.1"
|
|
59
58
|
},
|
|
60
59
|
"peerDependencies": {
|
|
61
|
-
"@portabletext/editor": "^3.3.
|
|
60
|
+
"@portabletext/editor": "^3.3.4",
|
|
62
61
|
"react": "^18.3 || ^19"
|
|
63
62
|
},
|
|
64
63
|
"engines": {
|
package/src/edge-cases.feature
DELETED
|
@@ -1,207 +0,0 @@
|
|
|
1
|
-
Feature: Edge Cases
|
|
2
|
-
|
|
3
|
-
Background:
|
|
4
|
-
Given a global keymap
|
|
5
|
-
|
|
6
|
-
Scenario Outline: Longer Transform
|
|
7
|
-
Given the text <text>
|
|
8
|
-
When <inserted text> is inserted
|
|
9
|
-
And "new" is typed
|
|
10
|
-
Then the text is <new text>
|
|
11
|
-
|
|
12
|
-
Examples:
|
|
13
|
-
| text | inserted text | new text |
|
|
14
|
-
| "" | "." | "...new" |
|
|
15
|
-
| "foo" | "." | "foo...new" |
|
|
16
|
-
| "" | "foo." | "foo...new" |
|
|
17
|
-
| "foo." | "." | "foo....new" |
|
|
18
|
-
| "" | "foo.bar." | "foo...bar...new" |
|
|
19
|
-
| "foo.bar." | "baz." | "foo.bar.baz...new" |
|
|
20
|
-
|
|
21
|
-
Scenario Outline: End String Rule
|
|
22
|
-
Given the text <text>
|
|
23
|
-
When <inserted text> is inserted
|
|
24
|
-
And "new" is typed
|
|
25
|
-
Then the text is <new text>
|
|
26
|
-
|
|
27
|
-
Examples:
|
|
28
|
-
| text | inserted text | new text |
|
|
29
|
-
| "-" | ">" | "→new" |
|
|
30
|
-
| "" | "->" | "→new" |
|
|
31
|
-
| "foo" | "->" | "foo→new" |
|
|
32
|
-
| "" | "foo->" | "foo→new" |
|
|
33
|
-
| "foo-" | ">bar" | "foo->barnew" |
|
|
34
|
-
| "" | "foo->bar->" | "foo->bar→new" |
|
|
35
|
-
| "foo->bar->" | "baz->" | "foo->bar->baz→new" |
|
|
36
|
-
|
|
37
|
-
Scenario Outline: Non-Global Rule
|
|
38
|
-
Given the text <text>
|
|
39
|
-
When <inserted text> is inserted
|
|
40
|
-
And "new" is typed
|
|
41
|
-
Then the text is <new text>
|
|
42
|
-
|
|
43
|
-
Examples:
|
|
44
|
-
| text | inserted text | new text |
|
|
45
|
-
| "(c" | ")" | "©new" |
|
|
46
|
-
| "" | "(c)" | "©new" |
|
|
47
|
-
| "foo" | "(c)" | "foo©new" |
|
|
48
|
-
| "" | "foo(c)" | "foo©new" |
|
|
49
|
-
| "foo(c" | ")bar" | "foo©barnew" |
|
|
50
|
-
| "" | "foo(c)bar(c)" | "foo©bar©new" |
|
|
51
|
-
| "foo(c)bar(c)" | "baz(c)" | "foo(c)bar(c)baz©new" |
|
|
52
|
-
|
|
53
|
-
Scenario Outline: Writing after Multiple Groups Rule
|
|
54
|
-
Given the text <text>
|
|
55
|
-
When <inserted text> is inserted
|
|
56
|
-
And "new" is typed
|
|
57
|
-
Then the text is <new text>
|
|
58
|
-
|
|
59
|
-
Examples:
|
|
60
|
-
| text | inserted text | new text |
|
|
61
|
-
| "" | "xfooy" | "zfooznew" |
|
|
62
|
-
| "xfoo" | "y" | "zfooznew" |
|
|
63
|
-
| "xfooy" | "z" | "xfooyznew" |
|
|
64
|
-
| "" | "xfyxoy" | "zfzzoznew" |
|
|
65
|
-
| "" | "xfyxoyxoy" | "zfzzozzoznew" |
|
|
66
|
-
|
|
67
|
-
Scenario Outline: Replacing 'a' and 'c'
|
|
68
|
-
Given the text <text>
|
|
69
|
-
When <inserted text> is inserted
|
|
70
|
-
And "new" is typed
|
|
71
|
-
Then the text is <new text>
|
|
72
|
-
|
|
73
|
-
Examples:
|
|
74
|
-
| text | inserted text | new text |
|
|
75
|
-
| "" | "ABC" | "CBAnew" |
|
|
76
|
-
| "AB" | "C" | "CBAnew" |
|
|
77
|
-
|
|
78
|
-
Scenario Outline: Undoing Multiple Groups Rule
|
|
79
|
-
Given the text <text>
|
|
80
|
-
When <inserted text> is inserted
|
|
81
|
-
Then the text is <before undo>
|
|
82
|
-
When undo is performed
|
|
83
|
-
Then the text is <after undo>
|
|
84
|
-
|
|
85
|
-
Examples:
|
|
86
|
-
| text | inserted text | before undo | after undo |
|
|
87
|
-
| "" | "xfooy" | "zfooz" | "xfooy" |
|
|
88
|
-
| "xfoo" | "y" | "zfooz" | "xfooy" |
|
|
89
|
-
| "xfooy" | "z" | "xfooyz" | "xfooy" |
|
|
90
|
-
| "" | "xfyxoy" | "zfzzoz" | "xfyxoy" |
|
|
91
|
-
| "" | "xfyxoyxoy" | "zfzzozzoz" | "xfyxoyxoy" |
|
|
92
|
-
|
|
93
|
-
Scenario Outline: Preserving inline objects
|
|
94
|
-
Given the text <text>
|
|
95
|
-
When <inserted text> is inserted
|
|
96
|
-
And "new" is typed
|
|
97
|
-
Then the text is <new text>
|
|
98
|
-
|
|
99
|
-
Examples:
|
|
100
|
-
| text | inserted text | new text |
|
|
101
|
-
| "(,{stock-ticker},c" | ")" | "(,{stock-ticker},c)new" |
|
|
102
|
-
| "-,{stock-ticker}," | ">" | "-,{stock-ticker},>new" |
|
|
103
|
-
| ",{stock-ticker},-,{stock-ticker}," | ">" | ",{stock-ticker},-,{stock-ticker},>new" |
|
|
104
|
-
| ",{stock-ticker},-" | ">" | ",{stock-ticker},→new" |
|
|
105
|
-
|
|
106
|
-
Scenario: Preserving adjoining inline object and placing caret correctly
|
|
107
|
-
Given the text "(c,{stock-ticker},"
|
|
108
|
-
When the caret is put after "c"
|
|
109
|
-
And ")new" is typed
|
|
110
|
-
Then the text is "©new,{stock-ticker},"
|
|
111
|
-
|
|
112
|
-
Scenario: Preserving adjoining inline object and placing caret correctly
|
|
113
|
-
Given the text "#,{stock-ticker},"
|
|
114
|
-
When the caret is put after "#"
|
|
115
|
-
And " new" is typed
|
|
116
|
-
Then the text is "new,{stock-ticker},"
|
|
117
|
-
|
|
118
|
-
Scenario Outline: H1 rule
|
|
119
|
-
Given the text <text>
|
|
120
|
-
When the caret is put <position>
|
|
121
|
-
And <key> is pressed
|
|
122
|
-
And "# " is inserted
|
|
123
|
-
And "new" is typed
|
|
124
|
-
Then the text is <new text>
|
|
125
|
-
|
|
126
|
-
Examples:
|
|
127
|
-
| text | position | key | new text |
|
|
128
|
-
# Pressing Shift is a noop. It only exists so we can press Backspace in
|
|
129
|
-
# subsequent Scenarios to position to caret at the edge of the inline
|
|
130
|
-
# object.
|
|
131
|
-
| "" | after "" | "{Shift}" | "new" |
|
|
132
|
-
| "foo" | after "foo" | "{Shift}" | "foo# new" |
|
|
133
|
-
| ",{stock-ticker},foo" | after "foo" | "{Shift}" | ",{stock-ticker},foo# new" |
|
|
134
|
-
# This is an edge case we have to live with. There's no way of knowing
|
|
135
|
-
# that the inline object before the caret should prevent the rule from
|
|
136
|
-
# running.
|
|
137
|
-
| ",{stock-ticker},f" | after "f" | "{Backspace}" | "new,{stock-ticker}," |
|
|
138
|
-
| "f,{stock-ticker}," | after "f" | "{Backspace}" | "new,{stock-ticker}," |
|
|
139
|
-
|
|
140
|
-
Scenario Outline: Better H2 rule
|
|
141
|
-
Given the text <text>
|
|
142
|
-
When the caret is put <position>
|
|
143
|
-
And <key> is pressed
|
|
144
|
-
And "## " is inserted
|
|
145
|
-
And "new" is typed
|
|
146
|
-
Then the text is <new text>
|
|
147
|
-
|
|
148
|
-
Examples:
|
|
149
|
-
| text | position | key | new text |
|
|
150
|
-
# Pressing Shift is a noop. It only exists so we can press Backspace in
|
|
151
|
-
# subsequent Scenarios to position to caret at the edge of the inline
|
|
152
|
-
# object.
|
|
153
|
-
| "" | after "" | "{Shift}" | "new" |
|
|
154
|
-
| "foo" | after "foo" | "{Shift}" | "foo## new" |
|
|
155
|
-
| ",{stock-ticker},foo" | after "foo" | "{Shift}" | ",{stock-ticker},foo## new" |
|
|
156
|
-
| ",{stock-ticker},f" | after "f" | "{Backspace}" | ",{stock-ticker},## new" |
|
|
157
|
-
| "f,{stock-ticker}," | after "f" | "{Backspace}" | "new,{stock-ticker}," |
|
|
158
|
-
|
|
159
|
-
Scenario Outline: Unmatched Groups Rule
|
|
160
|
-
Given the text <text>
|
|
161
|
-
When the caret is put <position>
|
|
162
|
-
And <inserted text> is inserted
|
|
163
|
-
And "new" is typed
|
|
164
|
-
Then the text is <new text>
|
|
165
|
-
|
|
166
|
-
Examples:
|
|
167
|
-
| text | position | inserted text | new text |
|
|
168
|
-
| "" | after "" | "---" | "<hr />new" |
|
|
169
|
-
|
|
170
|
-
Scenario Outline: Expanded selection
|
|
171
|
-
Given the text <text>
|
|
172
|
-
When <selection> is selected
|
|
173
|
-
And <inserted text> is inserted
|
|
174
|
-
And "new" is typed
|
|
175
|
-
Then the text is <new text>
|
|
176
|
-
|
|
177
|
-
Examples:
|
|
178
|
-
| text | selection | inserted text | new text |
|
|
179
|
-
| "(foo" | "foo" | "c)" | "©new" |
|
|
180
|
-
| "(foo" | "oo" | "c)" | "(fc)new" |
|
|
181
|
-
| "(foo" | "(foo" | "c)" | "c)new" |
|
|
182
|
-
| "(coo\|bar" | "ooba" | ")" | "©newr" |
|
|
183
|
-
|
|
184
|
-
Scenario Outline: Undo after transform on expanded selection
|
|
185
|
-
Given the text <text>
|
|
186
|
-
When <selection> is selected
|
|
187
|
-
And <inserted text> is inserted
|
|
188
|
-
Then the text is <before undo>
|
|
189
|
-
When undo is performed
|
|
190
|
-
Then the text is <after undo>
|
|
191
|
-
|
|
192
|
-
Examples:
|
|
193
|
-
| text | selection | inserted text | before undo | after undo |
|
|
194
|
-
| "(cf" | "f" | ")" | "©" | "(c)" |
|
|
195
|
-
|
|
196
|
-
Scenario: Consecutive undo after selection change
|
|
197
|
-
Given the text ""
|
|
198
|
-
When "->" is typed
|
|
199
|
-
And undo is performed
|
|
200
|
-
And "{ArrowLeft}" is pressed
|
|
201
|
-
And undo is performed
|
|
202
|
-
Then the text is "-"
|
|
203
|
-
|
|
204
|
-
Scenario Outline: Multiple overlapping matches in one insertion
|
|
205
|
-
Given the text ""
|
|
206
|
-
When "1*2*3" is inserted
|
|
207
|
-
Then the text is "1×2×3"
|
package/src/edge-cases.test.tsx
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import {getPreviousInlineObject} from '@portabletext/editor/selectors'
|
|
2
|
-
import {parameterTypes} from '@portabletext/editor/test'
|
|
3
|
-
import {
|
|
4
|
-
createTestEditor,
|
|
5
|
-
stepDefinitions,
|
|
6
|
-
type Context,
|
|
7
|
-
} from '@portabletext/editor/test/vitest'
|
|
8
|
-
import {defineSchema} from '@portabletext/schema'
|
|
9
|
-
import {Before} from 'racejar'
|
|
10
|
-
import {Feature} from 'racejar/vitest'
|
|
11
|
-
import edgeCasesFeature from './edge-cases.feature?raw'
|
|
12
|
-
import {InputRulePlugin} from './plugin.input-rule'
|
|
13
|
-
import {defineTextTransformRule} from './text-transform-rule'
|
|
14
|
-
|
|
15
|
-
const longerTransformRule = defineTextTransformRule({
|
|
16
|
-
on: /\./,
|
|
17
|
-
transform: () => '...',
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
const endStringRule = defineTextTransformRule({
|
|
21
|
-
on: /->$/,
|
|
22
|
-
transform: () => '→',
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
const nonGlobalRule = defineTextTransformRule({
|
|
26
|
-
on: /\(c\)/,
|
|
27
|
-
transform: () => '©',
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
const multipleGroupsRule = defineTextTransformRule({
|
|
31
|
-
on: /(x)[fo]+(y)/,
|
|
32
|
-
transform: () => 'z',
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
const replaceAandCRule = defineTextTransformRule({
|
|
36
|
-
on: /(A).*(C)/,
|
|
37
|
-
transform: ({location}) => {
|
|
38
|
-
return location.text === 'A' ? 'C' : 'A'
|
|
39
|
-
},
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
const h1Rule = defineTextTransformRule({
|
|
43
|
-
on: /^(# )/,
|
|
44
|
-
transform: () => '',
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
const betterH2Rule = defineTextTransformRule({
|
|
48
|
-
on: /^(## )/,
|
|
49
|
-
guard: ({snapshot}) => {
|
|
50
|
-
return !getPreviousInlineObject(snapshot)
|
|
51
|
-
},
|
|
52
|
-
transform: () => '',
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
const unmatchedGroupsRule = defineTextTransformRule({
|
|
56
|
-
on: /^(---)|^(—-)|^(___)|^(\*\*\*)/,
|
|
57
|
-
transform: () => '<hr />',
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
const multiplicationRule = defineTextTransformRule({
|
|
61
|
-
on: /\d+\s?([*x])\s?\d+/,
|
|
62
|
-
transform: () => '×',
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
Feature({
|
|
66
|
-
hooks: [
|
|
67
|
-
Before(async (context: Context) => {
|
|
68
|
-
const {editor, locator} = await createTestEditor({
|
|
69
|
-
children: (
|
|
70
|
-
<>
|
|
71
|
-
<InputRulePlugin rules={[longerTransformRule]} />
|
|
72
|
-
<InputRulePlugin rules={[endStringRule]} />
|
|
73
|
-
<InputRulePlugin rules={[nonGlobalRule]} />
|
|
74
|
-
<InputRulePlugin rules={[multipleGroupsRule]} />
|
|
75
|
-
<InputRulePlugin rules={[h1Rule]} />
|
|
76
|
-
<InputRulePlugin rules={[betterH2Rule]} />
|
|
77
|
-
<InputRulePlugin rules={[replaceAandCRule]} />
|
|
78
|
-
<InputRulePlugin rules={[unmatchedGroupsRule]} />
|
|
79
|
-
<InputRulePlugin rules={[multiplicationRule]} />
|
|
80
|
-
</>
|
|
81
|
-
),
|
|
82
|
-
schemaDefinition: defineSchema({
|
|
83
|
-
decorators: [{name: 'strong'}],
|
|
84
|
-
annotations: [{name: 'link'}],
|
|
85
|
-
inlineObjects: [{name: 'stock-ticker'}],
|
|
86
|
-
}),
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
context.locator = locator
|
|
90
|
-
context.editor = editor
|
|
91
|
-
}),
|
|
92
|
-
],
|
|
93
|
-
featureText: edgeCasesFeature,
|
|
94
|
-
stepDefinitions,
|
|
95
|
-
parameterTypes,
|
|
96
|
-
})
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
Feature: Emoji Picker Rules
|
|
2
|
-
|
|
3
|
-
Scenario: Trigger Rule
|
|
4
|
-
Given the text ""
|
|
5
|
-
When ":" is typed
|
|
6
|
-
Then the keyword is ""
|
|
7
|
-
|
|
8
|
-
Scenario: Partial Keyword Rule
|
|
9
|
-
Given the text ""
|
|
10
|
-
When ":jo" is typed
|
|
11
|
-
Then the keyword is "jo"
|
|
12
|
-
|
|
13
|
-
Scenario: Keyword Rule
|
|
14
|
-
Given the text ""
|
|
15
|
-
When ":joy:" is typed
|
|
16
|
-
Then the keyword is "joy"
|
|
17
|
-
|
|
18
|
-
Scenario Outline: Consecutive keywords
|
|
19
|
-
Given the text ":joy:"
|
|
20
|
-
When <text> is typed
|
|
21
|
-
Then the keyword is <keyword>
|
|
22
|
-
|
|
23
|
-
Examples:
|
|
24
|
-
| text | keyword |
|
|
25
|
-
| ":" | "" |
|
|
26
|
-
| ":cat" | "cat" |
|
|
27
|
-
| ":cat:" | "cat" |
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
import {useEditor} from '@portabletext/editor'
|
|
2
|
-
import {defineBehavior, effect, raise} from '@portabletext/editor/behaviors'
|
|
3
|
-
import {parameterTypes} from '@portabletext/editor/test'
|
|
4
|
-
import {
|
|
5
|
-
createTestEditor,
|
|
6
|
-
stepDefinitions,
|
|
7
|
-
type Context,
|
|
8
|
-
} from '@portabletext/editor/test/vitest'
|
|
9
|
-
import {defineSchema} from '@portabletext/schema'
|
|
10
|
-
import {Before, Then} from 'racejar'
|
|
11
|
-
import {Feature} from 'racejar/vitest'
|
|
12
|
-
import {useEffect, useState} from 'react'
|
|
13
|
-
import {expect, vi} from 'vitest'
|
|
14
|
-
import {page, type Locator} from 'vitest/browser'
|
|
15
|
-
import emojiPickerRulesFeature from './emoji-picker-rules.feature?raw'
|
|
16
|
-
import {defineInputRule} from './input-rule'
|
|
17
|
-
import {defineInputRuleBehavior} from './plugin.input-rule'
|
|
18
|
-
|
|
19
|
-
const triggerRule = defineInputRule({
|
|
20
|
-
on: /:/,
|
|
21
|
-
actions: [
|
|
22
|
-
() => [
|
|
23
|
-
raise({
|
|
24
|
-
type: 'custom.keyword found',
|
|
25
|
-
keyword: '',
|
|
26
|
-
}),
|
|
27
|
-
],
|
|
28
|
-
],
|
|
29
|
-
})
|
|
30
|
-
const partialKeywordRule = defineInputRule({
|
|
31
|
-
on: /:[a-zA-Z-_0-9]+/,
|
|
32
|
-
guard: ({event}) => {
|
|
33
|
-
const lastMatch = event.matches.at(-1)
|
|
34
|
-
|
|
35
|
-
if (!lastMatch) {
|
|
36
|
-
return false
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
return {keyword: lastMatch.text.slice(1)}
|
|
40
|
-
},
|
|
41
|
-
actions: [(_, {keyword}) => [raise({type: 'custom.keyword found', keyword})]],
|
|
42
|
-
})
|
|
43
|
-
const keywordRule = defineInputRule({
|
|
44
|
-
on: /:[a-zA-Z-_0-9]+:/,
|
|
45
|
-
guard: ({event}) => {
|
|
46
|
-
const lastMatch = event.matches.at(-1)
|
|
47
|
-
|
|
48
|
-
if (!lastMatch) {
|
|
49
|
-
return false
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
return {keyword: lastMatch.text.slice(1, -1)}
|
|
53
|
-
},
|
|
54
|
-
actions: [(_, {keyword}) => [raise({type: 'custom.keyword found', keyword})]],
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
function EmojiPickerRulesPlugin() {
|
|
58
|
-
const editor = useEditor()
|
|
59
|
-
const [keyword, setKeyword] = useState('')
|
|
60
|
-
|
|
61
|
-
useEffect(() => {
|
|
62
|
-
const unregisterBehaviors = [
|
|
63
|
-
editor.registerBehavior({
|
|
64
|
-
behavior: defineInputRuleBehavior({
|
|
65
|
-
rules: [keywordRule, partialKeywordRule, triggerRule],
|
|
66
|
-
}),
|
|
67
|
-
}),
|
|
68
|
-
editor.registerBehavior({
|
|
69
|
-
behavior: defineBehavior<{keyword: string}>({
|
|
70
|
-
on: 'custom.keyword found',
|
|
71
|
-
actions: [
|
|
72
|
-
({event}) => [
|
|
73
|
-
effect(() => {
|
|
74
|
-
setKeyword(event.keyword)
|
|
75
|
-
}),
|
|
76
|
-
],
|
|
77
|
-
],
|
|
78
|
-
}),
|
|
79
|
-
}),
|
|
80
|
-
]
|
|
81
|
-
|
|
82
|
-
return () => {
|
|
83
|
-
for (const unregister of unregisterBehaviors) {
|
|
84
|
-
unregister()
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}, [])
|
|
88
|
-
|
|
89
|
-
return <div data-testid="keyword">{keyword}</div>
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
Feature({
|
|
93
|
-
hooks: [
|
|
94
|
-
Before(async (context: Context & {keywordLocator: Locator}) => {
|
|
95
|
-
const {editor, locator} = await createTestEditor({
|
|
96
|
-
children: <EmojiPickerRulesPlugin />,
|
|
97
|
-
schemaDefinition: defineSchema({
|
|
98
|
-
decorators: [{name: 'strong'}],
|
|
99
|
-
annotations: [{name: 'link'}],
|
|
100
|
-
inlineObjects: [{name: 'stock-ticker'}],
|
|
101
|
-
}),
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
context.locator = locator
|
|
105
|
-
context.editor = editor
|
|
106
|
-
context.keywordLocator = page.getByTestId('keyword')
|
|
107
|
-
|
|
108
|
-
await vi.waitFor(() =>
|
|
109
|
-
expect.element(context.keywordLocator).toBeInTheDocument(),
|
|
110
|
-
)
|
|
111
|
-
}),
|
|
112
|
-
],
|
|
113
|
-
featureText: emojiPickerRulesFeature,
|
|
114
|
-
stepDefinitions: [
|
|
115
|
-
...stepDefinitions,
|
|
116
|
-
Then(
|
|
117
|
-
'the keyword is {string}',
|
|
118
|
-
(context: Context & {keywordLocator: Locator}, keyword: string) => {
|
|
119
|
-
expect(context.keywordLocator.element().textContent).toEqual(keyword)
|
|
120
|
-
},
|
|
121
|
-
),
|
|
122
|
-
],
|
|
123
|
-
parameterTypes,
|
|
124
|
-
})
|
package/src/global.d.ts
DELETED
package/src/index.ts
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
BlockOffset,
|
|
3
|
-
BlockPath,
|
|
4
|
-
EditorSelection,
|
|
5
|
-
EditorSnapshot,
|
|
6
|
-
} from '@portabletext/editor'
|
|
7
|
-
import {
|
|
8
|
-
getNextInlineObjects,
|
|
9
|
-
getPreviousInlineObjects,
|
|
10
|
-
} from '@portabletext/editor/selectors'
|
|
11
|
-
import {blockOffsetToSpanSelectionPoint} from '@portabletext/editor/utils'
|
|
12
|
-
|
|
13
|
-
export type InputRuleMatchLocation = {
|
|
14
|
-
/**
|
|
15
|
-
* The matched text
|
|
16
|
-
*/
|
|
17
|
-
text: string
|
|
18
|
-
/**
|
|
19
|
-
* Estimated selection of where in the original text the match is located.
|
|
20
|
-
* The selection is estimated since the match is found in the text after
|
|
21
|
-
* insertion.
|
|
22
|
-
*/
|
|
23
|
-
selection: NonNullable<EditorSelection>
|
|
24
|
-
/**
|
|
25
|
-
* Block offsets of the match in the text after the insertion
|
|
26
|
-
*/
|
|
27
|
-
targetOffsets: {
|
|
28
|
-
anchor: BlockOffset
|
|
29
|
-
focus: BlockOffset
|
|
30
|
-
backward: boolean
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function getInputRuleMatchLocation({
|
|
35
|
-
match,
|
|
36
|
-
adjustIndexBy,
|
|
37
|
-
snapshot,
|
|
38
|
-
focusBlock,
|
|
39
|
-
originalTextBefore,
|
|
40
|
-
}: {
|
|
41
|
-
match: [string, number, number]
|
|
42
|
-
adjustIndexBy: number
|
|
43
|
-
snapshot: EditorSnapshot
|
|
44
|
-
focusBlock: {
|
|
45
|
-
path: BlockPath
|
|
46
|
-
}
|
|
47
|
-
originalTextBefore: string
|
|
48
|
-
}): InputRuleMatchLocation | undefined {
|
|
49
|
-
const [text, start, end] = match
|
|
50
|
-
const adjustedIndex = start + adjustIndexBy
|
|
51
|
-
|
|
52
|
-
const targetOffsets = {
|
|
53
|
-
anchor: {
|
|
54
|
-
path: focusBlock.path,
|
|
55
|
-
offset: adjustedIndex,
|
|
56
|
-
},
|
|
57
|
-
focus: {
|
|
58
|
-
path: focusBlock.path,
|
|
59
|
-
offset: adjustedIndex + end - start,
|
|
60
|
-
},
|
|
61
|
-
backward: false,
|
|
62
|
-
}
|
|
63
|
-
const normalizedOffsets = {
|
|
64
|
-
anchor: {
|
|
65
|
-
path: focusBlock.path,
|
|
66
|
-
offset: Math.min(targetOffsets.anchor.offset, originalTextBefore.length),
|
|
67
|
-
},
|
|
68
|
-
focus: {
|
|
69
|
-
path: focusBlock.path,
|
|
70
|
-
offset: Math.min(targetOffsets.focus.offset, originalTextBefore.length),
|
|
71
|
-
},
|
|
72
|
-
backward: false,
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const anchorBackwards = blockOffsetToSpanSelectionPoint({
|
|
76
|
-
context: snapshot.context,
|
|
77
|
-
blockOffset: normalizedOffsets.anchor,
|
|
78
|
-
direction: 'backward',
|
|
79
|
-
})
|
|
80
|
-
const focusForwards = blockOffsetToSpanSelectionPoint({
|
|
81
|
-
context: snapshot.context,
|
|
82
|
-
blockOffset: normalizedOffsets.focus,
|
|
83
|
-
direction: 'forward',
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
if (!anchorBackwards || !focusForwards) {
|
|
87
|
-
return undefined
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const selection = {
|
|
91
|
-
anchor: anchorBackwards,
|
|
92
|
-
focus: focusForwards,
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const inlineObjectsAfterMatch = getNextInlineObjects({
|
|
96
|
-
...snapshot,
|
|
97
|
-
context: {
|
|
98
|
-
...snapshot.context,
|
|
99
|
-
selection: {
|
|
100
|
-
anchor: selection.anchor,
|
|
101
|
-
focus: selection.anchor,
|
|
102
|
-
},
|
|
103
|
-
},
|
|
104
|
-
})
|
|
105
|
-
const inlineObjectsBefore = getPreviousInlineObjects(snapshot)
|
|
106
|
-
|
|
107
|
-
if (
|
|
108
|
-
inlineObjectsAfterMatch.some((inlineObjectAfter) =>
|
|
109
|
-
inlineObjectsBefore.some(
|
|
110
|
-
(inlineObjectBefore) =>
|
|
111
|
-
inlineObjectAfter.node._key === inlineObjectBefore.node._key,
|
|
112
|
-
),
|
|
113
|
-
)
|
|
114
|
-
) {
|
|
115
|
-
return undefined
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
return {
|
|
119
|
-
text,
|
|
120
|
-
selection,
|
|
121
|
-
targetOffsets,
|
|
122
|
-
}
|
|
123
|
-
}
|
package/src/input-rule.ts
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import type {BlockPath, PortableTextBlock} from '@portabletext/editor'
|
|
2
|
-
import type {
|
|
3
|
-
BehaviorActionSet,
|
|
4
|
-
BehaviorGuard,
|
|
5
|
-
} from '@portabletext/editor/behaviors'
|
|
6
|
-
import type {InputRuleMatchLocation} from './input-rule-match-location'
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Match found in the text after the insertion
|
|
10
|
-
* @alpha
|
|
11
|
-
*/
|
|
12
|
-
export type InputRuleMatch = InputRuleMatchLocation & {
|
|
13
|
-
groupMatches: Array<InputRuleMatchLocation>
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* @alpha
|
|
18
|
-
*/
|
|
19
|
-
export type InputRuleEvent = {
|
|
20
|
-
type: 'custom.input rule'
|
|
21
|
-
/**
|
|
22
|
-
* Matches found by the input rule
|
|
23
|
-
*/
|
|
24
|
-
matches: Array<InputRuleMatch>
|
|
25
|
-
/**
|
|
26
|
-
* The text before the insertion
|
|
27
|
-
*/
|
|
28
|
-
textBefore: string
|
|
29
|
-
/**
|
|
30
|
-
* The text is destined to be inserted
|
|
31
|
-
*/
|
|
32
|
-
textInserted: string
|
|
33
|
-
/**
|
|
34
|
-
* The block where the insertion takes place
|
|
35
|
-
*/
|
|
36
|
-
focusBlock: {
|
|
37
|
-
path: BlockPath
|
|
38
|
-
node: PortableTextBlock
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* @alpha
|
|
44
|
-
*/
|
|
45
|
-
export type InputRuleGuard<TGuardResponse = true> = BehaviorGuard<
|
|
46
|
-
InputRuleEvent,
|
|
47
|
-
TGuardResponse
|
|
48
|
-
>
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* @alpha
|
|
52
|
-
*/
|
|
53
|
-
export type InputRule<TGuardResponse = true> = {
|
|
54
|
-
on: RegExp
|
|
55
|
-
guard?: InputRuleGuard<TGuardResponse>
|
|
56
|
-
actions: Array<BehaviorActionSet<InputRuleEvent, TGuardResponse>>
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* @alpha
|
|
61
|
-
*/
|
|
62
|
-
export function defineInputRule<TGuardResponse = true>(
|
|
63
|
-
config: InputRule<TGuardResponse>,
|
|
64
|
-
): InputRule<TGuardResponse> {
|
|
65
|
-
return config
|
|
66
|
-
}
|
|
@@ -1,433 +0,0 @@
|
|
|
1
|
-
import {useEditor, type BlockOffset, type Editor} from '@portabletext/editor'
|
|
2
|
-
import {
|
|
3
|
-
defineBehavior,
|
|
4
|
-
effect,
|
|
5
|
-
forward,
|
|
6
|
-
raise,
|
|
7
|
-
type BehaviorAction,
|
|
8
|
-
} from '@portabletext/editor/behaviors'
|
|
9
|
-
import {
|
|
10
|
-
getBlockOffsets,
|
|
11
|
-
getBlockTextBefore,
|
|
12
|
-
getFocusBlock,
|
|
13
|
-
} from '@portabletext/editor/selectors'
|
|
14
|
-
import {isSelectionCollapsed} from '@portabletext/editor/utils'
|
|
15
|
-
import {useActorRef} from '@xstate/react'
|
|
16
|
-
import {
|
|
17
|
-
fromCallback,
|
|
18
|
-
setup,
|
|
19
|
-
type AnyEventObject,
|
|
20
|
-
type CallbackLogicFunction,
|
|
21
|
-
} from 'xstate'
|
|
22
|
-
import type {InputRule, InputRuleMatch} from './input-rule'
|
|
23
|
-
import {getInputRuleMatchLocation} from './input-rule-match-location'
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* @alpha
|
|
27
|
-
*/
|
|
28
|
-
export function defineInputRuleBehavior(config: {
|
|
29
|
-
rules: Array<InputRule<any>>
|
|
30
|
-
onApply?: ({
|
|
31
|
-
endOffsets,
|
|
32
|
-
}: {
|
|
33
|
-
endOffsets: {start: BlockOffset; end: BlockOffset} | undefined
|
|
34
|
-
}) => void
|
|
35
|
-
}) {
|
|
36
|
-
return defineBehavior({
|
|
37
|
-
on: 'insert.text',
|
|
38
|
-
guard: ({snapshot, event, dom}) => {
|
|
39
|
-
if (
|
|
40
|
-
!snapshot.context.selection ||
|
|
41
|
-
!isSelectionCollapsed(snapshot.context.selection)
|
|
42
|
-
) {
|
|
43
|
-
return false
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const focusBlock = getFocusBlock(snapshot)
|
|
47
|
-
|
|
48
|
-
if (!focusBlock) {
|
|
49
|
-
return false
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const originalTextBefore = getBlockTextBefore(snapshot)
|
|
53
|
-
let textBefore = originalTextBefore
|
|
54
|
-
const originalNewText = textBefore + event.text
|
|
55
|
-
let newText = originalNewText
|
|
56
|
-
|
|
57
|
-
const foundMatches: Array<InputRuleMatch['groupMatches'][number]> = []
|
|
58
|
-
const foundActions: Array<BehaviorAction> = []
|
|
59
|
-
|
|
60
|
-
for (const rule of config.rules) {
|
|
61
|
-
const matcher = new RegExp(rule.on.source, 'gd')
|
|
62
|
-
|
|
63
|
-
while (true) {
|
|
64
|
-
// Find matches in the text after the insertion
|
|
65
|
-
const ruleMatches = [...newText.matchAll(matcher)].flatMap(
|
|
66
|
-
(regExpMatch) => {
|
|
67
|
-
if (regExpMatch.indices === undefined) {
|
|
68
|
-
return []
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const match = regExpMatch.indices.at(0)
|
|
72
|
-
|
|
73
|
-
if (!match) {
|
|
74
|
-
return []
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const matchLocation = getInputRuleMatchLocation({
|
|
78
|
-
match: [regExpMatch.at(0) ?? '', ...match],
|
|
79
|
-
adjustIndexBy: originalNewText.length - newText.length,
|
|
80
|
-
snapshot,
|
|
81
|
-
focusBlock,
|
|
82
|
-
originalTextBefore,
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
if (!matchLocation) {
|
|
86
|
-
return []
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const existsInTextBefore =
|
|
90
|
-
matchLocation.targetOffsets.focus.offset <=
|
|
91
|
-
originalTextBefore.length
|
|
92
|
-
|
|
93
|
-
// Ignore if this match occurs in the text before the insertion
|
|
94
|
-
if (existsInTextBefore) {
|
|
95
|
-
return []
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const alreadyFound = foundMatches.some(
|
|
99
|
-
(foundMatch) =>
|
|
100
|
-
foundMatch.targetOffsets.anchor.offset ===
|
|
101
|
-
matchLocation.targetOffsets.anchor.offset,
|
|
102
|
-
)
|
|
103
|
-
|
|
104
|
-
// Ignore if this match has already been found
|
|
105
|
-
if (alreadyFound) {
|
|
106
|
-
return []
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const groupMatches =
|
|
110
|
-
regExpMatch.indices.length > 1
|
|
111
|
-
? regExpMatch.indices
|
|
112
|
-
.slice(1)
|
|
113
|
-
.filter((indices) => indices !== undefined)
|
|
114
|
-
: []
|
|
115
|
-
|
|
116
|
-
const ruleMatch = {
|
|
117
|
-
text: matchLocation.text,
|
|
118
|
-
selection: matchLocation.selection,
|
|
119
|
-
targetOffsets: matchLocation.targetOffsets,
|
|
120
|
-
groupMatches: groupMatches.flatMap((match, index) => {
|
|
121
|
-
const text = regExpMatch.at(index + 1) ?? ''
|
|
122
|
-
const groupMatchLocation = getInputRuleMatchLocation({
|
|
123
|
-
match: [text, ...match],
|
|
124
|
-
adjustIndexBy: originalNewText.length - newText.length,
|
|
125
|
-
snapshot,
|
|
126
|
-
focusBlock,
|
|
127
|
-
originalTextBefore,
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
if (!groupMatchLocation) {
|
|
131
|
-
return []
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
return groupMatchLocation
|
|
135
|
-
}),
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
return [ruleMatch]
|
|
139
|
-
},
|
|
140
|
-
)
|
|
141
|
-
|
|
142
|
-
if (ruleMatches.length > 0) {
|
|
143
|
-
const guardResult =
|
|
144
|
-
rule.guard?.({
|
|
145
|
-
snapshot,
|
|
146
|
-
event: {
|
|
147
|
-
type: 'custom.input rule',
|
|
148
|
-
matches: ruleMatches,
|
|
149
|
-
focusBlock,
|
|
150
|
-
textBefore: originalTextBefore,
|
|
151
|
-
textInserted: event.text,
|
|
152
|
-
},
|
|
153
|
-
dom,
|
|
154
|
-
}) ?? true
|
|
155
|
-
|
|
156
|
-
if (!guardResult) {
|
|
157
|
-
break
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const actionSets = rule.actions.map((action) =>
|
|
161
|
-
action(
|
|
162
|
-
{
|
|
163
|
-
snapshot,
|
|
164
|
-
event: {
|
|
165
|
-
type: 'custom.input rule',
|
|
166
|
-
matches: ruleMatches,
|
|
167
|
-
focusBlock,
|
|
168
|
-
textBefore: originalTextBefore,
|
|
169
|
-
textInserted: event.text,
|
|
170
|
-
},
|
|
171
|
-
dom,
|
|
172
|
-
},
|
|
173
|
-
guardResult,
|
|
174
|
-
),
|
|
175
|
-
)
|
|
176
|
-
|
|
177
|
-
for (const actionSet of actionSets) {
|
|
178
|
-
for (const action of actionSet) {
|
|
179
|
-
foundActions.push(action)
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
const matches = ruleMatches.flatMap((match) =>
|
|
184
|
-
match.groupMatches.length === 0 ? [match] : match.groupMatches,
|
|
185
|
-
)
|
|
186
|
-
|
|
187
|
-
for (const match of matches) {
|
|
188
|
-
// Remember each match and adjust `textBefore` and `newText` so
|
|
189
|
-
// no subsequent matches can overlap with this one
|
|
190
|
-
foundMatches.push(match)
|
|
191
|
-
textBefore = newText.slice(
|
|
192
|
-
0,
|
|
193
|
-
match.targetOffsets.focus.offset ?? 0,
|
|
194
|
-
)
|
|
195
|
-
newText = originalNewText.slice(
|
|
196
|
-
match.targetOffsets.focus.offset ?? 0,
|
|
197
|
-
)
|
|
198
|
-
}
|
|
199
|
-
} else {
|
|
200
|
-
// If no match was found, break out of the loop to try the next
|
|
201
|
-
// rule
|
|
202
|
-
break
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
if (foundActions.length === 0) {
|
|
208
|
-
return false
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
return {actions: foundActions}
|
|
212
|
-
},
|
|
213
|
-
actions: [
|
|
214
|
-
({event}) => [forward(event)],
|
|
215
|
-
(_, {actions}) => actions,
|
|
216
|
-
({snapshot}) => [
|
|
217
|
-
effect(() => {
|
|
218
|
-
const blockOffsets = getBlockOffsets(snapshot)
|
|
219
|
-
|
|
220
|
-
config.onApply?.({endOffsets: blockOffsets})
|
|
221
|
-
}),
|
|
222
|
-
],
|
|
223
|
-
],
|
|
224
|
-
})
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
type InputRulePluginProps = {
|
|
228
|
-
rules: Array<InputRule<any>>
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Turn an array of `InputRule`s into a Behavior that can be used to apply the
|
|
233
|
-
* rules to the editor.
|
|
234
|
-
*
|
|
235
|
-
* The plugin handles undo/redo out of the box including smart undo with
|
|
236
|
-
* Backspace.
|
|
237
|
-
*
|
|
238
|
-
* @example
|
|
239
|
-
* ```tsx
|
|
240
|
-
* <InputRulePlugin rules={smartQuotesRules} />
|
|
241
|
-
* ```
|
|
242
|
-
*
|
|
243
|
-
* @alpha
|
|
244
|
-
*/
|
|
245
|
-
export function InputRulePlugin(props: InputRulePluginProps) {
|
|
246
|
-
const editor = useEditor()
|
|
247
|
-
|
|
248
|
-
useActorRef(inputRuleMachine, {
|
|
249
|
-
input: {editor, rules: props.rules},
|
|
250
|
-
})
|
|
251
|
-
|
|
252
|
-
return null
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
type InputRuleMachineEvent =
|
|
256
|
-
| {
|
|
257
|
-
type: 'input rule raised'
|
|
258
|
-
endOffsets: {start: BlockOffset; end: BlockOffset} | undefined
|
|
259
|
-
}
|
|
260
|
-
| {type: 'history.undo raised'}
|
|
261
|
-
| {
|
|
262
|
-
type: 'selection changed'
|
|
263
|
-
blockOffsets: {start: BlockOffset; end: BlockOffset} | undefined
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
const inputRuleListenerCallback: CallbackLogicFunction<
|
|
267
|
-
AnyEventObject,
|
|
268
|
-
InputRuleMachineEvent,
|
|
269
|
-
{
|
|
270
|
-
editor: Editor
|
|
271
|
-
rules: Array<InputRule>
|
|
272
|
-
}
|
|
273
|
-
> = ({input, sendBack}) => {
|
|
274
|
-
const unregister = input.editor.registerBehavior({
|
|
275
|
-
behavior: defineInputRuleBehavior({
|
|
276
|
-
rules: input.rules,
|
|
277
|
-
onApply: ({endOffsets}) => {
|
|
278
|
-
sendBack({type: 'input rule raised', endOffsets})
|
|
279
|
-
},
|
|
280
|
-
}),
|
|
281
|
-
})
|
|
282
|
-
|
|
283
|
-
return () => {
|
|
284
|
-
unregister()
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
const deleteBackwardListenerCallback: CallbackLogicFunction<
|
|
289
|
-
AnyEventObject,
|
|
290
|
-
InputRuleMachineEvent,
|
|
291
|
-
{editor: Editor}
|
|
292
|
-
> = ({input, sendBack}) => {
|
|
293
|
-
return input.editor.registerBehavior({
|
|
294
|
-
behavior: defineBehavior({
|
|
295
|
-
on: 'delete.backward',
|
|
296
|
-
actions: [
|
|
297
|
-
() => [
|
|
298
|
-
raise({type: 'history.undo'}),
|
|
299
|
-
effect(() => {
|
|
300
|
-
sendBack({type: 'history.undo raised'})
|
|
301
|
-
}),
|
|
302
|
-
],
|
|
303
|
-
],
|
|
304
|
-
}),
|
|
305
|
-
})
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
const selectionListenerCallback: CallbackLogicFunction<
|
|
309
|
-
AnyEventObject,
|
|
310
|
-
InputRuleMachineEvent,
|
|
311
|
-
{editor: Editor}
|
|
312
|
-
> = ({sendBack, input}) => {
|
|
313
|
-
const unregister = input.editor.registerBehavior({
|
|
314
|
-
behavior: defineBehavior({
|
|
315
|
-
on: 'select',
|
|
316
|
-
guard: ({snapshot, event}) => {
|
|
317
|
-
const blockOffsets = getBlockOffsets({
|
|
318
|
-
...snapshot,
|
|
319
|
-
context: {
|
|
320
|
-
...snapshot.context,
|
|
321
|
-
selection: event.at,
|
|
322
|
-
},
|
|
323
|
-
})
|
|
324
|
-
|
|
325
|
-
return {blockOffsets}
|
|
326
|
-
},
|
|
327
|
-
actions: [
|
|
328
|
-
({event}, {blockOffsets}) => [
|
|
329
|
-
effect(() => {
|
|
330
|
-
sendBack({type: 'selection changed', blockOffsets})
|
|
331
|
-
}),
|
|
332
|
-
forward(event),
|
|
333
|
-
],
|
|
334
|
-
],
|
|
335
|
-
}),
|
|
336
|
-
})
|
|
337
|
-
|
|
338
|
-
return unregister
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
const inputRuleSetup = setup({
|
|
342
|
-
types: {
|
|
343
|
-
context: {} as {
|
|
344
|
-
editor: Editor
|
|
345
|
-
rules: Array<InputRule>
|
|
346
|
-
endOffsets: {start: BlockOffset; end: BlockOffset} | undefined
|
|
347
|
-
},
|
|
348
|
-
input: {} as {
|
|
349
|
-
editor: Editor
|
|
350
|
-
rules: Array<InputRule>
|
|
351
|
-
},
|
|
352
|
-
events: {} as InputRuleMachineEvent,
|
|
353
|
-
},
|
|
354
|
-
actors: {
|
|
355
|
-
'delete.backward listener': fromCallback(deleteBackwardListenerCallback),
|
|
356
|
-
'input rule listener': fromCallback(inputRuleListenerCallback),
|
|
357
|
-
'selection listener': fromCallback(selectionListenerCallback),
|
|
358
|
-
},
|
|
359
|
-
guards: {
|
|
360
|
-
'block offset changed': ({context, event}) => {
|
|
361
|
-
if (event.type !== 'selection changed') {
|
|
362
|
-
return false
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
if (!event.blockOffsets || !context.endOffsets) {
|
|
366
|
-
return true
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
const startChanged =
|
|
370
|
-
context.endOffsets.start.path[0]._key !==
|
|
371
|
-
event.blockOffsets.start.path[0]._key ||
|
|
372
|
-
context.endOffsets.start.offset !== event.blockOffsets.start.offset
|
|
373
|
-
const endChanged =
|
|
374
|
-
context.endOffsets.end.path[0]._key !==
|
|
375
|
-
event.blockOffsets.end.path[0]._key ||
|
|
376
|
-
context.endOffsets.end.offset !== event.blockOffsets.end.offset
|
|
377
|
-
|
|
378
|
-
return startChanged || endChanged
|
|
379
|
-
},
|
|
380
|
-
},
|
|
381
|
-
})
|
|
382
|
-
|
|
383
|
-
const assignEndOffsets = inputRuleSetup.assign({
|
|
384
|
-
endOffsets: ({context, event}) =>
|
|
385
|
-
event.type === 'input rule raised' ? event.endOffsets : context.endOffsets,
|
|
386
|
-
})
|
|
387
|
-
|
|
388
|
-
const inputRuleMachine = inputRuleSetup.createMachine({
|
|
389
|
-
id: 'input rule',
|
|
390
|
-
context: ({input}) => ({
|
|
391
|
-
editor: input.editor,
|
|
392
|
-
rules: input.rules,
|
|
393
|
-
endOffsets: undefined,
|
|
394
|
-
}),
|
|
395
|
-
initial: 'idle',
|
|
396
|
-
invoke: {
|
|
397
|
-
src: 'input rule listener',
|
|
398
|
-
input: ({context}) => ({
|
|
399
|
-
editor: context.editor,
|
|
400
|
-
rules: context.rules,
|
|
401
|
-
}),
|
|
402
|
-
},
|
|
403
|
-
on: {
|
|
404
|
-
'input rule raised': {
|
|
405
|
-
target: '.input rule applied',
|
|
406
|
-
actions: assignEndOffsets,
|
|
407
|
-
},
|
|
408
|
-
},
|
|
409
|
-
states: {
|
|
410
|
-
'idle': {},
|
|
411
|
-
'input rule applied': {
|
|
412
|
-
invoke: [
|
|
413
|
-
{
|
|
414
|
-
src: 'delete.backward listener',
|
|
415
|
-
input: ({context}) => ({editor: context.editor}),
|
|
416
|
-
},
|
|
417
|
-
{
|
|
418
|
-
src: 'selection listener',
|
|
419
|
-
input: ({context}) => ({editor: context.editor}),
|
|
420
|
-
},
|
|
421
|
-
],
|
|
422
|
-
on: {
|
|
423
|
-
'selection changed': {
|
|
424
|
-
target: 'idle',
|
|
425
|
-
guard: 'block offset changed',
|
|
426
|
-
},
|
|
427
|
-
'history.undo raised': {
|
|
428
|
-
target: 'idle',
|
|
429
|
-
},
|
|
430
|
-
},
|
|
431
|
-
},
|
|
432
|
-
},
|
|
433
|
-
})
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
Feature: Stock Ticker Rule
|
|
2
|
-
|
|
3
|
-
Scenario Outline: Transforms plain text into stock ticker
|
|
4
|
-
Given the text <text>
|
|
5
|
-
When <inserted text> is inserted
|
|
6
|
-
And "{ArrowRight}" is pressed
|
|
7
|
-
And "new" is typed
|
|
8
|
-
Then the text is <new text>
|
|
9
|
-
|
|
10
|
-
Examples:
|
|
11
|
-
| text | inserted text | new text |
|
|
12
|
-
| "" | "{AAPL}" | ",{stock-ticker},new" |
|
|
@@ -1,46 +0,0 @@
|
|
|
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
|
-
})
|
package/src/rule.stock-ticker.ts
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
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.focusBlock.node._key},
|
|
60
|
-
'children',
|
|
61
|
-
{_key: stockTickerKey},
|
|
62
|
-
],
|
|
63
|
-
offset: 0,
|
|
64
|
-
},
|
|
65
|
-
focus: {
|
|
66
|
-
path: [
|
|
67
|
-
{_key: event.focusBlock.node._key},
|
|
68
|
-
'children',
|
|
69
|
-
{_key: stockTickerKey},
|
|
70
|
-
],
|
|
71
|
-
offset: 0,
|
|
72
|
-
},
|
|
73
|
-
},
|
|
74
|
-
}),
|
|
75
|
-
]
|
|
76
|
-
},
|
|
77
|
-
],
|
|
78
|
-
})
|
|
79
|
-
}
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
import {raise, type BehaviorAction} from '@portabletext/editor/behaviors'
|
|
2
|
-
import {getMarkState} from '@portabletext/editor/selectors'
|
|
3
|
-
import type {InputRule, InputRuleGuard} from './input-rule'
|
|
4
|
-
import type {InputRuleMatchLocation} from './input-rule-match-location'
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* @alpha
|
|
8
|
-
*/
|
|
9
|
-
export type TextTransformRule<TGuardResponse = true> = {
|
|
10
|
-
on: RegExp
|
|
11
|
-
guard?: InputRuleGuard<TGuardResponse>
|
|
12
|
-
transform: (
|
|
13
|
-
{location}: {location: InputRuleMatchLocation},
|
|
14
|
-
guardResponse: TGuardResponse,
|
|
15
|
-
) => string
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Define an `InputRule` specifically designed to transform matched text into
|
|
20
|
-
* some other text.
|
|
21
|
-
*
|
|
22
|
-
* @example
|
|
23
|
-
* ```tsx
|
|
24
|
-
* const transformRule = defineTextTransformRule({
|
|
25
|
-
* on: /--/,
|
|
26
|
-
* transform: () => '—',
|
|
27
|
-
* })
|
|
28
|
-
* ```
|
|
29
|
-
*
|
|
30
|
-
* @alpha
|
|
31
|
-
*/
|
|
32
|
-
export function defineTextTransformRule<TGuardResponse = true>(
|
|
33
|
-
config: TextTransformRule<TGuardResponse>,
|
|
34
|
-
): InputRule<TGuardResponse> {
|
|
35
|
-
return {
|
|
36
|
-
on: config.on,
|
|
37
|
-
guard: config.guard ?? (() => true as TGuardResponse),
|
|
38
|
-
actions: [
|
|
39
|
-
({snapshot, event}, guardResponse) => {
|
|
40
|
-
const locations = event.matches.flatMap((match) =>
|
|
41
|
-
match.groupMatches.length === 0 ? [match] : match.groupMatches,
|
|
42
|
-
)
|
|
43
|
-
const newText = event.textBefore + event.textInserted
|
|
44
|
-
|
|
45
|
-
let textLengthDelta = 0
|
|
46
|
-
const actions: Array<BehaviorAction> = []
|
|
47
|
-
|
|
48
|
-
for (const location of locations.reverse()) {
|
|
49
|
-
const text = config.transform({location}, guardResponse)
|
|
50
|
-
|
|
51
|
-
textLengthDelta =
|
|
52
|
-
textLengthDelta -
|
|
53
|
-
(text.length -
|
|
54
|
-
(location.targetOffsets.focus.offset -
|
|
55
|
-
location.targetOffsets.anchor.offset))
|
|
56
|
-
|
|
57
|
-
actions.push(raise({type: 'select', at: location.targetOffsets}))
|
|
58
|
-
actions.push(raise({type: 'delete', at: location.targetOffsets}))
|
|
59
|
-
actions.push(
|
|
60
|
-
raise({
|
|
61
|
-
type: 'insert.child',
|
|
62
|
-
child: {
|
|
63
|
-
_type: snapshot.context.schema.span.name,
|
|
64
|
-
text,
|
|
65
|
-
marks:
|
|
66
|
-
getMarkState({
|
|
67
|
-
...snapshot,
|
|
68
|
-
context: {
|
|
69
|
-
...snapshot.context,
|
|
70
|
-
selection: {
|
|
71
|
-
anchor: location.selection.anchor,
|
|
72
|
-
focus: {
|
|
73
|
-
path: location.selection.focus.path,
|
|
74
|
-
offset: Math.min(
|
|
75
|
-
location.selection.focus.offset,
|
|
76
|
-
event.textBefore.length,
|
|
77
|
-
),
|
|
78
|
-
},
|
|
79
|
-
},
|
|
80
|
-
},
|
|
81
|
-
})?.marks ?? [],
|
|
82
|
-
},
|
|
83
|
-
}),
|
|
84
|
-
)
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const endCaretPosition = {
|
|
88
|
-
path: event.focusBlock.path,
|
|
89
|
-
offset: newText.length - textLengthDelta,
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
return [
|
|
93
|
-
...actions,
|
|
94
|
-
raise({
|
|
95
|
-
type: 'select',
|
|
96
|
-
at: {
|
|
97
|
-
anchor: endCaretPosition,
|
|
98
|
-
focus: endCaretPosition,
|
|
99
|
-
},
|
|
100
|
-
}),
|
|
101
|
-
]
|
|
102
|
-
},
|
|
103
|
-
],
|
|
104
|
-
}
|
|
105
|
-
}
|