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