@portabletext/plugin-input-rule 1.0.23 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@portabletext/plugin-input-rule",
3
- "version": "1.0.23",
3
+ "version": "1.0.24",
4
4
  "description": "Easily configure input rules in the Portable Text Editor",
5
5
  "keywords": [
6
6
  "portabletext",
@@ -30,8 +30,7 @@
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",
@@ -53,12 +52,12 @@
53
52
  "typescript": "5.9.3",
54
53
  "typescript-eslint": "^8.48.0",
55
54
  "vitest": "^4.0.14",
56
- "@portabletext/editor": "3.3.3",
57
- "@portabletext/schema": "2.0.0",
58
- "racejar": "2.0.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.3",
60
+ "@portabletext/editor": "^3.3.4",
62
61
  "react": "^18.3 || ^19"
63
62
  },
64
63
  "engines": {
@@ -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"
@@ -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
@@ -1,4 +0,0 @@
1
- declare module '*.feature?raw' {
2
- const content: string
3
- export default content
4
- }
package/src/index.ts DELETED
@@ -1,3 +0,0 @@
1
- export * from './input-rule'
2
- export * from './plugin.input-rule'
3
- export * from './text-transform-rule'
@@ -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
- })
@@ -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
- }