@portabletext/toolbar 0.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.
@@ -0,0 +1,189 @@
1
+ import {useEditor, type Editor} from '@portabletext/editor'
2
+ import {
3
+ defineBehavior,
4
+ effect,
5
+ type InsertPlacement,
6
+ } from '@portabletext/editor/behaviors'
7
+ import {useActor} from '@xstate/react'
8
+ import {fromCallback, setup, type AnyEventObject} from 'xstate'
9
+ import {disableListener, type DisableListenerEvent} from './disable-listener'
10
+ import type {ToolbarBlockObjectSchemaType} from './use-toolbar-schema'
11
+
12
+ const keyboardShortcutListener = fromCallback<
13
+ AnyEventObject,
14
+ {editor: Editor; schemaType: ToolbarBlockObjectSchemaType},
15
+ BlockObjectButtonEvent
16
+ >(({input, sendBack}) => {
17
+ const shortcut = input.schemaType.shortcut
18
+
19
+ if (!shortcut) {
20
+ return
21
+ }
22
+
23
+ return input.editor.registerBehavior({
24
+ behavior: defineBehavior({
25
+ on: 'keyboard.keydown',
26
+ guard: ({event}) => shortcut.guard(event.originEvent),
27
+ actions: [
28
+ () => [
29
+ effect(() => {
30
+ sendBack({type: 'open dialog'})
31
+ }),
32
+ ],
33
+ ],
34
+ }),
35
+ })
36
+ })
37
+
38
+ const blockObjectButtonMachine = setup({
39
+ types: {
40
+ context: {} as {
41
+ editor: Editor
42
+ schemaType: ToolbarBlockObjectSchemaType
43
+ },
44
+ input: {} as {
45
+ editor: Editor
46
+ schemaType: ToolbarBlockObjectSchemaType
47
+ },
48
+ events: {} as DisableListenerEvent | BlockObjectButtonEvent,
49
+ },
50
+ actions: {
51
+ insert: ({context, event}) => {
52
+ if (event.type !== 'insert') {
53
+ return
54
+ }
55
+
56
+ context.editor.send({
57
+ type: 'insert.block object',
58
+ blockObject: {
59
+ name: context.schemaType.name,
60
+ value: event.value,
61
+ },
62
+ placement: event.placement ?? 'auto',
63
+ })
64
+ context.editor.send({type: 'focus'})
65
+ },
66
+ },
67
+ actors: {
68
+ 'disable listener': disableListener,
69
+ 'keyboard shortcut listener': keyboardShortcutListener,
70
+ },
71
+ }).createMachine({
72
+ id: 'block object button',
73
+ context: ({input}) => ({
74
+ editor: input.editor,
75
+ schemaType: input.schemaType,
76
+ }),
77
+ invoke: [
78
+ {
79
+ src: 'disable listener',
80
+ input: ({context}) => ({editor: context.editor}),
81
+ },
82
+ ],
83
+ initial: 'disabled',
84
+ states: {
85
+ disabled: {
86
+ on: {
87
+ enable: {
88
+ target: 'enabled',
89
+ },
90
+ },
91
+ },
92
+ enabled: {
93
+ on: {
94
+ disable: {
95
+ target: 'disabled',
96
+ },
97
+ },
98
+ initial: 'idle',
99
+ states: {
100
+ 'idle': {
101
+ invoke: [
102
+ {
103
+ src: 'keyboard shortcut listener',
104
+ input: ({context}) => ({
105
+ editor: context.editor,
106
+ schemaType: context.schemaType,
107
+ }),
108
+ },
109
+ ],
110
+ on: {
111
+ 'open dialog': {
112
+ target: 'showing dialog',
113
+ },
114
+ },
115
+ },
116
+ 'showing dialog': {
117
+ on: {
118
+ 'close dialog': {
119
+ target: 'idle',
120
+ },
121
+ 'insert': {
122
+ actions: ['insert'],
123
+ target: 'idle',
124
+ },
125
+ },
126
+ },
127
+ },
128
+ },
129
+ },
130
+ })
131
+
132
+ /**
133
+ * @beta
134
+ */
135
+ export type BlockObjectButtonEvent =
136
+ | {
137
+ type: 'close dialog'
138
+ }
139
+ | {
140
+ type: 'open dialog'
141
+ }
142
+ | {
143
+ type: 'insert'
144
+ value: {[key: string]: unknown}
145
+ placement: InsertPlacement | undefined
146
+ }
147
+
148
+ /**
149
+ * @beta
150
+ */
151
+ export type BlockObjectButton = {
152
+ snapshot: {
153
+ matches: (
154
+ state:
155
+ | 'disabled'
156
+ | 'enabled'
157
+ | {enabled: 'idle'}
158
+ | {enabled: 'showing dialog'},
159
+ ) => boolean
160
+ }
161
+ send: (event: BlockObjectButtonEvent) => void
162
+ }
163
+
164
+ /**
165
+ * @beta
166
+ * Manages the state, keyboard shortcut and available events for a block
167
+ * object button.
168
+ *
169
+ * Note: This hook assumes that the button triggers a dialog for inputting
170
+ * the block object value.
171
+ */
172
+ export function useBlockObjectButton(props: {
173
+ schemaType: ToolbarBlockObjectSchemaType
174
+ }): BlockObjectButton {
175
+ const editor = useEditor()
176
+ const [actorSnapshot, send] = useActor(blockObjectButtonMachine, {
177
+ input: {
178
+ editor,
179
+ schemaType: props.schemaType,
180
+ },
181
+ })
182
+
183
+ return {
184
+ snapshot: {
185
+ matches: (state) => actorSnapshot.matches(state),
186
+ },
187
+ send,
188
+ }
189
+ }
@@ -0,0 +1,285 @@
1
+ import {
2
+ useEditor,
3
+ type BlockPath,
4
+ type Editor,
5
+ type PortableTextObject,
6
+ } from '@portabletext/editor'
7
+ import * as selectors from '@portabletext/editor/selectors'
8
+ import {useActor} from '@xstate/react'
9
+ import * as React from 'react'
10
+ import type {RefObject} from 'react'
11
+ import {assign, fromCallback, setup, type AnyEventObject} from 'xstate'
12
+ import {disableListener, type DisableListenerEvent} from './disable-listener'
13
+ import type {ToolbarBlockObjectSchemaType} from './use-toolbar-schema'
14
+
15
+ type ActiveContext = {
16
+ blockObjects: Array<{
17
+ value: PortableTextObject
18
+ schemaType: ToolbarBlockObjectSchemaType
19
+ at: BlockPath
20
+ }>
21
+ elementRef: RefObject<Element | null>
22
+ }
23
+
24
+ type ActiveListenerEvent =
25
+ | ({
26
+ type: 'set active'
27
+ } & ActiveContext)
28
+ | {
29
+ type: 'set inactive'
30
+ }
31
+
32
+ const activeListener = fromCallback<
33
+ AnyEventObject,
34
+ {editor: Editor; schemaTypes: ReadonlyArray<ToolbarBlockObjectSchemaType>},
35
+ ActiveListenerEvent
36
+ >(({input, sendBack}) => {
37
+ return input.editor.on('selection', () => {
38
+ const snapshot = input.editor.getSnapshot()
39
+
40
+ if (!selectors.isSelectionCollapsed(snapshot)) {
41
+ sendBack({type: 'set inactive'})
42
+ return
43
+ }
44
+
45
+ const focusBlockObject = selectors.getFocusBlockObject(snapshot)
46
+
47
+ if (!focusBlockObject) {
48
+ sendBack({type: 'set inactive'})
49
+ return
50
+ }
51
+
52
+ const schemaType = input.schemaTypes.find(
53
+ (schemaType) => schemaType.name === focusBlockObject.node._type,
54
+ )
55
+
56
+ if (!schemaType) {
57
+ sendBack({type: 'set inactive'})
58
+ return
59
+ }
60
+
61
+ const selectedNodes = input.editor.dom.getBlockNodes(snapshot)
62
+ const firstSelectedNode = selectedNodes.at(0)
63
+
64
+ if (!firstSelectedNode || !(firstSelectedNode instanceof Element)) {
65
+ sendBack({type: 'set inactive'})
66
+ return
67
+ }
68
+
69
+ const elementRef = React.createRef<Element>()
70
+ elementRef.current = firstSelectedNode
71
+
72
+ sendBack({
73
+ type: 'set active',
74
+ blockObjects: [
75
+ {
76
+ value: focusBlockObject.node,
77
+ schemaType,
78
+ at: focusBlockObject.path,
79
+ },
80
+ ],
81
+ elementRef,
82
+ })
83
+ }).unsubscribe
84
+ })
85
+
86
+ const blockObjectPopoverMachine = setup({
87
+ types: {
88
+ context: {} as {
89
+ editor: Editor
90
+ schemaTypes: ReadonlyArray<ToolbarBlockObjectSchemaType>
91
+ } & ActiveContext,
92
+ input: {} as {
93
+ editor: Editor
94
+ schemaTypes: ReadonlyArray<ToolbarBlockObjectSchemaType>
95
+ },
96
+ events: {} as
97
+ | DisableListenerEvent
98
+ | ActiveListenerEvent
99
+ | BlockObjectPopoverEvent,
100
+ },
101
+ actions: {
102
+ reset: assign({
103
+ blockObjects: [],
104
+ elementRef: React.createRef<Element>(),
105
+ }),
106
+ },
107
+ actors: {
108
+ 'disable listener': disableListener,
109
+ 'active listener': activeListener,
110
+ },
111
+ }).createMachine({
112
+ id: 'block object popover',
113
+ context: ({input}) => ({
114
+ editor: input.editor,
115
+ schemaTypes: input.schemaTypes,
116
+ blockObjects: [],
117
+ elementRef: React.createRef<Element>(),
118
+ }),
119
+ invoke: [
120
+ {src: 'disable listener', input: ({context}) => ({editor: context.editor})},
121
+ {
122
+ src: 'active listener',
123
+ input: ({context}) => ({
124
+ editor: context.editor,
125
+ schemaTypes: context.schemaTypes,
126
+ }),
127
+ },
128
+ ],
129
+ initial: 'disabled',
130
+ states: {
131
+ disabled: {
132
+ initial: 'inactive',
133
+ states: {
134
+ inactive: {
135
+ entry: ['reset'],
136
+ on: {
137
+ 'set active': {
138
+ actions: assign({
139
+ blockObjects: ({event}) => event.blockObjects,
140
+ elementRef: ({event}) => event.elementRef,
141
+ }),
142
+ target: 'active',
143
+ },
144
+ 'enable': {
145
+ target: '#block object popover.enabled.inactive',
146
+ },
147
+ },
148
+ },
149
+ active: {
150
+ on: {
151
+ 'set inactive': {
152
+ target: 'inactive',
153
+ },
154
+ 'enable': {
155
+ target: '#block object popover.enabled.active',
156
+ },
157
+ },
158
+ },
159
+ },
160
+ },
161
+ enabled: {
162
+ initial: 'inactive',
163
+ states: {
164
+ inactive: {
165
+ entry: ['reset'],
166
+ on: {
167
+ 'set active': {
168
+ target: 'active',
169
+ actions: assign({
170
+ blockObjects: ({event}) => event.blockObjects,
171
+ elementRef: ({event}) => event.elementRef,
172
+ }),
173
+ },
174
+ 'disable': {
175
+ target: '#block object popover.disabled.inactive',
176
+ },
177
+ },
178
+ },
179
+ active: {
180
+ on: {
181
+ 'set inactive': {
182
+ target: 'inactive',
183
+ },
184
+ 'disable': {
185
+ target: '#block object popover.disabled.active',
186
+ },
187
+ 'set active': {
188
+ actions: assign({
189
+ blockObjects: ({event}) => event.blockObjects,
190
+ elementRef: ({event}) => event.elementRef,
191
+ }),
192
+ },
193
+ 'edit': {
194
+ actions: ({context, event}) => {
195
+ context.editor.send({
196
+ type: 'block.set',
197
+ at: event.at,
198
+ props: event.props,
199
+ })
200
+ context.editor.send({type: 'focus'})
201
+ },
202
+ },
203
+ 'remove': {
204
+ actions: ({context, event}) => {
205
+ context.editor.send({
206
+ type: 'delete.block',
207
+ at: event.at,
208
+ })
209
+ context.editor.send({type: 'focus'})
210
+ },
211
+ },
212
+ 'close': {
213
+ actions: ({context}) => {
214
+ context.editor.send({type: 'focus'})
215
+ },
216
+ target: 'inactive',
217
+ },
218
+ },
219
+ },
220
+ },
221
+ },
222
+ },
223
+ })
224
+
225
+ /**
226
+ * @beta
227
+ */
228
+ export type BlockObjectPopoverEvent =
229
+ | {
230
+ type: 'remove'
231
+ at: BlockPath
232
+ }
233
+ | {
234
+ type: 'edit'
235
+ at: BlockPath
236
+ props: {[key: string]: unknown}
237
+ }
238
+ | {
239
+ type: 'close'
240
+ }
241
+
242
+ /**
243
+ * @beta
244
+ */
245
+ export type BlockObjectPopover = {
246
+ snapshot: {
247
+ context: ActiveContext
248
+ matches: (
249
+ state:
250
+ | 'disabled'
251
+ | 'enabled'
252
+ | {
253
+ enabled: 'inactive' | 'active'
254
+ },
255
+ ) => boolean
256
+ }
257
+ send: (event: BlockObjectPopoverEvent) => void
258
+ }
259
+
260
+ /**
261
+ * @beta
262
+ * Manages the state and available events for a block object popover.
263
+ */
264
+ export function useBlockObjectPopover(props: {
265
+ schemaTypes: ReadonlyArray<ToolbarBlockObjectSchemaType>
266
+ }): BlockObjectPopover {
267
+ const editor = useEditor()
268
+ const [actorSnapshot, send] = useActor(blockObjectPopoverMachine, {
269
+ input: {
270
+ editor,
271
+ schemaTypes: props.schemaTypes,
272
+ },
273
+ })
274
+
275
+ return {
276
+ snapshot: {
277
+ context: {
278
+ blockObjects: actorSnapshot.context.blockObjects,
279
+ elementRef: actorSnapshot.context.elementRef,
280
+ },
281
+ matches: (state) => actorSnapshot.matches(state),
282
+ },
283
+ send,
284
+ }
285
+ }
@@ -0,0 +1,185 @@
1
+ import {
2
+ useEditor,
3
+ type DecoratorSchemaType,
4
+ type Editor,
5
+ } from '@portabletext/editor'
6
+ import * as selectors from '@portabletext/editor/selectors'
7
+ import {useActor} from '@xstate/react'
8
+ import {fromCallback, setup, type AnyEventObject} from 'xstate'
9
+ import {disableListener, type DisableListenerEvent} from './disable-listener'
10
+ import {useDecoratorKeyboardShortcut} from './use-decorator-keyboard-shortcut'
11
+ import {useMutuallyExclusiveDecorator} from './use-mutually-exclusive-decorator'
12
+ import type {ToolbarDecoratorSchemaType} from './use-toolbar-schema'
13
+
14
+ const activeListener = fromCallback<
15
+ AnyEventObject,
16
+ {editor: Editor; schemaType: DecoratorSchemaType},
17
+ {type: 'set active'} | {type: 'set inactive'}
18
+ >(({input, sendBack}) => {
19
+ return input.editor.on('*', () => {
20
+ const snapshot = input.editor.getSnapshot()
21
+
22
+ if (selectors.isActiveDecorator(input.schemaType.name)(snapshot)) {
23
+ sendBack({type: 'set active'})
24
+ } else {
25
+ sendBack({type: 'set inactive'})
26
+ }
27
+ }).unsubscribe
28
+ })
29
+
30
+ const decoratorButtonMachine = setup({
31
+ types: {
32
+ context: {} as {
33
+ editor: Editor
34
+ schemaType: DecoratorSchemaType
35
+ },
36
+ input: {} as {
37
+ editor: Editor
38
+ schemaType: DecoratorSchemaType
39
+ },
40
+ events: {} as DisableListenerEvent | DecoratorButtonEvent,
41
+ },
42
+ actions: {
43
+ toggle: ({context, event}) => {
44
+ if (event.type !== 'toggle') {
45
+ return
46
+ }
47
+
48
+ context.editor.send({
49
+ type: 'decorator.toggle',
50
+ decorator: context.schemaType.name,
51
+ })
52
+ context.editor.send({type: 'focus'})
53
+ },
54
+ },
55
+ actors: {
56
+ 'disable listener': disableListener,
57
+ 'active listener': activeListener,
58
+ },
59
+ }).createMachine({
60
+ id: 'decorator button',
61
+ context: ({input}) => ({
62
+ editor: input.editor,
63
+ schemaType: input.schemaType,
64
+ }),
65
+ invoke: [
66
+ {src: 'disable listener', input: ({context}) => ({editor: context.editor})},
67
+ {
68
+ src: 'active listener',
69
+ input: ({context}) => ({
70
+ editor: context.editor,
71
+ schemaType: context.schemaType,
72
+ }),
73
+ },
74
+ ],
75
+ initial: 'disabled',
76
+ states: {
77
+ disabled: {
78
+ initial: 'inactive',
79
+ states: {
80
+ inactive: {
81
+ on: {
82
+ 'enable': {
83
+ target: '#decorator button.enabled.inactive',
84
+ },
85
+ 'set active': {
86
+ target: 'active',
87
+ },
88
+ },
89
+ },
90
+ active: {
91
+ on: {
92
+ 'enable': {
93
+ target: '#decorator button.enabled.active',
94
+ },
95
+ 'set inactive': {
96
+ target: 'inactive',
97
+ },
98
+ },
99
+ },
100
+ },
101
+ },
102
+ enabled: {
103
+ on: {
104
+ toggle: {
105
+ actions: ['toggle'],
106
+ },
107
+ },
108
+ initial: 'inactive',
109
+ states: {
110
+ inactive: {
111
+ on: {
112
+ 'disable': {
113
+ target: '#decorator button.disabled.inactive',
114
+ },
115
+ 'set active': {
116
+ target: 'active',
117
+ },
118
+ },
119
+ },
120
+ active: {
121
+ on: {
122
+ 'disable': {
123
+ target: '#decorator button.disabled.active',
124
+ },
125
+ 'set inactive': {
126
+ target: 'inactive',
127
+ },
128
+ },
129
+ },
130
+ },
131
+ },
132
+ },
133
+ })
134
+
135
+ /**
136
+ * @beta
137
+ */
138
+ export type DecoratorButtonEvent = {
139
+ type: 'toggle'
140
+ }
141
+
142
+ /**
143
+ * @beta
144
+ */
145
+ export type DecoratorButton = {
146
+ snapshot: {
147
+ matches: (
148
+ state:
149
+ | 'disabled'
150
+ | 'enabled'
151
+ | {disabled: 'inactive'}
152
+ | {disabled: 'active'}
153
+ | {enabled: 'inactive'}
154
+ | {enabled: 'active'},
155
+ ) => boolean
156
+ }
157
+ send: (event: DecoratorButtonEvent) => void
158
+ }
159
+
160
+ /**
161
+ * @beta
162
+ * Manages the state, keyboard shortcuts and available events for a decorator
163
+ * button and sets up mutually exclusive decorator behaviors.
164
+ */
165
+ export function useDecoratorButton(props: {
166
+ schemaType: ToolbarDecoratorSchemaType
167
+ }): DecoratorButton {
168
+ const editor = useEditor()
169
+ const [actorSnapshot, send] = useActor(decoratorButtonMachine, {
170
+ input: {
171
+ editor,
172
+ schemaType: props.schemaType,
173
+ },
174
+ })
175
+
176
+ useDecoratorKeyboardShortcut(props)
177
+ useMutuallyExclusiveDecorator(props)
178
+
179
+ return {
180
+ snapshot: {
181
+ matches: (state) => actorSnapshot.matches(state),
182
+ },
183
+ send,
184
+ }
185
+ }