@portabletext/plugin-character-pair-decorator 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +34 -0
- package/dist/index.cjs +291 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +14 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +281 -0
- package/dist/index.js.map +1 -0
- package/package.json +67 -0
- package/src/behavior.character-pair-decorator.ts +201 -0
- package/src/index.ts +1 -0
- package/src/plugin.character-pair-decorator.ts +239 -0
- package/src/regex.character-pair.test.ts +74 -0
- package/src/regex.character-pair.ts +24 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2016 - 2025 Sanity.io
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# `@portabletext/plugin-character-pair-decorator`
|
|
2
|
+
|
|
3
|
+
> Automatically match a pair of characters and decorate the text in between
|
|
4
|
+
|
|
5
|
+
Import the `CharacterPairDecoratorPlugin` React component and place it inside the `EditorProvider` to automatically register the necessary Behaviors:
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
import {
|
|
9
|
+
defineSchema,
|
|
10
|
+
EditorProvider,
|
|
11
|
+
PortableTextEditable,
|
|
12
|
+
} from '@portabletext/editor'
|
|
13
|
+
import {CharacterPairDecoratorPlugin} from '@portabletext/plugin-character-pair-decorator'
|
|
14
|
+
|
|
15
|
+
function App() {
|
|
16
|
+
return (
|
|
17
|
+
<EditorProvider
|
|
18
|
+
initialConfig={{
|
|
19
|
+
schemaDefinition: defineSchema({
|
|
20
|
+
decorators: [{name: 'italic'}],
|
|
21
|
+
}),
|
|
22
|
+
}}
|
|
23
|
+
>
|
|
24
|
+
<PortableTextEditable />
|
|
25
|
+
<CharacterPairDecoratorPlugin
|
|
26
|
+
decorator={({schema}) =>
|
|
27
|
+
schema.decorators.find((d) => d.name === 'italic')?.name
|
|
28
|
+
}
|
|
29
|
+
pair={{char: '#', amount: 1}}
|
|
30
|
+
/>
|
|
31
|
+
</EditorProvider>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
```
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: !0 });
|
|
3
|
+
var editor = require("@portabletext/editor"), behaviors = require("@portabletext/editor/behaviors"), utils = require("@portabletext/editor/utils"), react = require("@xstate/react"), remeda = require("remeda"), xstate = require("xstate"), selectors = require("@portabletext/editor/selectors");
|
|
4
|
+
function _interopNamespaceCompat(e) {
|
|
5
|
+
if (e && typeof e == "object" && "default" in e) return e;
|
|
6
|
+
var n = /* @__PURE__ */ Object.create(null);
|
|
7
|
+
return e && Object.keys(e).forEach(function(k) {
|
|
8
|
+
if (k !== "default") {
|
|
9
|
+
var d = Object.getOwnPropertyDescriptor(e, k);
|
|
10
|
+
Object.defineProperty(n, k, d.get ? d : {
|
|
11
|
+
enumerable: !0,
|
|
12
|
+
get: function() {
|
|
13
|
+
return e[k];
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
}), n.default = e, Object.freeze(n);
|
|
18
|
+
}
|
|
19
|
+
var utils__namespace = /* @__PURE__ */ _interopNamespaceCompat(utils), selectors__namespace = /* @__PURE__ */ _interopNamespaceCompat(selectors);
|
|
20
|
+
function createCharacterPairRegex(char, amount) {
|
|
21
|
+
const prePrefix = `(?<!\\${char})`, prefix = `\\${char}`.repeat(Math.max(amount, 1)), postPrefix = "(?!\\s)", content = `([^${char}\\n]+?)`, preSuffix = "(?<!\\s)", suffix = `\\${char}`.repeat(Math.max(amount, 1)), postSuffix = `(?!\\${char})`;
|
|
22
|
+
return `${prePrefix}${prefix}${postPrefix}${content}${preSuffix}${suffix}${postSuffix}`;
|
|
23
|
+
}
|
|
24
|
+
function createCharacterPairDecoratorBehavior(config) {
|
|
25
|
+
config.pair.amount < 1 && console.warn(
|
|
26
|
+
"The amount of characters in the pair should be greater than 0"
|
|
27
|
+
);
|
|
28
|
+
const pairRegex = createCharacterPairRegex(
|
|
29
|
+
config.pair.char,
|
|
30
|
+
config.pair.amount
|
|
31
|
+
), regEx = new RegExp(`(${pairRegex})$`);
|
|
32
|
+
return behaviors.defineBehavior({
|
|
33
|
+
on: "insert.text",
|
|
34
|
+
guard: ({ snapshot, event }) => {
|
|
35
|
+
if (config.pair.amount < 1)
|
|
36
|
+
return !1;
|
|
37
|
+
const decorator = config.decorator({ schema: snapshot.context.schema });
|
|
38
|
+
if (decorator === void 0)
|
|
39
|
+
return !1;
|
|
40
|
+
const focusTextBlock = selectors__namespace.getFocusTextBlock(snapshot), selectionStartPoint = selectors__namespace.getSelectionStartPoint(snapshot), selectionStartOffset = selectionStartPoint ? utils__namespace.spanSelectionPointToBlockOffset({
|
|
41
|
+
value: snapshot.context.value,
|
|
42
|
+
selectionPoint: selectionStartPoint
|
|
43
|
+
}) : void 0;
|
|
44
|
+
if (!focusTextBlock || !selectionStartOffset)
|
|
45
|
+
return !1;
|
|
46
|
+
const newText = `${selectors__namespace.getBlockTextBefore(snapshot)}${event.text}`, textToDecorate = newText.match(regEx)?.at(0);
|
|
47
|
+
if (textToDecorate === void 0)
|
|
48
|
+
return !1;
|
|
49
|
+
const prefixOffsets = {
|
|
50
|
+
anchor: {
|
|
51
|
+
path: focusTextBlock.path,
|
|
52
|
+
// Example: "foo **bar**".length - "**bar**".length = 4
|
|
53
|
+
offset: newText.length - textToDecorate.length
|
|
54
|
+
},
|
|
55
|
+
focus: {
|
|
56
|
+
path: focusTextBlock.path,
|
|
57
|
+
// Example: "foo **bar**".length - "**bar**".length + "*".length * 2 = 6
|
|
58
|
+
offset: newText.length - textToDecorate.length + config.pair.char.length * config.pair.amount
|
|
59
|
+
}
|
|
60
|
+
}, suffixOffsets = {
|
|
61
|
+
anchor: {
|
|
62
|
+
path: focusTextBlock.path,
|
|
63
|
+
// Example: "foo **bar*|" (10) + "*".length - 2 = 9
|
|
64
|
+
offset: selectionStartOffset.offset + event.text.length - config.pair.char.length * config.pair.amount
|
|
65
|
+
},
|
|
66
|
+
focus: {
|
|
67
|
+
path: focusTextBlock.path,
|
|
68
|
+
// Example: "foo **bar*|" (10) + "*".length = 11
|
|
69
|
+
offset: selectionStartOffset.offset + event.text.length
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
if (prefixOffsets.focus.offset - prefixOffsets.anchor.offset > 1) {
|
|
73
|
+
const prefixSelection = utils__namespace.blockOffsetsToSelection({
|
|
74
|
+
value: snapshot.context.value,
|
|
75
|
+
offsets: prefixOffsets
|
|
76
|
+
}), inlineObjectBeforePrefixFocus = selectors__namespace.getPreviousInlineObject(
|
|
77
|
+
{
|
|
78
|
+
...snapshot,
|
|
79
|
+
context: {
|
|
80
|
+
...snapshot.context,
|
|
81
|
+
selection: prefixSelection ? {
|
|
82
|
+
anchor: prefixSelection.focus,
|
|
83
|
+
focus: prefixSelection.focus
|
|
84
|
+
} : null
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
), inlineObjectBeforePrefixFocusOffset = inlineObjectBeforePrefixFocus ? utils__namespace.childSelectionPointToBlockOffset({
|
|
88
|
+
value: snapshot.context.value,
|
|
89
|
+
selectionPoint: {
|
|
90
|
+
path: inlineObjectBeforePrefixFocus.path,
|
|
91
|
+
offset: 0
|
|
92
|
+
}
|
|
93
|
+
}) : void 0;
|
|
94
|
+
if (inlineObjectBeforePrefixFocusOffset && inlineObjectBeforePrefixFocusOffset.offset > prefixOffsets.anchor.offset && inlineObjectBeforePrefixFocusOffset.offset < prefixOffsets.focus.offset)
|
|
95
|
+
return !1;
|
|
96
|
+
}
|
|
97
|
+
if (suffixOffsets.focus.offset - suffixOffsets.anchor.offset > 1) {
|
|
98
|
+
const previousInlineObject = selectors__namespace.getPreviousInlineObject(snapshot), previousInlineObjectOffset = previousInlineObject ? utils__namespace.childSelectionPointToBlockOffset({
|
|
99
|
+
value: snapshot.context.value,
|
|
100
|
+
selectionPoint: {
|
|
101
|
+
path: previousInlineObject.path,
|
|
102
|
+
offset: 0
|
|
103
|
+
}
|
|
104
|
+
}) : void 0;
|
|
105
|
+
if (previousInlineObjectOffset && previousInlineObjectOffset.offset > suffixOffsets.anchor.offset && previousInlineObjectOffset.offset < suffixOffsets.focus.offset)
|
|
106
|
+
return !1;
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
prefixOffsets,
|
|
110
|
+
suffixOffsets,
|
|
111
|
+
decorator
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
actions: [
|
|
115
|
+
// Insert the text as usual in its own undo step
|
|
116
|
+
({ event }) => [behaviors.execute(event)],
|
|
117
|
+
(_, { prefixOffsets, suffixOffsets, decorator }) => [
|
|
118
|
+
// Decorate the text between the prefix and suffix
|
|
119
|
+
behaviors.execute({
|
|
120
|
+
type: "decorator.add",
|
|
121
|
+
decorator,
|
|
122
|
+
at: {
|
|
123
|
+
anchor: prefixOffsets.focus,
|
|
124
|
+
focus: suffixOffsets.anchor
|
|
125
|
+
}
|
|
126
|
+
}),
|
|
127
|
+
// Delete the suffix
|
|
128
|
+
behaviors.execute({
|
|
129
|
+
type: "delete.text",
|
|
130
|
+
at: suffixOffsets
|
|
131
|
+
}),
|
|
132
|
+
// Delete the prefix
|
|
133
|
+
behaviors.execute({
|
|
134
|
+
type: "delete.text",
|
|
135
|
+
at: prefixOffsets
|
|
136
|
+
}),
|
|
137
|
+
// Toggle the decorator off so the next inserted text isn't emphasized
|
|
138
|
+
behaviors.execute({
|
|
139
|
+
type: "decorator.remove",
|
|
140
|
+
decorator
|
|
141
|
+
}),
|
|
142
|
+
behaviors.effect(() => {
|
|
143
|
+
config.onDecorate({
|
|
144
|
+
...suffixOffsets.anchor,
|
|
145
|
+
offset: suffixOffsets.anchor.offset - (prefixOffsets.focus.offset - prefixOffsets.anchor.offset)
|
|
146
|
+
});
|
|
147
|
+
})
|
|
148
|
+
]
|
|
149
|
+
]
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
function CharacterPairDecoratorPlugin(config) {
|
|
153
|
+
const editor$1 = editor.useEditor();
|
|
154
|
+
return react.useActorRef(decoratorPairMachine, {
|
|
155
|
+
input: {
|
|
156
|
+
editor: editor$1,
|
|
157
|
+
decorator: config.decorator,
|
|
158
|
+
pair: config.pair
|
|
159
|
+
}
|
|
160
|
+
}), null;
|
|
161
|
+
}
|
|
162
|
+
const decorateListener = ({ sendBack, input }) => input.editor.registerBehavior({
|
|
163
|
+
behavior: createCharacterPairDecoratorBehavior({
|
|
164
|
+
decorator: input.decorator,
|
|
165
|
+
pair: input.pair,
|
|
166
|
+
onDecorate: (offset) => {
|
|
167
|
+
sendBack({ type: "decorator.add", blockOffset: offset });
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
}), selectionListenerCallback = ({ sendBack, input }) => input.editor.registerBehavior({
|
|
171
|
+
behavior: behaviors.defineBehavior({
|
|
172
|
+
on: "select",
|
|
173
|
+
guard: ({ snapshot, event }) => {
|
|
174
|
+
if (!event.at)
|
|
175
|
+
return { blockOffsets: void 0 };
|
|
176
|
+
const anchor = utils__namespace.spanSelectionPointToBlockOffset({
|
|
177
|
+
value: snapshot.context.value,
|
|
178
|
+
selectionPoint: event.at.anchor
|
|
179
|
+
}), focus = utils__namespace.spanSelectionPointToBlockOffset({
|
|
180
|
+
value: snapshot.context.value,
|
|
181
|
+
selectionPoint: event.at.focus
|
|
182
|
+
});
|
|
183
|
+
return !anchor || !focus ? { blockOffsets: void 0 } : {
|
|
184
|
+
blockOffsets: {
|
|
185
|
+
anchor,
|
|
186
|
+
focus
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
},
|
|
190
|
+
actions: [
|
|
191
|
+
({ event }, { blockOffsets }) => [
|
|
192
|
+
{
|
|
193
|
+
type: "effect",
|
|
194
|
+
effect: () => {
|
|
195
|
+
sendBack({ type: "selection", blockOffsets });
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
behaviors.forward(event)
|
|
199
|
+
]
|
|
200
|
+
]
|
|
201
|
+
})
|
|
202
|
+
}), deleteBackwardListenerCallback = ({ sendBack, input }) => input.editor.registerBehavior({
|
|
203
|
+
behavior: behaviors.defineBehavior({
|
|
204
|
+
on: "delete.backward",
|
|
205
|
+
actions: [
|
|
206
|
+
() => [
|
|
207
|
+
behaviors.execute({
|
|
208
|
+
type: "history.undo"
|
|
209
|
+
}),
|
|
210
|
+
behaviors.effect(() => {
|
|
211
|
+
sendBack({ type: "delete.backward" });
|
|
212
|
+
})
|
|
213
|
+
]
|
|
214
|
+
]
|
|
215
|
+
})
|
|
216
|
+
}), decoratorPairMachine = xstate.setup({
|
|
217
|
+
types: {
|
|
218
|
+
context: {},
|
|
219
|
+
input: {},
|
|
220
|
+
events: {}
|
|
221
|
+
},
|
|
222
|
+
actors: {
|
|
223
|
+
"decorate listener": xstate.fromCallback(decorateListener),
|
|
224
|
+
"delete.backward listener": xstate.fromCallback(deleteBackwardListenerCallback),
|
|
225
|
+
"selection listener": xstate.fromCallback(selectionListenerCallback)
|
|
226
|
+
}
|
|
227
|
+
}).createMachine({
|
|
228
|
+
id: "decorator pair",
|
|
229
|
+
context: ({ input }) => ({
|
|
230
|
+
decorator: input.decorator,
|
|
231
|
+
editor: input.editor,
|
|
232
|
+
pair: input.pair
|
|
233
|
+
}),
|
|
234
|
+
initial: "idle",
|
|
235
|
+
states: {
|
|
236
|
+
idle: {
|
|
237
|
+
invoke: [
|
|
238
|
+
{
|
|
239
|
+
src: "decorate listener",
|
|
240
|
+
input: ({ context }) => ({
|
|
241
|
+
decorator: context.decorator,
|
|
242
|
+
editor: context.editor,
|
|
243
|
+
pair: context.pair
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
],
|
|
247
|
+
on: {
|
|
248
|
+
"decorator.add": {
|
|
249
|
+
target: "decorator added",
|
|
250
|
+
actions: xstate.assign({
|
|
251
|
+
offsetAfterDecorator: ({ event }) => event.blockOffset
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
"decorator added": {
|
|
257
|
+
exit: [
|
|
258
|
+
xstate.assign({
|
|
259
|
+
offsetAfterDecorator: void 0
|
|
260
|
+
})
|
|
261
|
+
],
|
|
262
|
+
invoke: [
|
|
263
|
+
{
|
|
264
|
+
src: "selection listener",
|
|
265
|
+
input: ({ context }) => ({ editor: context.editor })
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
src: "delete.backward listener",
|
|
269
|
+
input: ({ context }) => ({ editor: context.editor })
|
|
270
|
+
}
|
|
271
|
+
],
|
|
272
|
+
on: {
|
|
273
|
+
selection: {
|
|
274
|
+
target: "idle",
|
|
275
|
+
guard: ({ context, event }) => !remeda.isDeepEqual(
|
|
276
|
+
{
|
|
277
|
+
anchor: context.offsetAfterDecorator,
|
|
278
|
+
focus: context.offsetAfterDecorator
|
|
279
|
+
},
|
|
280
|
+
event.blockOffsets
|
|
281
|
+
)
|
|
282
|
+
},
|
|
283
|
+
"delete.backward": {
|
|
284
|
+
target: "idle"
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
exports.CharacterPairDecoratorPlugin = CharacterPairDecoratorPlugin;
|
|
291
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.cjs","sources":["../src/regex.character-pair.ts","../src/behavior.character-pair-decorator.ts","../src/plugin.character-pair-decorator.ts"],"sourcesContent":["export function createCharacterPairRegex(char: string, amount: number) {\n // Negative lookbehind: Ensures that the matched sequence is not preceded by the same character\n const prePrefix = `(?<!\\\\${char})`\n\n // Repeats the character `amount` times\n const prefix = `\\\\${char}`.repeat(Math.max(amount, 1))\n\n // Negative lookahead: Ensures that the opening pair (**, *, etc.) is not followed by a space\n const postPrefix = `(?!\\\\s)`\n\n // Captures the content inside the pair\n const content = `([^${char}\\\\n]+?)`\n\n // Negative lookbehind: Ensures that the content is not followed by a space\n const preSuffix = `(?<!\\\\s)`\n\n // Repeats the character `amount` times\n const suffix = `\\\\${char}`.repeat(Math.max(amount, 1))\n\n // Negative lookahead: Ensures that the matched sequence is not followed by the same character\n const postSuffix = `(?!\\\\${char})`\n\n return `${prePrefix}${prefix}${postPrefix}${content}${preSuffix}${suffix}${postSuffix}`\n}\n","import type {BlockOffset, EditorSchema} from '@portabletext/editor'\nimport {defineBehavior, effect, execute} from '@portabletext/editor/behaviors'\nimport * as selectors from '@portabletext/editor/selectors'\nimport * as utils from '@portabletext/editor/utils'\nimport {createCharacterPairRegex} from './regex.character-pair'\n\nexport function createCharacterPairDecoratorBehavior(config: {\n decorator: ({schema}: {schema: EditorSchema}) => string | undefined\n pair: {char: string; amount: number}\n onDecorate: (offset: BlockOffset) => void\n}) {\n if (config.pair.amount < 1) {\n console.warn(\n `The amount of characters in the pair should be greater than 0`,\n )\n }\n\n const pairRegex = createCharacterPairRegex(\n config.pair.char,\n config.pair.amount,\n )\n const regEx = new RegExp(`(${pairRegex})$`)\n\n return defineBehavior({\n on: 'insert.text',\n guard: ({snapshot, event}) => {\n if (config.pair.amount < 1) {\n return false\n }\n\n const decorator = config.decorator({schema: snapshot.context.schema})\n\n if (decorator === undefined) {\n return false\n }\n\n const focusTextBlock = selectors.getFocusTextBlock(snapshot)\n const selectionStartPoint = selectors.getSelectionStartPoint(snapshot)\n const selectionStartOffset = selectionStartPoint\n ? utils.spanSelectionPointToBlockOffset({\n value: snapshot.context.value,\n selectionPoint: selectionStartPoint,\n })\n : undefined\n\n if (!focusTextBlock || !selectionStartOffset) {\n return false\n }\n\n const textBefore = selectors.getBlockTextBefore(snapshot)\n const newText = `${textBefore}${event.text}`\n const textToDecorate = newText.match(regEx)?.at(0)\n\n if (textToDecorate === undefined) {\n return false\n }\n\n const prefixOffsets = {\n anchor: {\n path: focusTextBlock.path,\n // Example: \"foo **bar**\".length - \"**bar**\".length = 4\n offset: newText.length - textToDecorate.length,\n },\n focus: {\n path: focusTextBlock.path,\n // Example: \"foo **bar**\".length - \"**bar**\".length + \"*\".length * 2 = 6\n offset:\n newText.length -\n textToDecorate.length +\n config.pair.char.length * config.pair.amount,\n },\n }\n\n const suffixOffsets = {\n anchor: {\n path: focusTextBlock.path,\n // Example: \"foo **bar*|\" (10) + \"*\".length - 2 = 9\n offset:\n selectionStartOffset.offset +\n event.text.length -\n config.pair.char.length * config.pair.amount,\n },\n focus: {\n path: focusTextBlock.path,\n // Example: \"foo **bar*|\" (10) + \"*\".length = 11\n offset: selectionStartOffset.offset + event.text.length,\n },\n }\n\n // If the prefix is more than one character, then we need to check if\n // there is an inline object inside it\n if (prefixOffsets.focus.offset - prefixOffsets.anchor.offset > 1) {\n const prefixSelection = utils.blockOffsetsToSelection({\n value: snapshot.context.value,\n offsets: prefixOffsets,\n })\n const inlineObjectBeforePrefixFocus = selectors.getPreviousInlineObject(\n {\n ...snapshot,\n context: {\n ...snapshot.context,\n selection: prefixSelection\n ? {\n anchor: prefixSelection.focus,\n focus: prefixSelection.focus,\n }\n : null,\n },\n },\n )\n const inlineObjectBeforePrefixFocusOffset =\n inlineObjectBeforePrefixFocus\n ? utils.childSelectionPointToBlockOffset({\n value: snapshot.context.value,\n selectionPoint: {\n path: inlineObjectBeforePrefixFocus.path,\n offset: 0,\n },\n })\n : undefined\n\n if (\n inlineObjectBeforePrefixFocusOffset &&\n inlineObjectBeforePrefixFocusOffset.offset >\n prefixOffsets.anchor.offset &&\n inlineObjectBeforePrefixFocusOffset.offset <\n prefixOffsets.focus.offset\n ) {\n return false\n }\n }\n\n // If the suffix is more than one character, then we need to check if\n // there is an inline object inside it\n if (suffixOffsets.focus.offset - suffixOffsets.anchor.offset > 1) {\n const previousInlineObject = selectors.getPreviousInlineObject(snapshot)\n const previousInlineObjectOffset = previousInlineObject\n ? utils.childSelectionPointToBlockOffset({\n value: snapshot.context.value,\n selectionPoint: {\n path: previousInlineObject.path,\n offset: 0,\n },\n })\n : undefined\n\n if (\n previousInlineObjectOffset &&\n previousInlineObjectOffset.offset > suffixOffsets.anchor.offset &&\n previousInlineObjectOffset.offset < suffixOffsets.focus.offset\n ) {\n return false\n }\n }\n\n return {\n prefixOffsets,\n suffixOffsets,\n decorator,\n }\n },\n actions: [\n // Insert the text as usual in its own undo step\n ({event}) => [execute(event)],\n (_, {prefixOffsets, suffixOffsets, decorator}) => [\n // Decorate the text between the prefix and suffix\n execute({\n type: 'decorator.add',\n decorator,\n at: {\n anchor: prefixOffsets.focus,\n focus: suffixOffsets.anchor,\n },\n }),\n // Delete the suffix\n execute({\n type: 'delete.text',\n at: suffixOffsets,\n }),\n // Delete the prefix\n execute({\n type: 'delete.text',\n at: prefixOffsets,\n }),\n // Toggle the decorator off so the next inserted text isn't emphasized\n execute({\n type: 'decorator.remove',\n decorator,\n }),\n effect(() => {\n config.onDecorate({\n ...suffixOffsets.anchor,\n offset:\n suffixOffsets.anchor.offset -\n (prefixOffsets.focus.offset - prefixOffsets.anchor.offset),\n })\n }),\n ],\n ],\n })\n}\n","import type {BlockOffset, Editor, EditorSchema} from '@portabletext/editor'\nimport {useEditor} from '@portabletext/editor'\nimport {\n defineBehavior,\n effect,\n execute,\n forward,\n} from '@portabletext/editor/behaviors'\nimport * as utils from '@portabletext/editor/utils'\nimport {useActorRef} from '@xstate/react'\nimport {isDeepEqual} from 'remeda'\nimport {\n assign,\n fromCallback,\n setup,\n type AnyEventObject,\n type CallbackLogicFunction,\n} from 'xstate'\nimport {createCharacterPairDecoratorBehavior} from './behavior.character-pair-decorator'\n\n/**\n * @beta\n */\nexport function CharacterPairDecoratorPlugin(config: {\n decorator: ({schema}: {schema: EditorSchema}) => string | undefined\n pair: {char: string; amount: number}\n}) {\n const editor = useEditor()\n\n useActorRef(decoratorPairMachine, {\n input: {\n editor,\n decorator: config.decorator,\n pair: config.pair,\n },\n })\n\n return null\n}\n\ntype DecoratorPairEvent =\n | {\n type: 'decorator.add'\n blockOffset: BlockOffset\n }\n | {\n type: 'selection'\n blockOffsets?: {\n anchor: BlockOffset\n focus: BlockOffset\n }\n }\n | {\n type: 'delete.backward'\n }\n\nconst decorateListener: CallbackLogicFunction<\n AnyEventObject,\n DecoratorPairEvent,\n {\n decorator: ({schema}: {schema: EditorSchema}) => string | undefined\n editor: Editor\n pair: {char: string; amount: number}\n }\n> = ({sendBack, input}) => {\n const unregister = input.editor.registerBehavior({\n behavior: createCharacterPairDecoratorBehavior({\n decorator: input.decorator,\n pair: input.pair,\n onDecorate: (offset) => {\n sendBack({type: 'decorator.add', blockOffset: offset})\n },\n }),\n })\n\n return unregister\n}\n\nconst selectionListenerCallback: CallbackLogicFunction<\n AnyEventObject,\n DecoratorPairEvent,\n {editor: Editor}\n> = ({sendBack, input}) => {\n const unregister = input.editor.registerBehavior({\n behavior: defineBehavior({\n on: 'select',\n guard: ({snapshot, event}) => {\n if (!event.at) {\n return {blockOffsets: undefined}\n }\n\n const anchor = utils.spanSelectionPointToBlockOffset({\n value: snapshot.context.value,\n selectionPoint: event.at.anchor,\n })\n const focus = utils.spanSelectionPointToBlockOffset({\n value: snapshot.context.value,\n selectionPoint: event.at.focus,\n })\n\n if (!anchor || !focus) {\n return {blockOffsets: undefined}\n }\n\n return {\n blockOffsets: {\n anchor,\n focus,\n },\n }\n },\n actions: [\n ({event}, {blockOffsets}) => [\n {\n type: 'effect',\n effect: () => {\n sendBack({type: 'selection', blockOffsets})\n },\n },\n forward(event),\n ],\n ],\n }),\n })\n\n return unregister\n}\n\nconst deleteBackwardListenerCallback: CallbackLogicFunction<\n AnyEventObject,\n DecoratorPairEvent,\n {editor: Editor}\n> = ({sendBack, input}) => {\n const unregister = input.editor.registerBehavior({\n behavior: defineBehavior({\n on: 'delete.backward',\n actions: [\n () => [\n execute({\n type: 'history.undo',\n }),\n effect(() => {\n sendBack({type: 'delete.backward'})\n }),\n ],\n ],\n }),\n })\n\n return unregister\n}\n\nconst decoratorPairMachine = setup({\n types: {\n context: {} as {\n decorator: ({schema}: {schema: EditorSchema}) => string | undefined\n editor: Editor\n offsetAfterDecorator?: BlockOffset\n pair: {char: string; amount: number}\n },\n input: {} as {\n decorator: ({schema}: {schema: EditorSchema}) => string | undefined\n editor: Editor\n pair: {char: string; amount: number}\n },\n events: {} as DecoratorPairEvent,\n },\n actors: {\n 'decorate listener': fromCallback(decorateListener),\n 'delete.backward listener': fromCallback(deleteBackwardListenerCallback),\n 'selection listener': fromCallback(selectionListenerCallback),\n },\n}).createMachine({\n id: 'decorator pair',\n context: ({input}) => ({\n decorator: input.decorator,\n editor: input.editor,\n pair: input.pair,\n }),\n initial: 'idle',\n states: {\n 'idle': {\n invoke: [\n {\n src: 'decorate listener',\n input: ({context}) => ({\n decorator: context.decorator,\n editor: context.editor,\n pair: context.pair,\n }),\n },\n ],\n on: {\n 'decorator.add': {\n target: 'decorator added',\n actions: assign({\n offsetAfterDecorator: ({event}) => event.blockOffset,\n }),\n },\n },\n },\n 'decorator added': {\n exit: [\n assign({\n offsetAfterDecorator: undefined,\n }),\n ],\n invoke: [\n {\n src: 'selection listener',\n input: ({context}) => ({editor: context.editor}),\n },\n {\n src: 'delete.backward listener',\n input: ({context}) => ({editor: context.editor}),\n },\n ],\n on: {\n 'selection': {\n target: 'idle',\n guard: ({context, event}) => {\n const selectionChanged = !isDeepEqual(\n {\n anchor: context.offsetAfterDecorator,\n focus: context.offsetAfterDecorator,\n },\n event.blockOffsets,\n )\n\n return selectionChanged\n },\n },\n 'delete.backward': {\n target: 'idle',\n },\n },\n },\n },\n})\n"],"names":["defineBehavior","selectors","utils","execute","effect","editor","useEditor","useActorRef","forward","setup","fromCallback","assign","isDeepEqual"],"mappings":"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"}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type {EditorSchema} from '@portabletext/editor'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @beta
|
|
5
|
+
*/
|
|
6
|
+
export declare function CharacterPairDecoratorPlugin(config: {
|
|
7
|
+
decorator: ({schema}: {schema: EditorSchema}) => string | undefined
|
|
8
|
+
pair: {
|
|
9
|
+
char: string
|
|
10
|
+
amount: number
|
|
11
|
+
}
|
|
12
|
+
}): null
|
|
13
|
+
|
|
14
|
+
export {}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type {EditorSchema} from '@portabletext/editor'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @beta
|
|
5
|
+
*/
|
|
6
|
+
export declare function CharacterPairDecoratorPlugin(config: {
|
|
7
|
+
decorator: ({schema}: {schema: EditorSchema}) => string | undefined
|
|
8
|
+
pair: {
|
|
9
|
+
char: string
|
|
10
|
+
amount: number
|
|
11
|
+
}
|
|
12
|
+
}): null
|
|
13
|
+
|
|
14
|
+
export {}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { useEditor } from "@portabletext/editor";
|
|
2
|
+
import { defineBehavior, execute, effect, forward } from "@portabletext/editor/behaviors";
|
|
3
|
+
import * as utils from "@portabletext/editor/utils";
|
|
4
|
+
import { useActorRef } from "@xstate/react";
|
|
5
|
+
import { isDeepEqual } from "remeda";
|
|
6
|
+
import { setup, fromCallback, assign } from "xstate";
|
|
7
|
+
import * as selectors from "@portabletext/editor/selectors";
|
|
8
|
+
function createCharacterPairRegex(char, amount) {
|
|
9
|
+
const prePrefix = `(?<!\\${char})`, prefix = `\\${char}`.repeat(Math.max(amount, 1)), postPrefix = "(?!\\s)", content = `([^${char}\\n]+?)`, preSuffix = "(?<!\\s)", suffix = `\\${char}`.repeat(Math.max(amount, 1)), postSuffix = `(?!\\${char})`;
|
|
10
|
+
return `${prePrefix}${prefix}${postPrefix}${content}${preSuffix}${suffix}${postSuffix}`;
|
|
11
|
+
}
|
|
12
|
+
function createCharacterPairDecoratorBehavior(config) {
|
|
13
|
+
config.pair.amount < 1 && console.warn(
|
|
14
|
+
"The amount of characters in the pair should be greater than 0"
|
|
15
|
+
);
|
|
16
|
+
const pairRegex = createCharacterPairRegex(
|
|
17
|
+
config.pair.char,
|
|
18
|
+
config.pair.amount
|
|
19
|
+
), regEx = new RegExp(`(${pairRegex})$`);
|
|
20
|
+
return defineBehavior({
|
|
21
|
+
on: "insert.text",
|
|
22
|
+
guard: ({ snapshot, event }) => {
|
|
23
|
+
if (config.pair.amount < 1)
|
|
24
|
+
return !1;
|
|
25
|
+
const decorator = config.decorator({ schema: snapshot.context.schema });
|
|
26
|
+
if (decorator === void 0)
|
|
27
|
+
return !1;
|
|
28
|
+
const focusTextBlock = selectors.getFocusTextBlock(snapshot), selectionStartPoint = selectors.getSelectionStartPoint(snapshot), selectionStartOffset = selectionStartPoint ? utils.spanSelectionPointToBlockOffset({
|
|
29
|
+
value: snapshot.context.value,
|
|
30
|
+
selectionPoint: selectionStartPoint
|
|
31
|
+
}) : void 0;
|
|
32
|
+
if (!focusTextBlock || !selectionStartOffset)
|
|
33
|
+
return !1;
|
|
34
|
+
const newText = `${selectors.getBlockTextBefore(snapshot)}${event.text}`, textToDecorate = newText.match(regEx)?.at(0);
|
|
35
|
+
if (textToDecorate === void 0)
|
|
36
|
+
return !1;
|
|
37
|
+
const prefixOffsets = {
|
|
38
|
+
anchor: {
|
|
39
|
+
path: focusTextBlock.path,
|
|
40
|
+
// Example: "foo **bar**".length - "**bar**".length = 4
|
|
41
|
+
offset: newText.length - textToDecorate.length
|
|
42
|
+
},
|
|
43
|
+
focus: {
|
|
44
|
+
path: focusTextBlock.path,
|
|
45
|
+
// Example: "foo **bar**".length - "**bar**".length + "*".length * 2 = 6
|
|
46
|
+
offset: newText.length - textToDecorate.length + config.pair.char.length * config.pair.amount
|
|
47
|
+
}
|
|
48
|
+
}, suffixOffsets = {
|
|
49
|
+
anchor: {
|
|
50
|
+
path: focusTextBlock.path,
|
|
51
|
+
// Example: "foo **bar*|" (10) + "*".length - 2 = 9
|
|
52
|
+
offset: selectionStartOffset.offset + event.text.length - config.pair.char.length * config.pair.amount
|
|
53
|
+
},
|
|
54
|
+
focus: {
|
|
55
|
+
path: focusTextBlock.path,
|
|
56
|
+
// Example: "foo **bar*|" (10) + "*".length = 11
|
|
57
|
+
offset: selectionStartOffset.offset + event.text.length
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
if (prefixOffsets.focus.offset - prefixOffsets.anchor.offset > 1) {
|
|
61
|
+
const prefixSelection = utils.blockOffsetsToSelection({
|
|
62
|
+
value: snapshot.context.value,
|
|
63
|
+
offsets: prefixOffsets
|
|
64
|
+
}), inlineObjectBeforePrefixFocus = selectors.getPreviousInlineObject(
|
|
65
|
+
{
|
|
66
|
+
...snapshot,
|
|
67
|
+
context: {
|
|
68
|
+
...snapshot.context,
|
|
69
|
+
selection: prefixSelection ? {
|
|
70
|
+
anchor: prefixSelection.focus,
|
|
71
|
+
focus: prefixSelection.focus
|
|
72
|
+
} : null
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
), inlineObjectBeforePrefixFocusOffset = inlineObjectBeforePrefixFocus ? utils.childSelectionPointToBlockOffset({
|
|
76
|
+
value: snapshot.context.value,
|
|
77
|
+
selectionPoint: {
|
|
78
|
+
path: inlineObjectBeforePrefixFocus.path,
|
|
79
|
+
offset: 0
|
|
80
|
+
}
|
|
81
|
+
}) : void 0;
|
|
82
|
+
if (inlineObjectBeforePrefixFocusOffset && inlineObjectBeforePrefixFocusOffset.offset > prefixOffsets.anchor.offset && inlineObjectBeforePrefixFocusOffset.offset < prefixOffsets.focus.offset)
|
|
83
|
+
return !1;
|
|
84
|
+
}
|
|
85
|
+
if (suffixOffsets.focus.offset - suffixOffsets.anchor.offset > 1) {
|
|
86
|
+
const previousInlineObject = selectors.getPreviousInlineObject(snapshot), previousInlineObjectOffset = previousInlineObject ? utils.childSelectionPointToBlockOffset({
|
|
87
|
+
value: snapshot.context.value,
|
|
88
|
+
selectionPoint: {
|
|
89
|
+
path: previousInlineObject.path,
|
|
90
|
+
offset: 0
|
|
91
|
+
}
|
|
92
|
+
}) : void 0;
|
|
93
|
+
if (previousInlineObjectOffset && previousInlineObjectOffset.offset > suffixOffsets.anchor.offset && previousInlineObjectOffset.offset < suffixOffsets.focus.offset)
|
|
94
|
+
return !1;
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
prefixOffsets,
|
|
98
|
+
suffixOffsets,
|
|
99
|
+
decorator
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
actions: [
|
|
103
|
+
// Insert the text as usual in its own undo step
|
|
104
|
+
({ event }) => [execute(event)],
|
|
105
|
+
(_, { prefixOffsets, suffixOffsets, decorator }) => [
|
|
106
|
+
// Decorate the text between the prefix and suffix
|
|
107
|
+
execute({
|
|
108
|
+
type: "decorator.add",
|
|
109
|
+
decorator,
|
|
110
|
+
at: {
|
|
111
|
+
anchor: prefixOffsets.focus,
|
|
112
|
+
focus: suffixOffsets.anchor
|
|
113
|
+
}
|
|
114
|
+
}),
|
|
115
|
+
// Delete the suffix
|
|
116
|
+
execute({
|
|
117
|
+
type: "delete.text",
|
|
118
|
+
at: suffixOffsets
|
|
119
|
+
}),
|
|
120
|
+
// Delete the prefix
|
|
121
|
+
execute({
|
|
122
|
+
type: "delete.text",
|
|
123
|
+
at: prefixOffsets
|
|
124
|
+
}),
|
|
125
|
+
// Toggle the decorator off so the next inserted text isn't emphasized
|
|
126
|
+
execute({
|
|
127
|
+
type: "decorator.remove",
|
|
128
|
+
decorator
|
|
129
|
+
}),
|
|
130
|
+
effect(() => {
|
|
131
|
+
config.onDecorate({
|
|
132
|
+
...suffixOffsets.anchor,
|
|
133
|
+
offset: suffixOffsets.anchor.offset - (prefixOffsets.focus.offset - prefixOffsets.anchor.offset)
|
|
134
|
+
});
|
|
135
|
+
})
|
|
136
|
+
]
|
|
137
|
+
]
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
function CharacterPairDecoratorPlugin(config) {
|
|
141
|
+
const editor = useEditor();
|
|
142
|
+
return useActorRef(decoratorPairMachine, {
|
|
143
|
+
input: {
|
|
144
|
+
editor,
|
|
145
|
+
decorator: config.decorator,
|
|
146
|
+
pair: config.pair
|
|
147
|
+
}
|
|
148
|
+
}), null;
|
|
149
|
+
}
|
|
150
|
+
const decorateListener = ({ sendBack, input }) => input.editor.registerBehavior({
|
|
151
|
+
behavior: createCharacterPairDecoratorBehavior({
|
|
152
|
+
decorator: input.decorator,
|
|
153
|
+
pair: input.pair,
|
|
154
|
+
onDecorate: (offset) => {
|
|
155
|
+
sendBack({ type: "decorator.add", blockOffset: offset });
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
}), selectionListenerCallback = ({ sendBack, input }) => input.editor.registerBehavior({
|
|
159
|
+
behavior: defineBehavior({
|
|
160
|
+
on: "select",
|
|
161
|
+
guard: ({ snapshot, event }) => {
|
|
162
|
+
if (!event.at)
|
|
163
|
+
return { blockOffsets: void 0 };
|
|
164
|
+
const anchor = utils.spanSelectionPointToBlockOffset({
|
|
165
|
+
value: snapshot.context.value,
|
|
166
|
+
selectionPoint: event.at.anchor
|
|
167
|
+
}), focus = utils.spanSelectionPointToBlockOffset({
|
|
168
|
+
value: snapshot.context.value,
|
|
169
|
+
selectionPoint: event.at.focus
|
|
170
|
+
});
|
|
171
|
+
return !anchor || !focus ? { blockOffsets: void 0 } : {
|
|
172
|
+
blockOffsets: {
|
|
173
|
+
anchor,
|
|
174
|
+
focus
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
},
|
|
178
|
+
actions: [
|
|
179
|
+
({ event }, { blockOffsets }) => [
|
|
180
|
+
{
|
|
181
|
+
type: "effect",
|
|
182
|
+
effect: () => {
|
|
183
|
+
sendBack({ type: "selection", blockOffsets });
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
forward(event)
|
|
187
|
+
]
|
|
188
|
+
]
|
|
189
|
+
})
|
|
190
|
+
}), deleteBackwardListenerCallback = ({ sendBack, input }) => input.editor.registerBehavior({
|
|
191
|
+
behavior: defineBehavior({
|
|
192
|
+
on: "delete.backward",
|
|
193
|
+
actions: [
|
|
194
|
+
() => [
|
|
195
|
+
execute({
|
|
196
|
+
type: "history.undo"
|
|
197
|
+
}),
|
|
198
|
+
effect(() => {
|
|
199
|
+
sendBack({ type: "delete.backward" });
|
|
200
|
+
})
|
|
201
|
+
]
|
|
202
|
+
]
|
|
203
|
+
})
|
|
204
|
+
}), decoratorPairMachine = setup({
|
|
205
|
+
types: {
|
|
206
|
+
context: {},
|
|
207
|
+
input: {},
|
|
208
|
+
events: {}
|
|
209
|
+
},
|
|
210
|
+
actors: {
|
|
211
|
+
"decorate listener": fromCallback(decorateListener),
|
|
212
|
+
"delete.backward listener": fromCallback(deleteBackwardListenerCallback),
|
|
213
|
+
"selection listener": fromCallback(selectionListenerCallback)
|
|
214
|
+
}
|
|
215
|
+
}).createMachine({
|
|
216
|
+
id: "decorator pair",
|
|
217
|
+
context: ({ input }) => ({
|
|
218
|
+
decorator: input.decorator,
|
|
219
|
+
editor: input.editor,
|
|
220
|
+
pair: input.pair
|
|
221
|
+
}),
|
|
222
|
+
initial: "idle",
|
|
223
|
+
states: {
|
|
224
|
+
idle: {
|
|
225
|
+
invoke: [
|
|
226
|
+
{
|
|
227
|
+
src: "decorate listener",
|
|
228
|
+
input: ({ context }) => ({
|
|
229
|
+
decorator: context.decorator,
|
|
230
|
+
editor: context.editor,
|
|
231
|
+
pair: context.pair
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
],
|
|
235
|
+
on: {
|
|
236
|
+
"decorator.add": {
|
|
237
|
+
target: "decorator added",
|
|
238
|
+
actions: assign({
|
|
239
|
+
offsetAfterDecorator: ({ event }) => event.blockOffset
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
"decorator added": {
|
|
245
|
+
exit: [
|
|
246
|
+
assign({
|
|
247
|
+
offsetAfterDecorator: void 0
|
|
248
|
+
})
|
|
249
|
+
],
|
|
250
|
+
invoke: [
|
|
251
|
+
{
|
|
252
|
+
src: "selection listener",
|
|
253
|
+
input: ({ context }) => ({ editor: context.editor })
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
src: "delete.backward listener",
|
|
257
|
+
input: ({ context }) => ({ editor: context.editor })
|
|
258
|
+
}
|
|
259
|
+
],
|
|
260
|
+
on: {
|
|
261
|
+
selection: {
|
|
262
|
+
target: "idle",
|
|
263
|
+
guard: ({ context, event }) => !isDeepEqual(
|
|
264
|
+
{
|
|
265
|
+
anchor: context.offsetAfterDecorator,
|
|
266
|
+
focus: context.offsetAfterDecorator
|
|
267
|
+
},
|
|
268
|
+
event.blockOffsets
|
|
269
|
+
)
|
|
270
|
+
},
|
|
271
|
+
"delete.backward": {
|
|
272
|
+
target: "idle"
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
export {
|
|
279
|
+
CharacterPairDecoratorPlugin
|
|
280
|
+
};
|
|
281
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sources":["../src/regex.character-pair.ts","../src/behavior.character-pair-decorator.ts","../src/plugin.character-pair-decorator.ts"],"sourcesContent":["export function createCharacterPairRegex(char: string, amount: number) {\n // Negative lookbehind: Ensures that the matched sequence is not preceded by the same character\n const prePrefix = `(?<!\\\\${char})`\n\n // Repeats the character `amount` times\n const prefix = `\\\\${char}`.repeat(Math.max(amount, 1))\n\n // Negative lookahead: Ensures that the opening pair (**, *, etc.) is not followed by a space\n const postPrefix = `(?!\\\\s)`\n\n // Captures the content inside the pair\n const content = `([^${char}\\\\n]+?)`\n\n // Negative lookbehind: Ensures that the content is not followed by a space\n const preSuffix = `(?<!\\\\s)`\n\n // Repeats the character `amount` times\n const suffix = `\\\\${char}`.repeat(Math.max(amount, 1))\n\n // Negative lookahead: Ensures that the matched sequence is not followed by the same character\n const postSuffix = `(?!\\\\${char})`\n\n return `${prePrefix}${prefix}${postPrefix}${content}${preSuffix}${suffix}${postSuffix}`\n}\n","import type {BlockOffset, EditorSchema} from '@portabletext/editor'\nimport {defineBehavior, effect, execute} from '@portabletext/editor/behaviors'\nimport * as selectors from '@portabletext/editor/selectors'\nimport * as utils from '@portabletext/editor/utils'\nimport {createCharacterPairRegex} from './regex.character-pair'\n\nexport function createCharacterPairDecoratorBehavior(config: {\n decorator: ({schema}: {schema: EditorSchema}) => string | undefined\n pair: {char: string; amount: number}\n onDecorate: (offset: BlockOffset) => void\n}) {\n if (config.pair.amount < 1) {\n console.warn(\n `The amount of characters in the pair should be greater than 0`,\n )\n }\n\n const pairRegex = createCharacterPairRegex(\n config.pair.char,\n config.pair.amount,\n )\n const regEx = new RegExp(`(${pairRegex})$`)\n\n return defineBehavior({\n on: 'insert.text',\n guard: ({snapshot, event}) => {\n if (config.pair.amount < 1) {\n return false\n }\n\n const decorator = config.decorator({schema: snapshot.context.schema})\n\n if (decorator === undefined) {\n return false\n }\n\n const focusTextBlock = selectors.getFocusTextBlock(snapshot)\n const selectionStartPoint = selectors.getSelectionStartPoint(snapshot)\n const selectionStartOffset = selectionStartPoint\n ? utils.spanSelectionPointToBlockOffset({\n value: snapshot.context.value,\n selectionPoint: selectionStartPoint,\n })\n : undefined\n\n if (!focusTextBlock || !selectionStartOffset) {\n return false\n }\n\n const textBefore = selectors.getBlockTextBefore(snapshot)\n const newText = `${textBefore}${event.text}`\n const textToDecorate = newText.match(regEx)?.at(0)\n\n if (textToDecorate === undefined) {\n return false\n }\n\n const prefixOffsets = {\n anchor: {\n path: focusTextBlock.path,\n // Example: \"foo **bar**\".length - \"**bar**\".length = 4\n offset: newText.length - textToDecorate.length,\n },\n focus: {\n path: focusTextBlock.path,\n // Example: \"foo **bar**\".length - \"**bar**\".length + \"*\".length * 2 = 6\n offset:\n newText.length -\n textToDecorate.length +\n config.pair.char.length * config.pair.amount,\n },\n }\n\n const suffixOffsets = {\n anchor: {\n path: focusTextBlock.path,\n // Example: \"foo **bar*|\" (10) + \"*\".length - 2 = 9\n offset:\n selectionStartOffset.offset +\n event.text.length -\n config.pair.char.length * config.pair.amount,\n },\n focus: {\n path: focusTextBlock.path,\n // Example: \"foo **bar*|\" (10) + \"*\".length = 11\n offset: selectionStartOffset.offset + event.text.length,\n },\n }\n\n // If the prefix is more than one character, then we need to check if\n // there is an inline object inside it\n if (prefixOffsets.focus.offset - prefixOffsets.anchor.offset > 1) {\n const prefixSelection = utils.blockOffsetsToSelection({\n value: snapshot.context.value,\n offsets: prefixOffsets,\n })\n const inlineObjectBeforePrefixFocus = selectors.getPreviousInlineObject(\n {\n ...snapshot,\n context: {\n ...snapshot.context,\n selection: prefixSelection\n ? {\n anchor: prefixSelection.focus,\n focus: prefixSelection.focus,\n }\n : null,\n },\n },\n )\n const inlineObjectBeforePrefixFocusOffset =\n inlineObjectBeforePrefixFocus\n ? utils.childSelectionPointToBlockOffset({\n value: snapshot.context.value,\n selectionPoint: {\n path: inlineObjectBeforePrefixFocus.path,\n offset: 0,\n },\n })\n : undefined\n\n if (\n inlineObjectBeforePrefixFocusOffset &&\n inlineObjectBeforePrefixFocusOffset.offset >\n prefixOffsets.anchor.offset &&\n inlineObjectBeforePrefixFocusOffset.offset <\n prefixOffsets.focus.offset\n ) {\n return false\n }\n }\n\n // If the suffix is more than one character, then we need to check if\n // there is an inline object inside it\n if (suffixOffsets.focus.offset - suffixOffsets.anchor.offset > 1) {\n const previousInlineObject = selectors.getPreviousInlineObject(snapshot)\n const previousInlineObjectOffset = previousInlineObject\n ? utils.childSelectionPointToBlockOffset({\n value: snapshot.context.value,\n selectionPoint: {\n path: previousInlineObject.path,\n offset: 0,\n },\n })\n : undefined\n\n if (\n previousInlineObjectOffset &&\n previousInlineObjectOffset.offset > suffixOffsets.anchor.offset &&\n previousInlineObjectOffset.offset < suffixOffsets.focus.offset\n ) {\n return false\n }\n }\n\n return {\n prefixOffsets,\n suffixOffsets,\n decorator,\n }\n },\n actions: [\n // Insert the text as usual in its own undo step\n ({event}) => [execute(event)],\n (_, {prefixOffsets, suffixOffsets, decorator}) => [\n // Decorate the text between the prefix and suffix\n execute({\n type: 'decorator.add',\n decorator,\n at: {\n anchor: prefixOffsets.focus,\n focus: suffixOffsets.anchor,\n },\n }),\n // Delete the suffix\n execute({\n type: 'delete.text',\n at: suffixOffsets,\n }),\n // Delete the prefix\n execute({\n type: 'delete.text',\n at: prefixOffsets,\n }),\n // Toggle the decorator off so the next inserted text isn't emphasized\n execute({\n type: 'decorator.remove',\n decorator,\n }),\n effect(() => {\n config.onDecorate({\n ...suffixOffsets.anchor,\n offset:\n suffixOffsets.anchor.offset -\n (prefixOffsets.focus.offset - prefixOffsets.anchor.offset),\n })\n }),\n ],\n ],\n })\n}\n","import type {BlockOffset, Editor, EditorSchema} from '@portabletext/editor'\nimport {useEditor} from '@portabletext/editor'\nimport {\n defineBehavior,\n effect,\n execute,\n forward,\n} from '@portabletext/editor/behaviors'\nimport * as utils from '@portabletext/editor/utils'\nimport {useActorRef} from '@xstate/react'\nimport {isDeepEqual} from 'remeda'\nimport {\n assign,\n fromCallback,\n setup,\n type AnyEventObject,\n type CallbackLogicFunction,\n} from 'xstate'\nimport {createCharacterPairDecoratorBehavior} from './behavior.character-pair-decorator'\n\n/**\n * @beta\n */\nexport function CharacterPairDecoratorPlugin(config: {\n decorator: ({schema}: {schema: EditorSchema}) => string | undefined\n pair: {char: string; amount: number}\n}) {\n const editor = useEditor()\n\n useActorRef(decoratorPairMachine, {\n input: {\n editor,\n decorator: config.decorator,\n pair: config.pair,\n },\n })\n\n return null\n}\n\ntype DecoratorPairEvent =\n | {\n type: 'decorator.add'\n blockOffset: BlockOffset\n }\n | {\n type: 'selection'\n blockOffsets?: {\n anchor: BlockOffset\n focus: BlockOffset\n }\n }\n | {\n type: 'delete.backward'\n }\n\nconst decorateListener: CallbackLogicFunction<\n AnyEventObject,\n DecoratorPairEvent,\n {\n decorator: ({schema}: {schema: EditorSchema}) => string | undefined\n editor: Editor\n pair: {char: string; amount: number}\n }\n> = ({sendBack, input}) => {\n const unregister = input.editor.registerBehavior({\n behavior: createCharacterPairDecoratorBehavior({\n decorator: input.decorator,\n pair: input.pair,\n onDecorate: (offset) => {\n sendBack({type: 'decorator.add', blockOffset: offset})\n },\n }),\n })\n\n return unregister\n}\n\nconst selectionListenerCallback: CallbackLogicFunction<\n AnyEventObject,\n DecoratorPairEvent,\n {editor: Editor}\n> = ({sendBack, input}) => {\n const unregister = input.editor.registerBehavior({\n behavior: defineBehavior({\n on: 'select',\n guard: ({snapshot, event}) => {\n if (!event.at) {\n return {blockOffsets: undefined}\n }\n\n const anchor = utils.spanSelectionPointToBlockOffset({\n value: snapshot.context.value,\n selectionPoint: event.at.anchor,\n })\n const focus = utils.spanSelectionPointToBlockOffset({\n value: snapshot.context.value,\n selectionPoint: event.at.focus,\n })\n\n if (!anchor || !focus) {\n return {blockOffsets: undefined}\n }\n\n return {\n blockOffsets: {\n anchor,\n focus,\n },\n }\n },\n actions: [\n ({event}, {blockOffsets}) => [\n {\n type: 'effect',\n effect: () => {\n sendBack({type: 'selection', blockOffsets})\n },\n },\n forward(event),\n ],\n ],\n }),\n })\n\n return unregister\n}\n\nconst deleteBackwardListenerCallback: CallbackLogicFunction<\n AnyEventObject,\n DecoratorPairEvent,\n {editor: Editor}\n> = ({sendBack, input}) => {\n const unregister = input.editor.registerBehavior({\n behavior: defineBehavior({\n on: 'delete.backward',\n actions: [\n () => [\n execute({\n type: 'history.undo',\n }),\n effect(() => {\n sendBack({type: 'delete.backward'})\n }),\n ],\n ],\n }),\n })\n\n return unregister\n}\n\nconst decoratorPairMachine = setup({\n types: {\n context: {} as {\n decorator: ({schema}: {schema: EditorSchema}) => string | undefined\n editor: Editor\n offsetAfterDecorator?: BlockOffset\n pair: {char: string; amount: number}\n },\n input: {} as {\n decorator: ({schema}: {schema: EditorSchema}) => string | undefined\n editor: Editor\n pair: {char: string; amount: number}\n },\n events: {} as DecoratorPairEvent,\n },\n actors: {\n 'decorate listener': fromCallback(decorateListener),\n 'delete.backward listener': fromCallback(deleteBackwardListenerCallback),\n 'selection listener': fromCallback(selectionListenerCallback),\n },\n}).createMachine({\n id: 'decorator pair',\n context: ({input}) => ({\n decorator: input.decorator,\n editor: input.editor,\n pair: input.pair,\n }),\n initial: 'idle',\n states: {\n 'idle': {\n invoke: [\n {\n src: 'decorate listener',\n input: ({context}) => ({\n decorator: context.decorator,\n editor: context.editor,\n pair: context.pair,\n }),\n },\n ],\n on: {\n 'decorator.add': {\n target: 'decorator added',\n actions: assign({\n offsetAfterDecorator: ({event}) => event.blockOffset,\n }),\n },\n },\n },\n 'decorator added': {\n exit: [\n assign({\n offsetAfterDecorator: undefined,\n }),\n ],\n invoke: [\n {\n src: 'selection listener',\n input: ({context}) => ({editor: context.editor}),\n },\n {\n src: 'delete.backward listener',\n input: ({context}) => ({editor: context.editor}),\n },\n ],\n on: {\n 'selection': {\n target: 'idle',\n guard: ({context, event}) => {\n const selectionChanged = !isDeepEqual(\n {\n anchor: context.offsetAfterDecorator,\n focus: context.offsetAfterDecorator,\n },\n event.blockOffsets,\n )\n\n return selectionChanged\n },\n },\n 'delete.backward': {\n target: 'idle',\n },\n },\n },\n },\n})\n"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@portabletext/plugin-character-pair-decorator",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Automatically match a pair of characters and decorate the text in between",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"portabletext",
|
|
7
|
+
"plugin",
|
|
8
|
+
"pair",
|
|
9
|
+
"decorator",
|
|
10
|
+
"behaviors"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://portabletext.org",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/portabletext/plugins/issues"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/portabletext/plugins.git",
|
|
19
|
+
"directory": "plugins/character-pair-decorator"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"author": "Sanity.io <hello@sanity.io>",
|
|
23
|
+
"sideEffects": false,
|
|
24
|
+
"type": "module",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"source": "./src/index.ts",
|
|
28
|
+
"import": "./dist/index.js",
|
|
29
|
+
"require": "./dist/index.cjs",
|
|
30
|
+
"default": "./dist/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./package.json": "./package.json"
|
|
33
|
+
},
|
|
34
|
+
"main": "./dist/index.cjs",
|
|
35
|
+
"module": "./dist/index.js",
|
|
36
|
+
"types": "./dist/index.d.ts",
|
|
37
|
+
"files": [
|
|
38
|
+
"src",
|
|
39
|
+
"dist"
|
|
40
|
+
],
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@portabletext/editor": "^1.48.4",
|
|
43
|
+
"@types/react": "^19.1.2",
|
|
44
|
+
"react": "^19.1.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@portabletext/editor": "^1.48.3",
|
|
48
|
+
"react": "^19.1.0"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@xstate/react": "^5.0.3",
|
|
52
|
+
"remeda": "^2.21.3",
|
|
53
|
+
"xstate": "^5.19.2"
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "pkg-utils build --strict --check --clean",
|
|
57
|
+
"check:lint": "biome lint .",
|
|
58
|
+
"check:react-compiler": "eslint --cache --no-inline-config --no-eslintrc --ignore-pattern '**/__tests__/**' --ext .cjs,.mjs,.js,.jsx,.ts,.tsx --parser @typescript-eslint/parser --plugin react-compiler --plugin react-hooks --rule 'react-compiler/react-compiler: [warn]' --rule 'react-hooks/rules-of-hooks: [error]' --rule 'react-hooks/exhaustive-deps: [error]' src",
|
|
59
|
+
"check:types": "tsc",
|
|
60
|
+
"check:types:watch": "tsc --watch",
|
|
61
|
+
"clean": "del .turbo && del dist && del node_modules",
|
|
62
|
+
"dev": "pkg-utils watch",
|
|
63
|
+
"lint:fix": "biome lint --write .",
|
|
64
|
+
"test:unit": "vitest --run",
|
|
65
|
+
"test:unit:watch": "vitest"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type {BlockOffset, EditorSchema} from '@portabletext/editor'
|
|
2
|
+
import {defineBehavior, effect, execute} from '@portabletext/editor/behaviors'
|
|
3
|
+
import * as selectors from '@portabletext/editor/selectors'
|
|
4
|
+
import * as utils from '@portabletext/editor/utils'
|
|
5
|
+
import {createCharacterPairRegex} from './regex.character-pair'
|
|
6
|
+
|
|
7
|
+
export function createCharacterPairDecoratorBehavior(config: {
|
|
8
|
+
decorator: ({schema}: {schema: EditorSchema}) => string | undefined
|
|
9
|
+
pair: {char: string; amount: number}
|
|
10
|
+
onDecorate: (offset: BlockOffset) => void
|
|
11
|
+
}) {
|
|
12
|
+
if (config.pair.amount < 1) {
|
|
13
|
+
console.warn(
|
|
14
|
+
`The amount of characters in the pair should be greater than 0`,
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const pairRegex = createCharacterPairRegex(
|
|
19
|
+
config.pair.char,
|
|
20
|
+
config.pair.amount,
|
|
21
|
+
)
|
|
22
|
+
const regEx = new RegExp(`(${pairRegex})$`)
|
|
23
|
+
|
|
24
|
+
return defineBehavior({
|
|
25
|
+
on: 'insert.text',
|
|
26
|
+
guard: ({snapshot, event}) => {
|
|
27
|
+
if (config.pair.amount < 1) {
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const decorator = config.decorator({schema: snapshot.context.schema})
|
|
32
|
+
|
|
33
|
+
if (decorator === undefined) {
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const focusTextBlock = selectors.getFocusTextBlock(snapshot)
|
|
38
|
+
const selectionStartPoint = selectors.getSelectionStartPoint(snapshot)
|
|
39
|
+
const selectionStartOffset = selectionStartPoint
|
|
40
|
+
? utils.spanSelectionPointToBlockOffset({
|
|
41
|
+
value: snapshot.context.value,
|
|
42
|
+
selectionPoint: selectionStartPoint,
|
|
43
|
+
})
|
|
44
|
+
: undefined
|
|
45
|
+
|
|
46
|
+
if (!focusTextBlock || !selectionStartOffset) {
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const textBefore = selectors.getBlockTextBefore(snapshot)
|
|
51
|
+
const newText = `${textBefore}${event.text}`
|
|
52
|
+
const textToDecorate = newText.match(regEx)?.at(0)
|
|
53
|
+
|
|
54
|
+
if (textToDecorate === undefined) {
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const prefixOffsets = {
|
|
59
|
+
anchor: {
|
|
60
|
+
path: focusTextBlock.path,
|
|
61
|
+
// Example: "foo **bar**".length - "**bar**".length = 4
|
|
62
|
+
offset: newText.length - textToDecorate.length,
|
|
63
|
+
},
|
|
64
|
+
focus: {
|
|
65
|
+
path: focusTextBlock.path,
|
|
66
|
+
// Example: "foo **bar**".length - "**bar**".length + "*".length * 2 = 6
|
|
67
|
+
offset:
|
|
68
|
+
newText.length -
|
|
69
|
+
textToDecorate.length +
|
|
70
|
+
config.pair.char.length * config.pair.amount,
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const suffixOffsets = {
|
|
75
|
+
anchor: {
|
|
76
|
+
path: focusTextBlock.path,
|
|
77
|
+
// Example: "foo **bar*|" (10) + "*".length - 2 = 9
|
|
78
|
+
offset:
|
|
79
|
+
selectionStartOffset.offset +
|
|
80
|
+
event.text.length -
|
|
81
|
+
config.pair.char.length * config.pair.amount,
|
|
82
|
+
},
|
|
83
|
+
focus: {
|
|
84
|
+
path: focusTextBlock.path,
|
|
85
|
+
// Example: "foo **bar*|" (10) + "*".length = 11
|
|
86
|
+
offset: selectionStartOffset.offset + event.text.length,
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// If the prefix is more than one character, then we need to check if
|
|
91
|
+
// there is an inline object inside it
|
|
92
|
+
if (prefixOffsets.focus.offset - prefixOffsets.anchor.offset > 1) {
|
|
93
|
+
const prefixSelection = utils.blockOffsetsToSelection({
|
|
94
|
+
value: snapshot.context.value,
|
|
95
|
+
offsets: prefixOffsets,
|
|
96
|
+
})
|
|
97
|
+
const inlineObjectBeforePrefixFocus = selectors.getPreviousInlineObject(
|
|
98
|
+
{
|
|
99
|
+
...snapshot,
|
|
100
|
+
context: {
|
|
101
|
+
...snapshot.context,
|
|
102
|
+
selection: prefixSelection
|
|
103
|
+
? {
|
|
104
|
+
anchor: prefixSelection.focus,
|
|
105
|
+
focus: prefixSelection.focus,
|
|
106
|
+
}
|
|
107
|
+
: null,
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
const inlineObjectBeforePrefixFocusOffset =
|
|
112
|
+
inlineObjectBeforePrefixFocus
|
|
113
|
+
? utils.childSelectionPointToBlockOffset({
|
|
114
|
+
value: snapshot.context.value,
|
|
115
|
+
selectionPoint: {
|
|
116
|
+
path: inlineObjectBeforePrefixFocus.path,
|
|
117
|
+
offset: 0,
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
: undefined
|
|
121
|
+
|
|
122
|
+
if (
|
|
123
|
+
inlineObjectBeforePrefixFocusOffset &&
|
|
124
|
+
inlineObjectBeforePrefixFocusOffset.offset >
|
|
125
|
+
prefixOffsets.anchor.offset &&
|
|
126
|
+
inlineObjectBeforePrefixFocusOffset.offset <
|
|
127
|
+
prefixOffsets.focus.offset
|
|
128
|
+
) {
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// If the suffix is more than one character, then we need to check if
|
|
134
|
+
// there is an inline object inside it
|
|
135
|
+
if (suffixOffsets.focus.offset - suffixOffsets.anchor.offset > 1) {
|
|
136
|
+
const previousInlineObject = selectors.getPreviousInlineObject(snapshot)
|
|
137
|
+
const previousInlineObjectOffset = previousInlineObject
|
|
138
|
+
? utils.childSelectionPointToBlockOffset({
|
|
139
|
+
value: snapshot.context.value,
|
|
140
|
+
selectionPoint: {
|
|
141
|
+
path: previousInlineObject.path,
|
|
142
|
+
offset: 0,
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
: undefined
|
|
146
|
+
|
|
147
|
+
if (
|
|
148
|
+
previousInlineObjectOffset &&
|
|
149
|
+
previousInlineObjectOffset.offset > suffixOffsets.anchor.offset &&
|
|
150
|
+
previousInlineObjectOffset.offset < suffixOffsets.focus.offset
|
|
151
|
+
) {
|
|
152
|
+
return false
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
prefixOffsets,
|
|
158
|
+
suffixOffsets,
|
|
159
|
+
decorator,
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
actions: [
|
|
163
|
+
// Insert the text as usual in its own undo step
|
|
164
|
+
({event}) => [execute(event)],
|
|
165
|
+
(_, {prefixOffsets, suffixOffsets, decorator}) => [
|
|
166
|
+
// Decorate the text between the prefix and suffix
|
|
167
|
+
execute({
|
|
168
|
+
type: 'decorator.add',
|
|
169
|
+
decorator,
|
|
170
|
+
at: {
|
|
171
|
+
anchor: prefixOffsets.focus,
|
|
172
|
+
focus: suffixOffsets.anchor,
|
|
173
|
+
},
|
|
174
|
+
}),
|
|
175
|
+
// Delete the suffix
|
|
176
|
+
execute({
|
|
177
|
+
type: 'delete.text',
|
|
178
|
+
at: suffixOffsets,
|
|
179
|
+
}),
|
|
180
|
+
// Delete the prefix
|
|
181
|
+
execute({
|
|
182
|
+
type: 'delete.text',
|
|
183
|
+
at: prefixOffsets,
|
|
184
|
+
}),
|
|
185
|
+
// Toggle the decorator off so the next inserted text isn't emphasized
|
|
186
|
+
execute({
|
|
187
|
+
type: 'decorator.remove',
|
|
188
|
+
decorator,
|
|
189
|
+
}),
|
|
190
|
+
effect(() => {
|
|
191
|
+
config.onDecorate({
|
|
192
|
+
...suffixOffsets.anchor,
|
|
193
|
+
offset:
|
|
194
|
+
suffixOffsets.anchor.offset -
|
|
195
|
+
(prefixOffsets.focus.offset - prefixOffsets.anchor.offset),
|
|
196
|
+
})
|
|
197
|
+
}),
|
|
198
|
+
],
|
|
199
|
+
],
|
|
200
|
+
})
|
|
201
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './plugin.character-pair-decorator'
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import type {BlockOffset, Editor, EditorSchema} from '@portabletext/editor'
|
|
2
|
+
import {useEditor} from '@portabletext/editor'
|
|
3
|
+
import {
|
|
4
|
+
defineBehavior,
|
|
5
|
+
effect,
|
|
6
|
+
execute,
|
|
7
|
+
forward,
|
|
8
|
+
} from '@portabletext/editor/behaviors'
|
|
9
|
+
import * as utils from '@portabletext/editor/utils'
|
|
10
|
+
import {useActorRef} from '@xstate/react'
|
|
11
|
+
import {isDeepEqual} from 'remeda'
|
|
12
|
+
import {
|
|
13
|
+
assign,
|
|
14
|
+
fromCallback,
|
|
15
|
+
setup,
|
|
16
|
+
type AnyEventObject,
|
|
17
|
+
type CallbackLogicFunction,
|
|
18
|
+
} from 'xstate'
|
|
19
|
+
import {createCharacterPairDecoratorBehavior} from './behavior.character-pair-decorator'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @beta
|
|
23
|
+
*/
|
|
24
|
+
export function CharacterPairDecoratorPlugin(config: {
|
|
25
|
+
decorator: ({schema}: {schema: EditorSchema}) => string | undefined
|
|
26
|
+
pair: {char: string; amount: number}
|
|
27
|
+
}) {
|
|
28
|
+
const editor = useEditor()
|
|
29
|
+
|
|
30
|
+
useActorRef(decoratorPairMachine, {
|
|
31
|
+
input: {
|
|
32
|
+
editor,
|
|
33
|
+
decorator: config.decorator,
|
|
34
|
+
pair: config.pair,
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type DecoratorPairEvent =
|
|
42
|
+
| {
|
|
43
|
+
type: 'decorator.add'
|
|
44
|
+
blockOffset: BlockOffset
|
|
45
|
+
}
|
|
46
|
+
| {
|
|
47
|
+
type: 'selection'
|
|
48
|
+
blockOffsets?: {
|
|
49
|
+
anchor: BlockOffset
|
|
50
|
+
focus: BlockOffset
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
| {
|
|
54
|
+
type: 'delete.backward'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const decorateListener: CallbackLogicFunction<
|
|
58
|
+
AnyEventObject,
|
|
59
|
+
DecoratorPairEvent,
|
|
60
|
+
{
|
|
61
|
+
decorator: ({schema}: {schema: EditorSchema}) => string | undefined
|
|
62
|
+
editor: Editor
|
|
63
|
+
pair: {char: string; amount: number}
|
|
64
|
+
}
|
|
65
|
+
> = ({sendBack, input}) => {
|
|
66
|
+
const unregister = input.editor.registerBehavior({
|
|
67
|
+
behavior: createCharacterPairDecoratorBehavior({
|
|
68
|
+
decorator: input.decorator,
|
|
69
|
+
pair: input.pair,
|
|
70
|
+
onDecorate: (offset) => {
|
|
71
|
+
sendBack({type: 'decorator.add', blockOffset: offset})
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
return unregister
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const selectionListenerCallback: CallbackLogicFunction<
|
|
80
|
+
AnyEventObject,
|
|
81
|
+
DecoratorPairEvent,
|
|
82
|
+
{editor: Editor}
|
|
83
|
+
> = ({sendBack, input}) => {
|
|
84
|
+
const unregister = input.editor.registerBehavior({
|
|
85
|
+
behavior: defineBehavior({
|
|
86
|
+
on: 'select',
|
|
87
|
+
guard: ({snapshot, event}) => {
|
|
88
|
+
if (!event.at) {
|
|
89
|
+
return {blockOffsets: undefined}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const anchor = utils.spanSelectionPointToBlockOffset({
|
|
93
|
+
value: snapshot.context.value,
|
|
94
|
+
selectionPoint: event.at.anchor,
|
|
95
|
+
})
|
|
96
|
+
const focus = utils.spanSelectionPointToBlockOffset({
|
|
97
|
+
value: snapshot.context.value,
|
|
98
|
+
selectionPoint: event.at.focus,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
if (!anchor || !focus) {
|
|
102
|
+
return {blockOffsets: undefined}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
blockOffsets: {
|
|
107
|
+
anchor,
|
|
108
|
+
focus,
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
actions: [
|
|
113
|
+
({event}, {blockOffsets}) => [
|
|
114
|
+
{
|
|
115
|
+
type: 'effect',
|
|
116
|
+
effect: () => {
|
|
117
|
+
sendBack({type: 'selection', blockOffsets})
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
forward(event),
|
|
121
|
+
],
|
|
122
|
+
],
|
|
123
|
+
}),
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
return unregister
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const deleteBackwardListenerCallback: CallbackLogicFunction<
|
|
130
|
+
AnyEventObject,
|
|
131
|
+
DecoratorPairEvent,
|
|
132
|
+
{editor: Editor}
|
|
133
|
+
> = ({sendBack, input}) => {
|
|
134
|
+
const unregister = input.editor.registerBehavior({
|
|
135
|
+
behavior: defineBehavior({
|
|
136
|
+
on: 'delete.backward',
|
|
137
|
+
actions: [
|
|
138
|
+
() => [
|
|
139
|
+
execute({
|
|
140
|
+
type: 'history.undo',
|
|
141
|
+
}),
|
|
142
|
+
effect(() => {
|
|
143
|
+
sendBack({type: 'delete.backward'})
|
|
144
|
+
}),
|
|
145
|
+
],
|
|
146
|
+
],
|
|
147
|
+
}),
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
return unregister
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const decoratorPairMachine = setup({
|
|
154
|
+
types: {
|
|
155
|
+
context: {} as {
|
|
156
|
+
decorator: ({schema}: {schema: EditorSchema}) => string | undefined
|
|
157
|
+
editor: Editor
|
|
158
|
+
offsetAfterDecorator?: BlockOffset
|
|
159
|
+
pair: {char: string; amount: number}
|
|
160
|
+
},
|
|
161
|
+
input: {} as {
|
|
162
|
+
decorator: ({schema}: {schema: EditorSchema}) => string | undefined
|
|
163
|
+
editor: Editor
|
|
164
|
+
pair: {char: string; amount: number}
|
|
165
|
+
},
|
|
166
|
+
events: {} as DecoratorPairEvent,
|
|
167
|
+
},
|
|
168
|
+
actors: {
|
|
169
|
+
'decorate listener': fromCallback(decorateListener),
|
|
170
|
+
'delete.backward listener': fromCallback(deleteBackwardListenerCallback),
|
|
171
|
+
'selection listener': fromCallback(selectionListenerCallback),
|
|
172
|
+
},
|
|
173
|
+
}).createMachine({
|
|
174
|
+
id: 'decorator pair',
|
|
175
|
+
context: ({input}) => ({
|
|
176
|
+
decorator: input.decorator,
|
|
177
|
+
editor: input.editor,
|
|
178
|
+
pair: input.pair,
|
|
179
|
+
}),
|
|
180
|
+
initial: 'idle',
|
|
181
|
+
states: {
|
|
182
|
+
'idle': {
|
|
183
|
+
invoke: [
|
|
184
|
+
{
|
|
185
|
+
src: 'decorate listener',
|
|
186
|
+
input: ({context}) => ({
|
|
187
|
+
decorator: context.decorator,
|
|
188
|
+
editor: context.editor,
|
|
189
|
+
pair: context.pair,
|
|
190
|
+
}),
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
on: {
|
|
194
|
+
'decorator.add': {
|
|
195
|
+
target: 'decorator added',
|
|
196
|
+
actions: assign({
|
|
197
|
+
offsetAfterDecorator: ({event}) => event.blockOffset,
|
|
198
|
+
}),
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
'decorator added': {
|
|
203
|
+
exit: [
|
|
204
|
+
assign({
|
|
205
|
+
offsetAfterDecorator: undefined,
|
|
206
|
+
}),
|
|
207
|
+
],
|
|
208
|
+
invoke: [
|
|
209
|
+
{
|
|
210
|
+
src: 'selection listener',
|
|
211
|
+
input: ({context}) => ({editor: context.editor}),
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
src: 'delete.backward listener',
|
|
215
|
+
input: ({context}) => ({editor: context.editor}),
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
on: {
|
|
219
|
+
'selection': {
|
|
220
|
+
target: 'idle',
|
|
221
|
+
guard: ({context, event}) => {
|
|
222
|
+
const selectionChanged = !isDeepEqual(
|
|
223
|
+
{
|
|
224
|
+
anchor: context.offsetAfterDecorator,
|
|
225
|
+
focus: context.offsetAfterDecorator,
|
|
226
|
+
},
|
|
227
|
+
event.blockOffsets,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
return selectionChanged
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
'delete.backward': {
|
|
234
|
+
target: 'idle',
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {expect, test} from 'vitest'
|
|
2
|
+
import {createCharacterPairRegex} from './regex.character-pair'
|
|
3
|
+
|
|
4
|
+
const italicRegex = new RegExp(
|
|
5
|
+
`(${createCharacterPairRegex('*', 1)}|${createCharacterPairRegex('_', 1)})$`,
|
|
6
|
+
)
|
|
7
|
+
function getTextToItalic(text: string) {
|
|
8
|
+
return text.match(italicRegex)?.at(0)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const boldRegex = new RegExp(
|
|
12
|
+
`(${createCharacterPairRegex('*', 2)}|${createCharacterPairRegex('_', 2)})$`,
|
|
13
|
+
)
|
|
14
|
+
function getTextToBold(text: string) {
|
|
15
|
+
return text.match(boldRegex)?.at(0)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test(getTextToItalic.name, () => {
|
|
19
|
+
expect(getTextToItalic('Hello *world*')).toBe('*world*')
|
|
20
|
+
expect(getTextToItalic('Hello _world_')).toBe('_world_')
|
|
21
|
+
expect(getTextToItalic('*Hello*world*')).toBe('*world*')
|
|
22
|
+
expect(getTextToItalic('_Hello_world_')).toBe('_world_')
|
|
23
|
+
|
|
24
|
+
expect(getTextToItalic('* Hello world *')).toBe(undefined)
|
|
25
|
+
expect(getTextToItalic('* Hello world*')).toBe(undefined)
|
|
26
|
+
expect(getTextToItalic('*Hello world *')).toBe(undefined)
|
|
27
|
+
expect(getTextToItalic('_ Hello world _')).toBe(undefined)
|
|
28
|
+
expect(getTextToItalic('_ Hello world_')).toBe(undefined)
|
|
29
|
+
expect(getTextToItalic('_Hello world _')).toBe(undefined)
|
|
30
|
+
|
|
31
|
+
expect(getTextToItalic('Hello *world')).toBe(undefined)
|
|
32
|
+
expect(getTextToItalic('Hello world*')).toBe(undefined)
|
|
33
|
+
expect(getTextToItalic('Hello *world* *')).toBe(undefined)
|
|
34
|
+
|
|
35
|
+
expect(getTextToItalic('_Hello*world_')).toBe('_Hello*world_')
|
|
36
|
+
expect(getTextToItalic('*Hello_world*')).toBe('*Hello_world*')
|
|
37
|
+
|
|
38
|
+
expect(getTextToItalic('*hello\nworld*')).toBe(undefined)
|
|
39
|
+
expect(getTextToItalic('_hello\nworld_')).toBe(undefined)
|
|
40
|
+
|
|
41
|
+
expect(getTextToItalic('*')).toBe(undefined)
|
|
42
|
+
expect(getTextToItalic('_')).toBe(undefined)
|
|
43
|
+
expect(getTextToItalic('**')).toBe(undefined)
|
|
44
|
+
expect(getTextToItalic('__')).toBe(undefined)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test(getTextToBold.name, () => {
|
|
48
|
+
expect(getTextToBold('Hello **world**')).toBe('**world**')
|
|
49
|
+
expect(getTextToBold('Hello __world__')).toBe('__world__')
|
|
50
|
+
expect(getTextToBold('**Hello**world**')).toBe('**world**')
|
|
51
|
+
expect(getTextToBold('__Hello__world__')).toBe('__world__')
|
|
52
|
+
|
|
53
|
+
expect(getTextToBold('** Hello world **')).toBe(undefined)
|
|
54
|
+
expect(getTextToBold('** Hello world**')).toBe(undefined)
|
|
55
|
+
expect(getTextToBold('**Hello world **')).toBe(undefined)
|
|
56
|
+
expect(getTextToBold('__ Hello world __')).toBe(undefined)
|
|
57
|
+
expect(getTextToBold('__ Hello world__')).toBe(undefined)
|
|
58
|
+
expect(getTextToBold('__Hello world __')).toBe(undefined)
|
|
59
|
+
|
|
60
|
+
expect(getTextToBold('Hello **world')).toBe(undefined)
|
|
61
|
+
expect(getTextToBold('Hello world**')).toBe(undefined)
|
|
62
|
+
expect(getTextToBold('Hello **world** **')).toBe(undefined)
|
|
63
|
+
|
|
64
|
+
expect(getTextToBold('__Hello**world__')).toBe('__Hello**world__')
|
|
65
|
+
expect(getTextToBold('**Hello__world**')).toBe('**Hello__world**')
|
|
66
|
+
|
|
67
|
+
expect(getTextToBold('**hello\nworld**')).toBe(undefined)
|
|
68
|
+
expect(getTextToBold('__hello\nworld__')).toBe(undefined)
|
|
69
|
+
|
|
70
|
+
expect(getTextToBold('**')).toBe(undefined)
|
|
71
|
+
expect(getTextToBold('__')).toBe(undefined)
|
|
72
|
+
expect(getTextToBold('****')).toBe(undefined)
|
|
73
|
+
expect(getTextToBold('____')).toBe(undefined)
|
|
74
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function createCharacterPairRegex(char: string, amount: number) {
|
|
2
|
+
// Negative lookbehind: Ensures that the matched sequence is not preceded by the same character
|
|
3
|
+
const prePrefix = `(?<!\\${char})`
|
|
4
|
+
|
|
5
|
+
// Repeats the character `amount` times
|
|
6
|
+
const prefix = `\\${char}`.repeat(Math.max(amount, 1))
|
|
7
|
+
|
|
8
|
+
// Negative lookahead: Ensures that the opening pair (**, *, etc.) is not followed by a space
|
|
9
|
+
const postPrefix = `(?!\\s)`
|
|
10
|
+
|
|
11
|
+
// Captures the content inside the pair
|
|
12
|
+
const content = `([^${char}\\n]+?)`
|
|
13
|
+
|
|
14
|
+
// Negative lookbehind: Ensures that the content is not followed by a space
|
|
15
|
+
const preSuffix = `(?<!\\s)`
|
|
16
|
+
|
|
17
|
+
// Repeats the character `amount` times
|
|
18
|
+
const suffix = `\\${char}`.repeat(Math.max(amount, 1))
|
|
19
|
+
|
|
20
|
+
// Negative lookahead: Ensures that the matched sequence is not followed by the same character
|
|
21
|
+
const postSuffix = `(?!\\${char})`
|
|
22
|
+
|
|
23
|
+
return `${prePrefix}${prefix}${postPrefix}${content}${preSuffix}${suffix}${postSuffix}`
|
|
24
|
+
}
|