This repository has been archived by the owner on Jun 2, 2022. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 4
/
Toolbar.tsx
491 lines (455 loc) · 15.6 KB
/
Toolbar.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
import React, { PureComponent, SFC, ComponentType, Component, FunctionComponent } from 'react'
import { View, TouchableOpacity, StyleProp, ViewStyle, ViewPropTypes, StyleSheet } from 'react-native'
import invariant from 'invariant'
import PropTypes from 'prop-types'
import { Transforms } from '@core/Transforms'
import { BridgeStatic, Bridge } from '@core/Bridge'
import { Attributes } from '@delta/attributes'
import { ToolbarLayoutPropType, DocumentPropType } from './types'
import { Images } from '@core/Images'
import { Document } from '@model/document'
import identity from 'ramda/es/identity'
import partial from 'ramda/es/partial'
/**
* Constant used within a {@link (Toolbar:namespace).Layout} to denote a separator.
*
* @public
*/
export const CONTROL_SEPARATOR = Symbol('separator')
/**
* Any actions which can be triggered with the {@link (Toolbar:class)} component.
*
* @public
*/
export type GenericControlAction = string | symbol | number
/**
* Actions which can be triggered with the {@link (Toolbar:class)} component to alter document.
*
* @public
*/
export enum DocumentControlAction {
/**
* Switch bold formatting in the selected text.
*/
SELECT_TEXT_BOLD,
/**
* Switch italic formatting in the selected text.
*/
SELECT_TEXT_ITALIC,
/**
* Switch underline formatting in the selected text.
*/
SELECT_TEXT_UNDERLINE,
/**
* Switch strikethrough formatting in the selected text.
*/
SELECT_TEXT_STRIKETHROUGH,
/**
* Insert an image at selection.
*/
INSERT_IMAGE_AT_SELECTION,
}
/**
* A set of definitions related to the {@link (Toolbar:class)} component.
*
* @public
*/
export declare namespace Toolbar {
export interface GenericControlSpec<A extends GenericControlAction, T extends object> {
/**
* The react {@link react#ComponentType} representing the rendered icon.
*
* @remarks
*
* - This icon component is expected to at least support {@link (Toolbar:namespace).TextControlMinimalIconProps}.
* - The component will optionally receive `iconProps`.
* - The icon should have a transparent background.
*/
IconComponent: ComponentType<TextControlMinimalIconProps & T>
/**
* The action performed when the control is actionated.
*/
actionType: A
/**
* Any value to be passed to action hook.
*/
actionOptions?: any
/**
* The props passed to `IconComponent`
*/
iconProps?: T extends Toolbar.VectorIconMinimalProps ? Toolbar.VectorIconMinimalProps : Partial<T>
}
/**
* An object describing a control which alter the document.
*/
export type DocumentControlSpec<T extends object = {}> = GenericControlSpec<DocumentControlAction, T>
/**
* Declaratively describes the layout of the {@link (Toolbar:class)} component.
*/
export type Layout = (DocumentControlSpec<any> | typeof CONTROL_SEPARATOR | GenericControlSpec<any, any>)[]
export interface IconButtonSpecs {
/**
* Button background when a control is not in active state.
*/
inactiveButtonBackgroundColor: string
/**
* Button icon color when a control is not in active state.
*/
inactiveButtonColor: string
/**
* Button icon color when a control is in active state.
*/
activeButtonBackgroundColor: string
/**
* Button background when a control is in active state.
*/
activeButtonColor: string
/**
* Icon size.
*/
iconSize: number
}
/**
* Props of the {@link (Toolbar:class)} component.
*/
export interface Props<ImageSource, ImageOptions = any> extends Partial<IconButtonSpecs> {
/**
* The instance to be shared with the {@link (Typer:class)}.
*/
bridge: Bridge<ImageSource>
/**
* The {@link Document | document}.
*/
document: Document
/**
* An array describing the resulting layout of this component.
*/
layout: Layout
/**
* An async function that returns a promise resolving to the {@link Images.Description | description} of an image.
*
* @remarks The corresponding {@link (Toolbar:namespace).GenericControlSpec.actionOptions} will be passed to this function.
*/
pickOneImage?: (options?: ImageOptions) => Promise<Images.Description<ImageSource>>
/**
* A callback fired when pressing a custom control.
*/
onPressCustomControl?: <A extends GenericControlAction>(actionType: A, actionOptions?: any) => void
/**
* A callback fired when inserting an image results in an error.
*/
onInsertImageError?: (e: Error) => void
/**
* The color of the separator.
*
* @remarks
*
* A separator can be defined by inserting {@link CONTROL_SEPARATOR} constant to the `layout` prop.
*/
separatorColor?: string
/**
* Style of the root component.
*/
style?: StyleProp<ViewStyle>
/**
* Style of the container component encompassing all controls.
*/
contentContainerStyle?: StyleProp<ViewStyle>
/**
* The space between two buttons.
*/
buttonSpacing?: number
}
/**
* Props for {@link (Toolbar:class).IconButton} component.
*/
export interface IconButtonProps extends IconButtonSpecs {
selected: boolean
IconComponent: ComponentType<TextControlMinimalIconProps>
onPress?: () => void
style?: StyleProp<ViewStyle>
iconProps?: object
}
/**
* The props passed to every icon {@link react#ComponentType}.
*/
export interface TextControlMinimalIconProps {
/**
* Icon color.
*
* @remarks
*
* The color varies depending on the active state.
* Will receive {@link (Toolbar:namespace).IconButtonSpecs.inactiveButtonColor} when not active and
* {@link (Toolbar:namespace).IconButtonSpecs.activeButtonColor} when active.
*/
color?: string
/**
* Icon size.
*/
size?: number
}
/**
* The shape of expected props to an icon from {@link https://www.npmjs.com/package/react-native-vector-icons | react-native-vector-icons}.
*/
export interface VectorIconMinimalProps {
/**
* Icon name.
*/
name: string
}
}
const DEFAULT_ICON_SIZE = 24
const styles = StyleSheet.create({
container: {
paddingVertical: 5,
flexDirection: 'row',
borderTopLeftRadius: 10,
borderTopRightRadius: 10,
},
})
function getDefaultButtonStyle({ inactiveButtonColor, inactiveButtonBackgroundColor }: Toolbar.IconButtonSpecs) {
return {
color: inactiveButtonColor,
backgroundColor: inactiveButtonBackgroundColor,
}
}
function getSelectedButtonStyle({ activeButtonColor, activeButtonBackgroundColor }: Toolbar.IconButtonSpecs) {
return {
color: activeButtonColor,
backgroundColor: activeButtonBackgroundColor,
}
}
// eslint-disable-next-line @typescript-eslint/class-name-casing
class _Toolbar extends PureComponent<Toolbar.Props<any, any>> {
public static displayName = 'Toolbar'
public static propTypes: Record<keyof Toolbar.Props<any>, any> = {
bridge: PropTypes.instanceOf(BridgeStatic).isRequired,
document: DocumentPropType,
pickOneImage: PropTypes.func,
onPressCustomControl: PropTypes.func,
onInsertImageError: PropTypes.func,
layout: ToolbarLayoutPropType,
inactiveButtonBackgroundColor: PropTypes.string,
inactiveButtonColor: PropTypes.string,
activeButtonBackgroundColor: PropTypes.string,
activeButtonColor: PropTypes.string,
separatorColor: PropTypes.string,
style: ViewPropTypes.style,
contentContainerStyle: ViewPropTypes.style,
iconSize: PropTypes.number,
buttonSpacing: PropTypes.number,
}
public static defaultProps: Partial<Record<keyof Toolbar.Props<any>, any>> = {
inactiveButtonBackgroundColor: 'transparent',
inactiveButtonColor: '#3a404c',
activeButtonBackgroundColor: 'transparent',
activeButtonColor: '#4286f4',
separatorColor: '#646e82',
iconSize: DEFAULT_ICON_SIZE,
}
public static IconButton: SFC<Toolbar.IconButtonProps> = ({
onPress,
selected,
style,
IconComponent,
iconProps,
...buttonSpec
}) => {
const dynamicStyle = selected ? getSelectedButtonStyle(buttonSpec) : getDefaultButtonStyle(buttonSpec)
return (
<TouchableOpacity onPress={onPress} style={[dynamicStyle, style]}>
<IconComponent color={dynamicStyle.color as string} size={buttonSpec.iconSize} {...iconProps} />
</TouchableOpacity>
)
}
private controlEventDom: Bridge.ControlEventDomain<any>
public constructor(props: Toolbar.Props<any>) {
super(props)
invariant(props.bridge != null, 'bridge prop is required')
this.controlEventDom = props.bridge.getControlEventDomain()
this.insertImageAtSelection = this.insertImageAtSelection.bind(this)
}
private Separator: SFC<{}> = () =>
React.createElement(View, {
style: {
height: this.props.iconSize,
width: 2,
backgroundColor: this.props.separatorColor,
marginRight: this.computeIconSpacing(),
},
})
private async insertImageAtSelection(options?: any) {
if (this.props.pickOneImage) {
try {
const description = await this.props.pickOneImage(options)
this.controlEventDom.insertOrReplaceAtSelection({ type: 'image', description })
} catch (e) {
this.props.onInsertImageError && this.props.onInsertImageError(e)
}
} else {
console.warn(`You didn't pass pickOneImage in ${Toolbar.name} component.`)
}
}
private applyTextTransformToSelection(
attributeName: Transforms.TextAttributeName,
activeAttributeValue: Attributes.TextValue,
) {
const currentTextAttribute = this.props.document.selectedTextAttributes[attributeName]
const nextAttributeValue = currentTextAttribute === activeAttributeValue ? null : activeAttributeValue
return () => {
this.controlEventDom.applyTextTransformToSelection(attributeName, nextAttributeValue)
}
}
private computeIconSpacing() {
return typeof this.props.buttonSpacing === 'number' ? this.props.buttonSpacing : (this.props.iconSize as number) / 3
}
private getButtonSpecs(): Toolbar.IconButtonSpecs {
const {
iconSize,
activeButtonBackgroundColor,
activeButtonColor,
inactiveButtonBackgroundColor,
inactiveButtonColor,
} = this.props
return {
iconSize: iconSize as number,
activeButtonBackgroundColor: activeButtonBackgroundColor as string,
activeButtonColor: activeButtonColor as string,
inactiveButtonBackgroundColor: inactiveButtonBackgroundColor as string,
inactiveButtonColor: inactiveButtonColor as string,
}
}
private renderStatelessActionController(
controlSpec: Toolbar.DocumentControlSpec,
onPress: () => void,
last: boolean,
) {
const IconButton = _Toolbar.IconButton
return (
<IconButton
{...this.getButtonSpecs()}
selected={false}
style={last ? undefined : { marginRight: this.computeIconSpacing() }}
IconComponent={controlSpec.IconComponent}
iconProps={controlSpec.iconProps}
onPress={onPress}
/>
)
}
private renderCustomController(controlSpec: Toolbar.GenericControlSpec<any, any>, last: boolean) {
const onPressCustomControl = partial(this.props.onPressCustomControl || identity, [
controlSpec.actionType,
controlSpec.actionOptions,
]) as () => void
return this.renderStatelessActionController(controlSpec, onPressCustomControl, last)
}
private renderInsertImageController(controlSpec: Toolbar.DocumentControlSpec, last: boolean) {
return this.renderStatelessActionController(
controlSpec,
this.insertImageAtSelection.bind(this, controlSpec.actionOptions),
last,
)
}
private renderTextTransformController(
attributeName: Transforms.TextAttributeName,
activeAttributeValue: Attributes.TextValue,
textControlSpec: Toolbar.DocumentControlSpec,
last = false,
) {
const {
document: { selectedTextAttributes },
} = this.props
const IconButton = _Toolbar.IconButton
return (
<IconButton
{...this.getButtonSpecs()}
selected={selectedTextAttributes[attributeName] === activeAttributeValue}
style={last ? undefined : { marginRight: this.computeIconSpacing() }}
IconComponent={textControlSpec.IconComponent}
iconProps={textControlSpec.iconProps}
onPress={this.applyTextTransformToSelection(attributeName, activeAttributeValue)}
/>
)
}
private renderIconControl(controlSpec: Toolbar.GenericControlSpec<DocumentControlAction | any, any>, last: boolean) {
switch (controlSpec.actionType) {
case DocumentControlAction.SELECT_TEXT_BOLD:
return this.renderTextTransformController('bold', true, controlSpec, last)
case DocumentControlAction.SELECT_TEXT_ITALIC:
return this.renderTextTransformController('italic', true, controlSpec, last)
case DocumentControlAction.SELECT_TEXT_UNDERLINE:
return this.renderTextTransformController('textDecoration', 'underline', controlSpec, last)
case DocumentControlAction.SELECT_TEXT_STRIKETHROUGH:
return this.renderTextTransformController('textDecoration', 'strikethrough', controlSpec, last)
case DocumentControlAction.INSERT_IMAGE_AT_SELECTION:
return this.renderInsertImageController(controlSpec, last)
default:
return this.renderCustomController(controlSpec, last)
}
}
private renderIconControlsMap() {
const { layout: textControlsMap } = this.props
const Separator = this.Separator
return textControlsMap.map((m, index) => {
const key = `index-${index}`
if (m === CONTROL_SEPARATOR) {
return <Separator key={key} />
}
return React.cloneElement(this.renderIconControl(m, index === textControlsMap.length - 1), { key })
})
}
public componentDidUpdate(oldProps: Toolbar.Props<any>) {
invariant(oldProps.bridge === this.props.bridge, "bridge prop cannot be changed during Toolbar's lifetime.")
}
public render() {
const dynamicStyles = { paddingHorizontal: this.computeIconSpacing() }
return (
<View style={[{ flexDirection: 'row', justifyContent: 'center' }, this.props.style]}>
<View style={[[dynamicStyles, styles.container, this.props.contentContainerStyle]]}>
{this.renderIconControlsMap()}
</View>
</View>
)
}
}
/**
* Utility function to build {@link (Toolbar:class)} controls from {@link https://www.npmjs.com/package/react-native-vector-icons | react-native-vector-icons}.
*
* @param IconComponent - The icon {@link react#ComponentType} such as `MaterialCommunityIcons`
* @param actionType - The control action performed when this control is actionated.
* @param name - The name of the icon within the `IconComponent` set.
*
* @returns An object describing this control.
*
* @public
*/
export function buildVectorIconControlSpec<A extends GenericControlAction, T extends Toolbar.VectorIconMinimalProps>(
IconComponent: ComponentType<T & Toolbar.TextControlMinimalIconProps>,
actionType: A,
name: string,
options?: Pick<Toolbar.GenericControlSpec<A, T>, 'actionOptions' | 'iconProps'>,
): Toolbar.GenericControlSpec<A, T> {
const iconProps: any = { name }
const specs: Toolbar.GenericControlSpec<A, T> = {
...options,
actionType,
iconProps,
IconComponent,
}
return specs
}
exports.Toolbar = _Toolbar
/**
* A component to let user control the {@link (Typer:class)} through a {@link (Bridge:interface)}.
*
* @public
*/
export declare class Toolbar<ImageSource = Images.StandardSource, ImageOptions = any> extends Component<
Toolbar.Props<ImageSource, ImageOptions>
> {
/**
* A button component displayed inside a toolbar.
*/
IconButton: FunctionComponent<Toolbar.IconButtonProps>
}