,
147 | S extends DocumentRendererState = DocumentRendererState
148 | > extends PureComponent {
149 | public static propTypes: Record, any> = {
150 | document: DocumentPropType.isRequired,
151 | ImageComponent: PropTypes.func,
152 | style: ViewPropTypes.style,
153 | contentContainerStyle: ViewPropTypes.style,
154 | documentStyle: ViewPropTypes.style,
155 | textStyle: PropTypes.any,
156 | spacing: PropTypes.number,
157 | maxMediaBlockHeight: PropTypes.number,
158 | maxMediaBlockWidth: PropTypes.number,
159 | textTransformSpecs: TextTransformSpecsType,
160 | ScrollView: PropTypes.func,
161 | scrollViewProps: PropTypes.object,
162 | }
163 |
164 | public static defaultProps: Partial, any>> = {
165 | ImageComponent: defaults.ImageComponent,
166 | spacing: defaults.spacing,
167 | textTransformSpecs: defaults.textTransformsSpecs,
168 | }
169 |
170 | protected assembler: BlockAssembler
171 |
172 | public constructor(props: P) {
173 | super(props)
174 | this.assembler = new BlockAssembler(props.document)
175 | }
176 |
177 | private getSpacing() {
178 | return this.props.spacing as number
179 | }
180 |
181 | private handleOnContainerLayout = (layoutEvent: LayoutChangeEvent) => {
182 | this.setState({
183 | containerWidth: layoutEvent.nativeEvent.layout.width,
184 | })
185 | }
186 |
187 | private getComponentStyles(): StyleProp {
188 | return [contentRendererStyles.scroll, this.props.style]
189 | }
190 |
191 | private getContentContainerStyles(): StyleProp {
192 | const padding = this.getSpacing()
193 | return [contentRendererStyles.contentContainer, this.props.contentContainerStyle, overridePadding(padding)]
194 | }
195 |
196 | private getDocumentStyles(): StyleProp {
197 | return [contentRendererStyles.documentStyle, this.props.documentStyle, genericStyles.zeroPadding]
198 | }
199 |
200 | protected getBlockStyle(block: Block) {
201 | if (block.isLast()) {
202 | return undefined
203 | }
204 | return {
205 | marginBottom: this.getSpacing(),
206 | }
207 | }
208 |
209 | @boundMethod
210 | protected renderBlockView(block: Block) {
211 | const { textStyle, maxMediaBlockHeight, maxMediaBlockWidth, textTransformSpecs, ImageComponent } = this.props
212 | const { descriptor } = block
213 | const key = `block-view-${descriptor.kind}-${descriptor.blockIndex}`
214 | return (
215 | }
224 | textTransformSpecs={textTransformSpecs as Transforms.Specs}
225 | />
226 | )
227 | }
228 |
229 | protected renderRoot(children: ReactNode) {
230 | const { scrollViewProps, ScrollView: UserScrollView } = this.props
231 | const ScrollViewComponent = (UserScrollView || ScrollView) as typeof ScrollView
232 | return (
233 |
234 |
235 |
236 | {children}
237 |
238 |
239 |
240 | )
241 | }
242 | }
243 |
--------------------------------------------------------------------------------
/src/components/GenericBlockInput/ImageBlockInput.tsx:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from 'react'
2 | import {
3 | View,
4 | TextInput,
5 | NativeSyntheticEvent,
6 | TextInputKeyPressEventData,
7 | TextInputProps,
8 | ViewStyle,
9 | StyleSheet,
10 | TouchableHighlight,
11 | TextStyle,
12 | StyleProp,
13 | } from 'react-native'
14 | import { ImageOp } from '@delta/operations'
15 | import { boundMethod } from 'autobind-decorator'
16 | import { SelectionShape } from '@delta/Selection'
17 | import { Images, computeImageFrame } from '@core/Images'
18 | import { StandardBlockInputProps, FocusableInput } from './types'
19 | import { genericStyles } from '@components/styles'
20 |
21 | export interface ImageBlockInputProps extends StandardBlockInputProps {
22 | imageOp: ImageOp
23 | blockScopedSelection: SelectionShape | null
24 | ImageComponent: Images.Component
25 | contentWidth: number
26 | underlayColor?: string
27 | maxMediaBlockWidth?: number
28 | maxMediaBlockHeight?: number
29 | }
30 |
31 | const constantTextInputProps: TextInputProps = {
32 | disableFullscreenUI: true,
33 | scrollEnabled: false,
34 | multiline: false,
35 | returnKeyType: 'next',
36 | keyboardType: 'default',
37 | textBreakStrategy: 'highQuality',
38 | importantForAutofill: 'noExcludeDescendants',
39 | autoFocus: false,
40 | blurOnSubmit: false,
41 | } as TextInputProps
42 |
43 | const TEXT_INPUT_WIDTH = 4
44 |
45 | const styles = StyleSheet.create({
46 | imageContainer: { position: 'relative', flexDirection: 'row' },
47 | })
48 |
49 | export class ImageBlockInput extends PureComponent>
50 | implements FocusableInput {
51 | private rightInput = React.createRef()
52 | private leftInput = React.createRef()
53 |
54 | private computeDimensions() {
55 | const { imageOp, maxMediaBlockHeight, maxMediaBlockWidth, contentWidth } = this.props
56 | return computeImageFrame(imageOp.insert, contentWidth, maxMediaBlockHeight, maxMediaBlockWidth)
57 | }
58 |
59 | private isSelectedForDeletion(): boolean {
60 | const { descriptor, blockScopedSelection } = this.props
61 | return (
62 | !!blockScopedSelection &&
63 | blockScopedSelection.start === 0 &&
64 | descriptor.numOfSelectableUnits === blockScopedSelection.end
65 | )
66 | }
67 |
68 | @boundMethod
69 | private handleOnSubmit() {
70 | this.props.controller.moveAfterBlock()
71 | }
72 |
73 | @boundMethod
74 | private handleOnPressLeftHandler() {
75 | this.props.controller.updateSelectionInBlock({ start: 0, end: 0 }, true)
76 | }
77 |
78 | @boundMethod
79 | private handleOnPressRightHandler() {
80 | this.props.controller.updateSelectionInBlock({ start: 1, end: 1 }, true)
81 | }
82 |
83 | @boundMethod
84 | private handleOnPressMiddleHandler() {
85 | this.props.controller.selectBlock()
86 | }
87 |
88 | @boundMethod
89 | private handleOnValueChange(text: string) {
90 | this.props.controller.insertOrReplaceTextAtSelection(text)
91 | }
92 |
93 | @boundMethod
94 | private handleOnKeyPress(e: NativeSyntheticEvent) {
95 | const key = e.nativeEvent.key
96 | if (key === 'Backspace') {
97 | if (this.isSelectedForDeletion()) {
98 | this.props.controller.removeCurrentBlock()
99 | } else if (!this.isLeftSelected()) {
100 | this.props.controller.selectBlock()
101 | }
102 | }
103 | }
104 |
105 | private renderHandles(fullHandlerWidth: number, containerDimensions: Images.Dimensions) {
106 | const underlayColor = this.props.underlayColor
107 | const touchableStyle: ViewStyle = {
108 | width: fullHandlerWidth,
109 | height: containerDimensions.height,
110 | position: 'absolute',
111 | backgroundColor: 'transparent',
112 | }
113 | const leftHandlePosition = {
114 | left: 0,
115 | bottom: 0,
116 | top: 0,
117 | right: containerDimensions.width - fullHandlerWidth,
118 | }
119 | const rightHandlePosition = {
120 | bottom: 0,
121 | top: 0,
122 | right: 0,
123 | left: containerDimensions.width - fullHandlerWidth,
124 | }
125 | return (
126 |
127 |
132 |
133 |
134 |
139 |
140 |
141 |
142 | )
143 | }
144 |
145 | private renderImageFrame(
146 | spareWidthOnSides: number,
147 | imageDimensions: Images.Dimensions,
148 | containerDimensions: Images.Dimensions,
149 | ) {
150 | const selectStyle = this.isSelectedForDeletion() ? { backgroundColor: 'blue', opacity: 0.5 } : null
151 | const { ImageComponent } = this.props
152 | const imageComponentProps: Images.ComponentProps = {
153 | description: this.props.imageOp.insert,
154 | printDimensions: imageDimensions,
155 | }
156 | const imageFrameStyle: ViewStyle = {
157 | ...imageDimensions,
158 | position: 'relative',
159 | }
160 | const leftInputStyle: ViewStyle = {
161 | position: 'absolute',
162 | left: spareWidthOnSides,
163 | top: 0,
164 | right: imageDimensions.width,
165 | bottom: 0,
166 | height: imageDimensions.height,
167 | width: TEXT_INPUT_WIDTH,
168 | }
169 | const rightInputStyle: ViewStyle = {
170 | position: 'absolute',
171 | left: imageDimensions.width + spareWidthOnSides - TEXT_INPUT_WIDTH,
172 | top: 0,
173 | right: 0,
174 | bottom: 0,
175 | height: imageDimensions.height,
176 | width: TEXT_INPUT_WIDTH,
177 | }
178 | const imageHandleStyle: StyleProp = [selectStyle, imageDimensions]
179 | const imageWrapperStyle: ViewStyle = {
180 | position: 'absolute',
181 | left: spareWidthOnSides,
182 | right: spareWidthOnSides,
183 | bottom: 0,
184 | top: 0,
185 | ...imageDimensions,
186 | }
187 | return (
188 |
189 |
190 |
191 |
192 |
193 |
194 | {this.renderTextInput(containerDimensions, this.leftInput)}
195 | {this.renderTextInput(containerDimensions, this.rightInput)}
196 |
197 | )
198 | }
199 |
200 | private renderImage(
201 | imageDimensions: Images.Dimensions,
202 | containerDimensions: Images.Dimensions,
203 | spareWidthOnSides: number,
204 | handlerWidth: number,
205 | ) {
206 | const fullHandlerWidth = handlerWidth + spareWidthOnSides
207 | return (
208 |
209 | {this.renderImageFrame(spareWidthOnSides, imageDimensions, containerDimensions)}
210 | {this.renderHandles(fullHandlerWidth, containerDimensions)}
211 |
212 | )
213 | }
214 |
215 | private isLeftSelected() {
216 | const selection = this.props.blockScopedSelection
217 | return selection && selection.start === selection.end && selection.start === 0
218 | }
219 |
220 | private focusRight() {
221 | this.rightInput.current && this.rightInput.current.focus()
222 | }
223 |
224 | private focusLeft() {
225 | this.leftInput.current && this.leftInput.current.focus()
226 | }
227 |
228 | @boundMethod
229 | public focus() {
230 | if (this.isLeftSelected()) {
231 | this.focusLeft()
232 | } else {
233 | this.focusRight()
234 | }
235 | }
236 |
237 | private renderTextInput({ height }: Images.Dimensions, ref: React.RefObject) {
238 | const dynamicStyle: TextStyle = {
239 | width: TEXT_INPUT_WIDTH,
240 | height: height,
241 | fontSize: height,
242 | color: 'transparent',
243 | padding: 0,
244 | borderWidth: 0,
245 | textAlign: 'center',
246 | backgroundColor: 'rgba(215,215,215,0.1)',
247 | }
248 | return (
249 |
257 | )
258 | }
259 |
260 | public componentDidMount() {
261 | if (this.props.isFocused) {
262 | this.focus()
263 | }
264 | }
265 |
266 | public componentDidUpdate(oldProps: ImageBlockInputProps) {
267 | const currenBlockedSelection = this.props.blockScopedSelection
268 | if (
269 | (this.props.isFocused && !oldProps.isFocused) ||
270 | (oldProps.blockScopedSelection &&
271 | currenBlockedSelection &&
272 | (oldProps.blockScopedSelection.start !== currenBlockedSelection.start ||
273 | (oldProps.blockScopedSelection && oldProps.blockScopedSelection.end !== currenBlockedSelection.end)) &&
274 | this.props.isFocused)
275 | ) {
276 | setTimeout(this.focus, 0)
277 | }
278 | }
279 |
280 | public render() {
281 | const imageDimensions = this.computeDimensions()
282 | const containerDimensions = {
283 | width: this.props.contentWidth,
284 | height: imageDimensions.height,
285 | }
286 | const spareWidthOnSides = Math.max((containerDimensions.width - imageDimensions.width) / 2, 0)
287 | const handlerWidth = Math.min(containerDimensions.width / 3, 60)
288 | return (
289 |
290 | {this.renderImage(imageDimensions, containerDimensions, spareWidthOnSides, handlerWidth)}
291 |
292 | )
293 | }
294 | }
295 |
--------------------------------------------------------------------------------
/src/components/GenericBlockInput/TextBlockInput/TextChangeSession.ts:
--------------------------------------------------------------------------------
1 | import { Selection, SelectionShape } from '@delta/Selection'
2 | import { DeltaChangeContext } from '@delta/DeltaChangeContext'
3 |
4 | export class TextChangeSession {
5 | private selectionBeforeChange: SelectionShape | null = null
6 | private selectionAfterChange: SelectionShape | null = null
7 | private textAfterChange: string | null = null
8 |
9 | public getDeltaChangeContext(): DeltaChangeContext {
10 | if (this.selectionAfterChange === null) {
11 | throw new Error('selectionAfterChange must be set before getting delta change context.')
12 | }
13 | if (this.selectionBeforeChange === null) {
14 | throw new Error('selectionBeforeChange must be set before getting delta change context.')
15 | }
16 | return new DeltaChangeContext(
17 | Selection.fromShape(this.selectionBeforeChange),
18 | Selection.fromShape(this.selectionAfterChange),
19 | )
20 | }
21 |
22 | public setTextAfterChange(textAfterChange: string) {
23 | this.textAfterChange = textAfterChange
24 | }
25 |
26 | public setSelectionBeforeChange(selectionBeforeChange: SelectionShape) {
27 | this.selectionBeforeChange = selectionBeforeChange
28 | }
29 |
30 | public setSelectionAfterChange(selectionAfterChange: SelectionShape) {
31 | this.selectionAfterChange = selectionAfterChange
32 | }
33 |
34 | public getTextAfterChange() {
35 | if (this.textAfterChange === null) {
36 | throw new Error('textAfterChange is not set.')
37 | }
38 | return this.textAfterChange
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/GenericBlockInput/TextBlockInput/TextChangeSessionBehavior.ts:
--------------------------------------------------------------------------------
1 | import { NativeSyntheticEvent, TextInputSelectionChangeEventData } from 'react-native'
2 | import { TextChangeSession } from './TextChangeSession'
3 | import { DocumentDeltaAtomicUpdate } from '@delta/DocumentDeltaAtomicUpdate'
4 | import { Selection, SelectionShape } from '@delta/Selection'
5 | import { TextOp } from '@delta/operations'
6 | import { DocumentDelta } from '@delta/DocumentDelta'
7 | import { Attributes } from '@delta/attributes'
8 |
9 | export interface TextChangeSessionOwner {
10 | getTextChangeSession: () => TextChangeSession | null
11 | setTextChangeSession: (textChangeSession: TextChangeSession | null) => void
12 | updateOps: (documentDeltaUpdate: DocumentDeltaAtomicUpdate) => void
13 | getBlockScopedSelection: () => SelectionShape | null
14 | getOps: () => TextOp[]
15 | getAttributesAtCursor: () => Attributes.Map
16 | updateSelection: (selection: SelectionShape) => void
17 | clearTimeout: () => void
18 | setTimeout: (callback: () => void, duration: number) => void
19 | }
20 |
21 | export interface TextChangeSessionBehavior {
22 | handleOnTextChanged: (owner: TextChangeSessionOwner, nextText: string) => void
23 | handleOnSelectionChanged: (
24 | owner: TextChangeSessionOwner,
25 | event: NativeSyntheticEvent,
26 | ) => void
27 | }
28 |
29 | function applySelectionChange(owner: TextChangeSessionOwner, textChangeSession: TextChangeSession) {
30 | const ops = owner.getOps()
31 | const documentDeltaUpdate = new DocumentDelta(ops).applyTextDiff(
32 | textChangeSession.getTextAfterChange(),
33 | textChangeSession.getDeltaChangeContext(),
34 | owner.getAttributesAtCursor(),
35 | )
36 | owner.setTextChangeSession(null)
37 | owner.updateOps(documentDeltaUpdate)
38 | }
39 |
40 | const IOS_TIMEOUT_DURATION = 10
41 |
42 | /**
43 | * As of RN61 on iOS, selection changes happens before text change.
44 | */
45 | export const iosTextChangeSessionBehavior: TextChangeSessionBehavior = {
46 | handleOnSelectionChanged(owner, { nativeEvent: { selection } }) {
47 | owner.clearTimeout()
48 | const textChangeSession = new TextChangeSession()
49 | textChangeSession.setSelectionBeforeChange(owner.getBlockScopedSelection() as SelectionShape)
50 | textChangeSession.setSelectionAfterChange(selection)
51 | owner.setTextChangeSession(textChangeSession)
52 | owner.setTimeout(() => {
53 | owner.setTextChangeSession(null)
54 | owner.updateSelection(selection)
55 | }, IOS_TIMEOUT_DURATION)
56 | },
57 | handleOnTextChanged(owner, nextText) {
58 | owner.clearTimeout()
59 | const textChangeSession = owner.getTextChangeSession()
60 | if (textChangeSession !== null) {
61 | textChangeSession.setTextAfterChange(nextText)
62 | applySelectionChange(owner, textChangeSession)
63 | }
64 | },
65 | }
66 |
67 | /**
68 | * As of RN61 on Android, text changes happens before selection change.
69 | */
70 | export const androidTextChangeSessionBehavior: TextChangeSessionBehavior = {
71 | handleOnTextChanged(owner, nextText) {
72 | const textChangeSession = new TextChangeSession()
73 | textChangeSession.setTextAfterChange(nextText)
74 | textChangeSession.setSelectionBeforeChange(owner.getBlockScopedSelection() as Selection)
75 | owner.setTextChangeSession(textChangeSession)
76 | },
77 | handleOnSelectionChanged(owner, { nativeEvent: { selection } }) {
78 | const nextSelection = Selection.between(selection.start, selection.end)
79 | const textChangeSession = owner.getTextChangeSession()
80 | if (textChangeSession !== null) {
81 | textChangeSession.setSelectionAfterChange(nextSelection)
82 | applySelectionChange(owner, textChangeSession)
83 | } else {
84 | owner.updateSelection(nextSelection)
85 | }
86 | },
87 | }
88 |
--------------------------------------------------------------------------------
/src/components/GenericBlockInput/TextBlockInput/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { memo, useRef, useCallback, forwardRef, useImperativeHandle, useEffect, PropsWithChildren } from 'react'
2 | import {
3 | View,
4 | TextInput,
5 | NativeSyntheticEvent,
6 | StyleSheet,
7 | TextInputProps,
8 | TextInputKeyPressEventData,
9 | StyleProp,
10 | TextStyle,
11 | Platform,
12 | } from 'react-native'
13 | import { RichText, richTextStyles } from '@components/RichText'
14 | import { SelectionShape } from '@delta/Selection'
15 | import { TextOp } from '@delta/operations'
16 | import { Attributes } from '@delta/attributes'
17 | import { Transforms } from '@core/Transforms'
18 | import { TextChangeSession } from './TextChangeSession'
19 | import { DocumentDeltaAtomicUpdate } from '@delta/DocumentDeltaAtomicUpdate'
20 | import { StandardBlockInputProps, FocusableInput } from '../types'
21 | import { genericStyles } from '@components/styles'
22 | import {
23 | TextChangeSessionOwner,
24 | androidTextChangeSessionBehavior,
25 | iosTextChangeSessionBehavior,
26 | } from './TextChangeSessionBehavior'
27 | import partial from 'ramda/es/partial'
28 |
29 | const styles = StyleSheet.create({
30 | grow: {
31 | flex: 1,
32 | },
33 | textInput: {
34 | textAlignVertical: 'top',
35 | },
36 | })
37 |
38 | export interface TextBlockInputProps extends StandardBlockInputProps {
39 | textOps: TextOp[]
40 | textAttributesAtCursor: Attributes.Map
41 | textStyle?: StyleProp
42 | textTransformSpecs: Transforms.Specs
43 | blockScopedSelection: SelectionShape | null
44 | disableSelectionOverrides: boolean
45 | }
46 |
47 | const constantTextInputProps: TextInputProps = {
48 | disableFullscreenUI: true,
49 | scrollEnabled: false,
50 | multiline: true,
51 | returnKeyType: 'next',
52 | keyboardType: 'default',
53 | textBreakStrategy: 'highQuality',
54 | importantForAutofill: 'noExcludeDescendants',
55 | blurOnSubmit: false,
56 | } as TextInputProps
57 |
58 | function selectionShapesAreEqual(s1: SelectionShape | null, s2: SelectionShape | null): boolean {
59 | return !!s1 && !!s2 && s1.start === s2.start && s1.end === s2.end
60 | }
61 |
62 | function propsAreEqual(
63 | previousProps: Readonly>,
64 | nextProps: Readonly>,
65 | ) {
66 | return (
67 | nextProps.overridingScopedSelection === previousProps.overridingScopedSelection &&
68 | nextProps.textOps === previousProps.textOps &&
69 | nextProps.isFocused === previousProps.isFocused &&
70 | selectionShapesAreEqual(previousProps.blockScopedSelection, nextProps.blockScopedSelection)
71 | )
72 | }
73 |
74 | const sessionBehavior = Platform.select({
75 | ios: iosTextChangeSessionBehavior,
76 | default: androidTextChangeSessionBehavior,
77 | })
78 |
79 | function _TextBlockInput(
80 | {
81 | textStyle,
82 | textOps,
83 | textTransformSpecs,
84 | overridingScopedSelection,
85 | disableSelectionOverrides,
86 | blockScopedSelection,
87 | controller,
88 | isFocused,
89 | textAttributesAtCursor,
90 | }: TextBlockInputProps,
91 | ref: any,
92 | ) {
93 | const inputRef = useRef()
94 | const timeoutRef = useRef()
95 | const textInputSelectionRef = useRef(blockScopedSelection)
96 | const nextOverrideSelectionRef = useRef(null)
97 | const cachedChangeSessionRef = useRef(null)
98 | const hasFocusRef = useRef(false)
99 | const setTimeoutLocal = useCallback((callback: Function, duration: number) => {
100 | timeoutRef.current = setTimeout(callback, duration)
101 | }, [])
102 | const clearTimeoutLocal = useCallback(() => {
103 | clearTimeout(timeoutRef.current)
104 | }, [])
105 | const getOps = useCallback(() => textOps, [textOps])
106 | const getAttributesAtCursor = useCallback(() => textAttributesAtCursor, [textAttributesAtCursor])
107 | const getBlockScopedSelection = useCallback(() => textInputSelectionRef.current, [])
108 | const getTextChangeSession = useCallback(() => cachedChangeSessionRef.current, [])
109 | const setTextChangeSession = useCallback(function setTextChangeSession(session: TextChangeSession | null) {
110 | cachedChangeSessionRef.current = session
111 | }, [])
112 | const setCachedSelection = useCallback(function setCachedSelection(selection: SelectionShape) {
113 | textInputSelectionRef.current = selection
114 | }, [])
115 | const focus = useCallback(function focus(nextOverrideSelection?: SelectionShape | null) {
116 | inputRef.current && inputRef.current.focus()
117 | if (nextOverrideSelection) {
118 | nextOverrideSelectionRef.current = nextOverrideSelection
119 | }
120 | }, [])
121 | const handleOnKeyPressed = useCallback(
122 | function handleOnKeyPressed(e: NativeSyntheticEvent) {
123 | const key = e.nativeEvent.key
124 | const cachedSelection = textInputSelectionRef.current
125 | if (key === 'Backspace' && cachedSelection && cachedSelection.start === 0 && cachedSelection.end === 0) {
126 | controller.removeOneBeforeBlock()
127 | }
128 | },
129 | [controller],
130 | )
131 | useImperativeHandle(ref, () => ({
132 | focus,
133 | }))
134 | // Clear timeout on unmount
135 | useEffect(() => {
136 | return clearTimeoutLocal
137 | }, [clearTimeoutLocal])
138 | // On focus
139 | useEffect(() => {
140 | if (isFocused && !hasFocusRef.current) {
141 | focus()
142 | } else if (!isFocused) {
143 | hasFocusRef.current = false
144 | }
145 | }, [isFocused, blockScopedSelection, focus])
146 | const handleOnFocus = useCallback(function handleOnFocus() {
147 | hasFocusRef.current = true
148 | }, [])
149 | const updateOps = useCallback(
150 | function updateOps(documentDeltaUpdate: DocumentDeltaAtomicUpdate) {
151 | setCachedSelection(documentDeltaUpdate.selectionAfterChange.toShape())
152 | return controller.applyAtomicDeltaUpdateInBlock(documentDeltaUpdate)
153 | },
154 | [controller, setCachedSelection],
155 | )
156 | const updateSelection = useCallback(
157 | function updateSelection(currentSelection: SelectionShape) {
158 | setCachedSelection(currentSelection)
159 | controller.updateSelectionInBlock(currentSelection)
160 | },
161 | [controller, setCachedSelection],
162 | )
163 | const sessionChangeOwner: TextChangeSessionOwner = {
164 | getBlockScopedSelection,
165 | getTextChangeSession,
166 | getAttributesAtCursor,
167 | getOps,
168 | setTextChangeSession,
169 | updateOps,
170 | updateSelection,
171 | setTimeout: setTimeoutLocal,
172 | clearTimeout: clearTimeoutLocal,
173 | }
174 | const forcedSelection = !disableSelectionOverrides && overridingScopedSelection
175 | const handleOnChangeText = useCallback(partial(sessionBehavior.handleOnTextChanged, [sessionChangeOwner]), [
176 | sessionChangeOwner,
177 | ])
178 | const handleOnSelectionChange = useCallback(partial(sessionBehavior.handleOnSelectionChanged, [sessionChangeOwner]), [
179 | sessionChangeOwner,
180 | ])
181 | const selection = forcedSelection || undefined
182 | return (
183 |
184 |
194 |
195 |
196 |
197 | )
198 | }
199 |
200 | /**
201 | * A component which is responsible for providing a user interface to edit {@link RichContent}.
202 | */
203 | export const TextBlockInput = memo(forwardRef(_TextBlockInput), propsAreEqual)
204 |
205 | TextBlockInput.displayName = 'TextBlockInput'
206 |
--------------------------------------------------------------------------------
/src/components/GenericBlockInput/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from 'react'
2 | import { StyleProp, TextStyle, View, ViewStyle } from 'react-native'
3 | import { TextBlockInput, TextBlockInputProps } from './TextBlockInput'
4 | import { ImageBlockInput, ImageBlockInputProps } from './ImageBlockInput'
5 | import { TextOp, ImageOp } from '@delta/operations'
6 | import invariant from 'invariant'
7 | import { Transforms } from '@core/Transforms'
8 | import { Attributes } from '@delta/attributes'
9 | import { SelectionShape } from '@delta/Selection'
10 | import { StandardBlockInputProps, FocusableInput } from './types'
11 | import { Images } from '@core/Images'
12 |
13 | export interface GenericBlockInputProps extends StandardBlockInputProps {
14 | textTransformSpecs: Transforms.Specs
15 | textAttributesAtCursor: Attributes.Map
16 | contentWidth: null | number
17 | blockScopedSelection: SelectionShape | null
18 | hightlightOnFocus: boolean
19 | ImageComponent: Images.Component
20 | disableSelectionOverrides?: boolean
21 | textStyle?: StyleProp
22 | blockStyle?: StyleProp
23 | maxMediaBlockWidth?: number
24 | maxMediaBlockHeight?: number
25 | underlayColor?: string
26 | }
27 |
28 | export { FocusableInput }
29 |
30 | export class GenericBlockInput extends PureComponent>
31 | implements FocusableInput {
32 | private ref = React.createRef()
33 |
34 | private getStyles() {
35 | if (this.props.hightlightOnFocus) {
36 | return this.props.isFocused
37 | ? { borderColor: 'red', borderWidth: 1 }
38 | : { borderColor: 'transparent', borderWidth: 1 }
39 | }
40 | return undefined
41 | }
42 |
43 | public focus() {
44 | this.ref.current && this.ref.current.focus()
45 | }
46 |
47 | public render() {
48 | const {
49 | descriptor,
50 | textStyle,
51 | contentWidth,
52 | blockStyle,
53 | blockScopedSelection,
54 | controller,
55 | disableSelectionOverrides,
56 | hightlightOnFocus,
57 | underlayColor,
58 | isFocused,
59 | maxMediaBlockHeight,
60 | maxMediaBlockWidth,
61 | overridingScopedSelection,
62 | textAttributesAtCursor,
63 | textTransformSpecs,
64 | ImageComponent,
65 | } = this.props
66 | let block = null
67 | const realContentWidth = contentWidth ? contentWidth - (hightlightOnFocus ? 2 : 0) : null
68 | if (descriptor.kind === 'text') {
69 | const textBlockProps: TextBlockInputProps = {
70 | descriptor,
71 | textStyle,
72 | controller,
73 | isFocused,
74 | blockScopedSelection,
75 | disableSelectionOverrides: disableSelectionOverrides || false,
76 | overridingScopedSelection: overridingScopedSelection,
77 | textAttributesAtCursor,
78 | textTransformSpecs,
79 | textOps: descriptor.opsSlice as TextOp[],
80 | }
81 | block =
82 | } else if (descriptor.kind === 'image' && realContentWidth !== null) {
83 | invariant(descriptor.opsSlice.length === 1, `Image blocks must be grouped alone.`)
84 | const imageBlockProps: ImageBlockInputProps = {
85 | descriptor,
86 | blockScopedSelection,
87 | controller,
88 | isFocused,
89 | maxMediaBlockHeight,
90 | maxMediaBlockWidth,
91 | overridingScopedSelection,
92 | underlayColor,
93 | ImageComponent,
94 | imageOp: descriptor.opsSlice[0] as ImageOp,
95 | contentWidth: realContentWidth,
96 | }
97 | block = ref={this.ref as any} {...imageBlockProps} />
98 | }
99 | return {block}
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/src/components/GenericBlockInput/types.ts:
--------------------------------------------------------------------------------
1 | import { BlockDescriptor } from '@model/blocks'
2 | import { BlockController } from '@components/BlockController'
3 | import { SelectionShape } from '@delta/Selection'
4 |
5 | export interface StandardBlockInputProps {
6 | descriptor: BlockDescriptor
7 | controller: BlockController
8 | isFocused: boolean
9 | overridingScopedSelection: SelectionShape | null
10 | }
11 |
12 | /**
13 | * @public
14 | */
15 | export interface FocusableInput {
16 | /**
17 | * Focus programatically.
18 | */
19 | focus: () => void
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/GenericBlockView/ImageBlockView.tsx:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from 'react'
2 | import { View, StyleSheet } from 'react-native'
3 | import { Images, computeImageFrame } from '@core/Images'
4 | import { ImageOp } from '@delta/operations'
5 | import { StandardBlockViewProps } from './types'
6 |
7 | export interface ImageBlockViewProps extends StandardBlockViewProps {
8 | ImageComponent: Images.Component
9 | imageOp: ImageOp
10 | contentWidth: number
11 | maxMediaBlockWidth?: number
12 | maxMediaBlockHeight?: number
13 | }
14 |
15 | const styles = StyleSheet.create({
16 | wrapper: {
17 | flexDirection: 'row',
18 | justifyContent: 'center',
19 | },
20 | })
21 |
22 | export class ImageBlockView extends PureComponent> {
23 | private computeDimensions() {
24 | const { imageOp, maxMediaBlockHeight, maxMediaBlockWidth, contentWidth } = this.props
25 | return computeImageFrame(imageOp.insert, contentWidth, maxMediaBlockHeight, maxMediaBlockWidth)
26 | }
27 |
28 | public render() {
29 | const { ImageComponent, imageOp } = this.props
30 | const imageComponentProps: Images.ComponentProps = {
31 | description: imageOp.insert,
32 | printDimensions: this.computeDimensions(),
33 | }
34 | return (
35 |
36 |
37 |
38 | )
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/GenericBlockView/TextBlockView.tsx:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from 'react'
2 | import { Transforms } from '@core/Transforms'
3 | import { StyleProp, TextStyle, Text } from 'react-native'
4 | import { TextOp } from '@delta/operations'
5 | import { RichText } from '@components/RichText'
6 | import { StandardBlockViewProps } from './types'
7 |
8 | export interface TextBlockViewProps extends StandardBlockViewProps {
9 | textTransformSpecs: Transforms.Specs
10 | textStyle?: StyleProp
11 | textOps: TextOp[]
12 | }
13 |
14 | export class TextBlockView extends PureComponent {
15 | public render() {
16 | const { textStyle, textOps, textTransformSpecs } = this.props
17 | return (
18 |
19 |
20 |
21 | )
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/GenericBlockView/index.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | import React, { PureComponent } from 'react'
3 | import { TextBlockView, TextBlockViewProps } from './TextBlockView'
4 | import { StyleProp, TextStyle, ViewStyle, View } from 'react-native'
5 | import { ImageBlockView, ImageBlockViewProps } from './ImageBlockView'
6 | import { TextOp, ImageOp } from '@delta/operations'
7 | import invariant from 'invariant'
8 | import { Transforms } from '@core/Transforms'
9 | import { Images } from '@core/Images'
10 | import { StandardBlockViewProps } from './types'
11 |
12 | export interface GenericBlockViewProps extends StandardBlockViewProps {
13 | textStyle?: StyleProp
14 | ImageComponent: Images.Component
15 | textTransformSpecs: Transforms.Specs
16 | contentWidth: null | number
17 | blockStyle?: StyleProp
18 | maxMediaBlockWidth?: number
19 | maxMediaBlockHeight?: number
20 | }
21 |
22 | export class GenericBlockView extends PureComponent> {
23 | public render() {
24 | const {
25 | descriptor,
26 | textStyle,
27 | contentWidth,
28 | blockStyle,
29 | maxMediaBlockHeight,
30 | maxMediaBlockWidth,
31 | textTransformSpecs,
32 | ImageComponent,
33 | } = this.props
34 | let block = null
35 | if (descriptor.kind === 'text') {
36 | const textBlockProps: TextBlockViewProps = {
37 | descriptor,
38 | textStyle,
39 | textTransformSpecs,
40 | textOps: descriptor.opsSlice as TextOp[],
41 | }
42 | block =
43 | } else if (descriptor.kind === 'image' && contentWidth !== null) {
44 | invariant(descriptor.opsSlice.length === 1, `Image blocks must be grouped alone.`)
45 | const imageBlockProps: ImageBlockViewProps = {
46 | descriptor,
47 | ImageComponent,
48 | maxMediaBlockHeight,
49 | maxMediaBlockWidth,
50 | imageOp: descriptor.opsSlice[0] as ImageOp,
51 | contentWidth: contentWidth,
52 | }
53 | block = {...imageBlockProps} />
54 | }
55 | return {block}
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/components/GenericBlockView/types.d.ts:
--------------------------------------------------------------------------------
1 | import { BlockDescriptor } from '@model/blocks'
2 |
3 | export interface StandardBlockViewProps {
4 | descriptor: BlockDescriptor
5 | }
6 |
--------------------------------------------------------------------------------
/src/components/Print.ts:
--------------------------------------------------------------------------------
1 | import { Component } from 'react'
2 | import { DocumentRenderer, DocumentRendererProps, DocumentRendererState } from './DocumentRenderer'
3 | import { BlockAssembler } from '@model/BlockAssembler'
4 | import { Images } from '@core/Images'
5 |
6 | /**
7 | * A set of definitions relative to {@link (Print:class)} component.
8 |
9 | * @public
10 | */
11 | export declare namespace Print {
12 | /**
13 | * {@link (Print:class)} properties.
14 | */
15 | export type Props = DocumentRendererProps
16 | }
17 |
18 | type PrintState = DocumentRendererState
19 |
20 | // eslint-disable-next-line @typescript-eslint/class-name-casing
21 | class _Print extends DocumentRenderer> {
22 | public static displayName = 'Print'
23 | public static propTypes = DocumentRenderer.propTypes
24 | public static defaultProps = DocumentRenderer.defaultProps
25 |
26 | public state: PrintState = {
27 | containerWidth: null,
28 | }
29 |
30 | public render() {
31 | this.assembler = new BlockAssembler(this.props.document)
32 | return this.renderRoot(this.assembler.getBlocks().map(this.renderBlockView))
33 | }
34 | }
35 |
36 | /**
37 | * A component solely responsible for viewing {@link Document | document}.
38 | *
39 | * @public
40 | *
41 | */
42 | export declare class Print extends Component> {}
43 |
44 | exports.Print = _Print
45 |
--------------------------------------------------------------------------------
/src/components/RichText.tsx:
--------------------------------------------------------------------------------
1 | import React, { ReactNode, Component, ComponentClass } from 'react'
2 | import { TextStyle, Text, StyleProp, StyleSheet } from 'react-native'
3 | import { GenericOp, isTextOp, TextOp } from '@delta/operations'
4 | import { Transforms } from '@core/Transforms'
5 | import invariant from 'invariant'
6 | import { boundMethod } from 'autobind-decorator'
7 | import { LineWalker } from '@delta/LineWalker'
8 | import { Attributes } from '@delta/attributes'
9 | import PropTypes from 'prop-types'
10 | import { OpsPropType, TextTransformSpecsType } from './types'
11 |
12 | /**
13 | * A set of definitions related to the {@link (RichText:type)} component.
14 | *
15 | * @public
16 | */
17 | declare namespace RichText {
18 | /**
19 | * Properties for the {@link (RichText:type)} component.
20 | */
21 | export interface Props {
22 | /**
23 | * The content to display.
24 | */
25 | textOps: TextOp[]
26 | /**
27 | * An object describing how to convert attributes to style properties.
28 | */
29 | textTransformSpecs: Transforms.Specs
30 | /**
31 | * Default text style.
32 | *
33 | * @remarks
34 | *
35 | * Text style can be overriden depending on attributes applying to an {@link GenericOp | operation}.
36 | * The mapped styled are dictated by the `textTransformsReg` property.
37 | */
38 | textStyle?: StyleProp
39 | }
40 | }
41 |
42 | function getLineStyle(lineType: Attributes.LineType): StyleProp {
43 | // Padding is supported from direct Text descendents of
44 | // TextInput as of RN60
45 | // TODO test
46 | switch (lineType) {
47 | case 'normal':
48 | return null
49 | case 'quoted':
50 | return { borderLeftWidth: 3, borderLeftColor: 'black' }
51 | }
52 | }
53 |
54 | export const richTextStyles = StyleSheet.create({
55 | defaultText: {
56 | fontSize: 18,
57 | },
58 | grow: {
59 | flexGrow: 1,
60 | },
61 | })
62 |
63 | // eslint-disable-next-line @typescript-eslint/class-name-casing
64 | class _RichText extends Component {
65 | private transforms: Transforms
66 | public static propTypes: Record = {
67 | textOps: OpsPropType.isRequired,
68 | textStyle: PropTypes.any,
69 | textTransformSpecs: TextTransformSpecsType.isRequired,
70 | }
71 |
72 | public constructor(props: RichText.Props) {
73 | super(props)
74 | this.renderOperation = this.renderOperation.bind(this)
75 | this.transforms = new Transforms(props.textTransformSpecs)
76 | }
77 |
78 | @boundMethod
79 | private renderOperation(op: GenericOp, lineIndex: number, elemIndex: number) {
80 | invariant(isTextOp(op), 'Only textual documentDelta are supported')
81 | const key = `text-${lineIndex}-${elemIndex}`
82 | const styles = this.transforms.getStylesFromOp(op as TextOp)
83 | return (
84 |
85 | {op.insert}
86 |
87 | )
88 | }
89 |
90 | private renderLines() {
91 | const { textOps, textStyle } = this.props
92 | const children: ReactNode[][] = []
93 | new LineWalker(textOps).eachLine(({ lineType, delta: lineDelta, index }) => {
94 | const textStyles = [textStyle, getLineStyle(lineType)]
95 | const lineChildren = lineDelta.ops.map((l, elIndex) => this.renderOperation(l, index, elIndex))
96 | children.push([
97 |
98 | {lineChildren}
99 | ,
100 | ])
101 | })
102 | if (children.length) {
103 | let index = 0
104 | return children.reduce((prev: ReactNode[], curr: ReactNode[]) => {
105 | if (prev) {
106 | // tslint:disable-next-line:no-increment-decrement
107 | return [...prev, {'\n'}, ...curr]
108 | }
109 | return curr
110 | })
111 | }
112 | return []
113 | }
114 |
115 | /**
116 | * @internal
117 | */
118 | public shouldComponentUpdate() {
119 | return true
120 | }
121 |
122 | /**
123 | * @internal
124 | */
125 | public componentDidUpdate(oldProps: RichText.Props) {
126 | invariant(
127 | oldProps.textTransformSpecs === this.props.textTransformSpecs,
128 | 'transforms prop cannot be changed after instantiation',
129 | )
130 | }
131 |
132 | /**
133 | * @internal
134 | */
135 | public render() {
136 | return this.renderLines()
137 | }
138 | }
139 |
140 | /**
141 | * A component to display rich content.
142 | *
143 | * @public
144 | *
145 | * @internalRemarks
146 | *
147 | * This type trick is aimed at preventing from exporting members which should be out of API surface.
148 | */
149 | type RichText = ComponentClass
150 | const RichText = _RichText as RichText
151 |
152 | export { RichText }
153 |
--------------------------------------------------------------------------------
/src/components/Typer.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Document } from '@model/document'
3 | import { boundMethod } from 'autobind-decorator'
4 | import PropTypes from 'prop-types'
5 | import { GenericBlockInput, FocusableInput } from './GenericBlockInput'
6 | import { Block } from '@model/Block'
7 | import { DocumentProvider, BlockController } from './BlockController'
8 | import { BlockAssembler } from '@model/BlockAssembler'
9 | import { SelectionShape, Selection } from '@delta/Selection'
10 | import { DocumentRenderer, DocumentRendererProps } from './DocumentRenderer'
11 | import { Bridge, BridgeStatic } from '@core/Bridge'
12 | import invariant from 'invariant'
13 | import { ImageHooksType } from './types'
14 | import { defaults } from './defaults'
15 | import { Images } from '@core/Images'
16 | import { Transforms } from '@core/Transforms'
17 | import equals from 'ramda/es/equals'
18 | import { Platform } from 'react-native'
19 |
20 | interface TyperState {
21 | containerWidth: number | null
22 | overridingSelection: SelectionShape | null
23 | }
24 |
25 | /**
26 | * A set of definitions relative to {@link (Typer:class)} component.
27 | *
28 | * @public
29 | */
30 | export declare namespace Typer {
31 | /**
32 | * {@link (Typer:class)} properties.
33 | */
34 | export interface Props extends DocumentRendererProps {
35 | /**
36 | * The {@link (Bridge:interface)} instance.
37 | *
38 | * @remarks This property MUST NOT be changed after instantiation.
39 | */
40 | bridge: Bridge
41 |
42 | /**
43 | * Callbacks on image insertion and deletion.
44 | */
45 | imageHooks?: Images.Hooks
46 |
47 | /**
48 | * Handler to receive {@link Document| document} updates.
49 | *
50 | */
51 | onDocumentUpdate?: (nextDocumentContent: Document) => void
52 |
53 | /**
54 | * Disable edition.
55 | */
56 | readonly?: boolean
57 |
58 | /**
59 | * Customize the color of image controls upon activation.
60 | */
61 | underlayColor?: string
62 |
63 | /**
64 | * In debug mode, active block will be highlighted.
65 | */
66 | debug?: boolean
67 |
68 | /**
69 | * Disable selection overrides.
70 | *
71 | * @remarks
72 | *
73 | * In some instances, the typer will override active text selections. This will happen when user press the edge of a media block:
74 | * the selection will be overriden in order to select the preceding or following text input closest selectable unit.
75 | *
76 | * However, some versions of React Native have an Android bug which can trigger a `setSpan` error. If such errors occur, you should disable selection overrides.
77 | * {@link https://github.com/facebook/react-native/issues/25265}
78 | * {@link https://github.com/facebook/react-native/issues/17236}
79 | * {@link https://github.com/facebook/react-native/issues/18316}
80 | */
81 | disableSelectionOverrides?: boolean
82 | /**
83 | * By default, when user select text and apply transforms, the selection will be overriden to stay the same and allow user to apply multiple transforms.
84 | * This is the normal behavior on iOS, but not on Android. Typeksill will by default enforce this behavior on Android too.
85 | * However, when this prop is set to `true`, such behavior will be prevented on Android.
86 | */
87 | androidDisableMultipleAttributeEdits?: boolean
88 | }
89 | }
90 |
91 | // eslint-disable-next-line @typescript-eslint/class-name-casing
92 | class _Typer extends DocumentRenderer, TyperState> implements DocumentProvider {
93 | public static displayName = 'Typer'
94 | public static propTypes: Record, any> = {
95 | ...DocumentRenderer.propTypes,
96 | bridge: PropTypes.instanceOf(BridgeStatic).isRequired,
97 | onDocumentUpdate: PropTypes.func,
98 | debug: PropTypes.bool,
99 | underlayColor: PropTypes.string,
100 | readonly: PropTypes.bool,
101 | imageHooks: ImageHooksType,
102 | disableSelectionOverrides: PropTypes.bool,
103 | androidDisableMultipleAttributeEdits: PropTypes.bool,
104 | }
105 |
106 | public static defaultProps: Partial, any>> = {
107 | ...DocumentRenderer.defaultProps,
108 | readonly: false,
109 | debug: false,
110 | underlayColor: defaults.underlayColor,
111 | imageHooks: defaults.imageHooks,
112 | }
113 |
114 | private focusedBlock = React.createRef>()
115 |
116 | public state: TyperState = {
117 | containerWidth: null,
118 | overridingSelection: null,
119 | }
120 |
121 | public constructor(props: Typer.Props) {
122 | super(props)
123 | }
124 |
125 | @boundMethod
126 | private clearSelection() {
127 | this.setState({ overridingSelection: null })
128 | }
129 |
130 | public getDocument() {
131 | return this.props.document
132 | }
133 |
134 | public getImageHooks() {
135 | return this.props.imageHooks as Images.Hooks
136 | }
137 |
138 | public async updateDocument(documentUpdate: Document): Promise {
139 | return (
140 | (this.props.onDocumentUpdate && this.props.document && this.props.onDocumentUpdate(documentUpdate)) ||
141 | Promise.resolve()
142 | )
143 | }
144 |
145 | @boundMethod
146 | private renderBlockInput(block: Block) {
147 | const descriptor = block.descriptor
148 | const { overridingSelection } = this.state
149 | const {
150 | textStyle,
151 | debug,
152 | underlayColor,
153 | maxMediaBlockHeight,
154 | maxMediaBlockWidth,
155 | ImageComponent,
156 | textTransformSpecs,
157 | disableSelectionOverrides,
158 | } = this.props
159 | const { selectedTextAttributes } = this.props.document
160 | const key = `block-input-${descriptor.kind}-${descriptor.blockIndex}`
161 | // TODO use weak map to memoize controller
162 | const controller = new BlockController(block, this)
163 | const isFocused = block.isFocused(this.props.document)
164 | return (
165 | }
176 | descriptor={descriptor}
177 | maxMediaBlockHeight={maxMediaBlockHeight}
178 | maxMediaBlockWidth={maxMediaBlockWidth}
179 | blockScopedSelection={block.getBlockScopedSelection(this.props.document.currentSelection)}
180 | overridingScopedSelection={
181 | isFocused && overridingSelection ? block.getBlockScopedSelection(overridingSelection) : null
182 | }
183 | textAttributesAtCursor={selectedTextAttributes}
184 | disableSelectionOverrides={disableSelectionOverrides}
185 | textTransformSpecs={textTransformSpecs as Transforms.Specs}
186 | />
187 | )
188 | }
189 |
190 | public overrideSelection(overridingSelection: SelectionShape) {
191 | this.setState({ overridingSelection })
192 | }
193 |
194 | public componentDidMount() {
195 | const sheetEventDom = this.props.bridge.getSheetEventDomain()
196 | sheetEventDom.addApplyTextTransformToSelectionListener(this, async (attributeName, attributeValue) => {
197 | const currentSelection = this.props.document.currentSelection
198 | await this.updateDocument(this.assembler.applyTextTransformToSelection(attributeName, attributeValue))
199 | // Force the current selection to allow multiple edits on Android.
200 | if (
201 | Platform.OS === 'android' &&
202 | Selection.fromShape(currentSelection).length() > 0 &&
203 | !this.props.androidDisableMultipleAttributeEdits
204 | ) {
205 | this.overrideSelection(this.props.document.currentSelection)
206 | }
207 | })
208 | sheetEventDom.addInsertOrReplaceAtSelectionListener(this, async element => {
209 | await this.updateDocument(this.assembler.insertOrReplaceAtSelection(element))
210 | if (element.type === 'image') {
211 | const { onImageAddedEvent } = this.props.imageHooks as Images.Hooks
212 | onImageAddedEvent && onImageAddedEvent(element.description)
213 | }
214 | })
215 | }
216 |
217 | public componentWillUnmount() {
218 | this.props.bridge.getSheetEventDomain().release(this)
219 | }
220 |
221 | public async componentDidUpdate(oldProps: Typer.Props) {
222 | invariant(oldProps.bridge === this.props.bridge, 'bridge prop cannot be changed after instantiation')
223 | const currentSelection = this.props.document.currentSelection
224 | const currentSelectedTextAttributes = this.props.document.selectedTextAttributes
225 | if (oldProps.document.currentSelection !== currentSelection) {
226 | const nextDocument = this.assembler.updateTextAttributesAtSelection()
227 | // update text attributes when necessary
228 | if (!equals(nextDocument.selectedTextAttributes, currentSelectedTextAttributes)) {
229 | await this.updateDocument(nextDocument)
230 | }
231 | }
232 | if (this.state.overridingSelection !== null) {
233 | setTimeout(this.clearSelection, Platform.select({ ios: 100, default: 0 }))
234 | }
235 | }
236 |
237 | public focus = () => {
238 | this.focusedBlock.current && this.focusedBlock.current.focus()
239 | }
240 |
241 | public render() {
242 | this.assembler = new BlockAssembler(this.props.document)
243 | const { readonly } = this.props
244 | return this.renderRoot(this.assembler.getBlocks().map(readonly ? this.renderBlockView : this.renderBlockInput))
245 | }
246 | }
247 |
248 | exports.Typer = _Typer
249 |
250 | /**
251 | * A component solely responsible for editing {@link Document | document}.
252 | *
253 | * @remarks This component is [controlled](https://reactjs.org/docs/forms.html#controlled-components).
254 | *
255 | * You MUST provide:
256 | *
257 | * - A {@link Document | `document`} prop to render contents. You can initialize it with {@link buildEmptyDocument};
258 | * - A {@link (Bridge:interface) | `bridge` } prop to share document-related events with external controls;
259 | *
260 | * You SHOULD provide:
261 | *
262 | * - A `onDocumentUpdate` prop to update its state.
263 | *
264 | * @public
265 | *
266 | */
267 | export declare class Typer extends Component>
268 | implements FocusableInput {
269 | focus: () => void
270 | }
271 |
--------------------------------------------------------------------------------
/src/components/__tests__/Print-test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | // Test renderer must be required after react-native.
3 | import renderer from 'react-test-renderer'
4 | import { Print } from '@components/Print'
5 | import { buildEmptyDocument } from '@model/document'
6 |
7 | describe('@components/', () => {
8 | it('should renders without crashing', () => {
9 | const print = renderer.create()
10 | expect(print).toBeTruthy()
11 | })
12 | })
13 |
--------------------------------------------------------------------------------
/src/components/__tests__/RichText-test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | // Test renderer must be required after react-native.
3 | import renderer from 'react-test-renderer'
4 | import { RichText } from '@components/RichText'
5 | import { defaultTextTransforms } from '@core/Transforms'
6 | import { flattenTextChild } from '@test/vdom'
7 | import { mockDocumentDelta } from '@test/document'
8 | import { TextOp } from '@delta/operations'
9 |
10 | describe('@components/', () => {
11 | it('should renders without crashing', () => {
12 | const delta = mockDocumentDelta()
13 | const richText = renderer.create(
14 | ,
15 | )
16 | expect(richText).toBeTruthy()
17 | })
18 | it('should comply with document documentDelta by removing last newline character', () => {
19 | const delta = mockDocumentDelta([
20 | { insert: 'eheh' },
21 | { insert: '\n', attributes: { $type: 'normal' } },
22 | { insert: 'ahah' },
23 | { insert: '\n', attributes: { $type: 'normal' } },
24 | { insert: 'ohoh\n' },
25 | ])
26 | const richText = renderer.create(
27 | ,
28 | )
29 | const textContent = flattenTextChild(richText.root)
30 | expect(textContent.join('')).toEqual(['eheh', '\n', 'ahah', '\n', 'ohoh'].join(''))
31 | })
32 | })
33 |
--------------------------------------------------------------------------------
/src/components/__tests__/Toolbar-test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | // Test renderer must be required after react-native.
3 | import renderer from 'react-test-renderer'
4 | import { buildEmptyDocument } from '@model/document'
5 | import { buildBridge } from '@core/Bridge'
6 | import { Toolbar } from '@components/Toolbar'
7 |
8 | describe('@components/', () => {
9 | it('should renders without crashing', () => {
10 | const toolbar = renderer.create()
11 | expect(toolbar).toBeTruthy()
12 | })
13 | })
14 |
--------------------------------------------------------------------------------
/src/components/__tests__/Typer-test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | // Test renderer must be required after react-native.
3 | import renderer from 'react-test-renderer'
4 | import { buildEmptyDocument, Document } from '@model/document'
5 | import { Typer } from '@components/Typer'
6 | import { buildBridge } from '@core/Bridge'
7 | import { buildTextOp } from '@delta/operations'
8 |
9 | describe('@components/', () => {
10 | it('should renders without crashing', () => {
11 | const typer = renderer.create()
12 | expect(typer).toBeTruthy()
13 | })
14 | it('should not call onDocumentUpdate after external updates', async () => {
15 | const bridge = buildBridge()
16 | const initialDocument = buildEmptyDocument()
17 | const callback = jest.fn()
18 | const typer1 = await renderer.create(
19 | ,
20 | )
21 | await typer1.update()
22 | expect(callback).not.toHaveBeenCalled()
23 | })
24 | it("should not call onDocumentUpdate after selection change which doesn't trigger text attribtues changes", async () => {
25 | const bridge = buildBridge()
26 | const initialDocument: Document = {
27 | ...buildEmptyDocument(),
28 | ops: [buildTextOp('A\n')],
29 | }
30 | const nextDocument: Document = {
31 | ...initialDocument,
32 | currentSelection: { start: 1, end: 1 },
33 | }
34 | const callback = jest.fn()
35 | const typer1 = await renderer.create(
36 | ,
37 | )
38 | await typer1.update()
39 | expect(callback).not.toHaveBeenCalled()
40 | })
41 | it('should call onDocumentUpdate after selection change which trigger text attribtues changes', async () => {
42 | const bridge = buildBridge()
43 | const initialDocument: Document = {
44 | ...buildEmptyDocument(),
45 | ops: [buildTextOp('A'), buildTextOp('B', { bold: true }), buildTextOp('\n')],
46 | }
47 | const nextDocument: Document = {
48 | ...initialDocument,
49 | currentSelection: { start: 2, end: 2 },
50 | }
51 | const callback = jest.fn()
52 | const typer1 = await renderer.create(
53 | ,
54 | )
55 | await typer1.update()
56 | expect(callback).toHaveBeenCalled()
57 | })
58 | it('should call onDocumentUpdate with new document version after block insertion', async () => {
59 | const bridge = buildBridge()
60 | let document: Document = {
61 | ...buildEmptyDocument(),
62 | ops: [buildTextOp('AB\n')],
63 | }
64 | const onDocumentUpdate = (doc: Document) => {
65 | document = doc
66 | }
67 | await renderer.create()
68 | const imageBlockDesc = { height: 0, width: 0, source: { uri: 'https://foo.bar' } }
69 | bridge.getControlEventDomain().insertOrReplaceAtSelection({ type: 'image', description: imageBlockDesc })
70 | expect(document.ops).toMatchObject([{ insert: { kind: 'image', ...imageBlockDesc } }, buildTextOp('AB\n')])
71 | })
72 | it('should call onDocumentUpdate with new document version after text attribute changes', async () => {
73 | const bridge = buildBridge()
74 | let document: Document = {
75 | ...buildEmptyDocument(),
76 | ops: [buildTextOp('AB\n')],
77 | currentSelection: { start: 0, end: 2 },
78 | }
79 | const onDocumentUpdate = (doc: Document) => {
80 | document = doc
81 | }
82 | await renderer.create()
83 | bridge.getControlEventDomain().applyTextTransformToSelection('bold', true)
84 | expect(document.ops).toMatchObject([buildTextOp('AB', { bold: true }), buildTextOp('\n')])
85 | })
86 | })
87 |
--------------------------------------------------------------------------------
/src/components/defaults.ts:
--------------------------------------------------------------------------------
1 | import { StandardImageComponent } from '@core/Images'
2 | import { defaultTextTransforms } from '@core/Transforms'
3 |
4 | export const defaults = {
5 | spacing: 15,
6 | ImageComponent: StandardImageComponent,
7 | textTransformsSpecs: defaultTextTransforms,
8 | underlayColor: 'rgba(30,30,30,0.3)',
9 | imageHooks: {},
10 | }
11 |
--------------------------------------------------------------------------------
/src/components/styles.ts:
--------------------------------------------------------------------------------
1 | import { StyleSheet, ViewStyle } from 'react-native'
2 |
3 | const zeroMargin: ViewStyle = {
4 | margin: 0,
5 | marginBottom: 0,
6 | marginEnd: 0,
7 | marginHorizontal: 0,
8 | marginLeft: 0,
9 | marginRight: 0,
10 | marginStart: 0,
11 | marginTop: 0,
12 | marginVertical: 0,
13 | }
14 |
15 | export function overridePadding(padding: number) {
16 | return {
17 | padding,
18 | paddingBottom: padding,
19 | paddingEnd: padding,
20 | paddingHorizontal: padding,
21 | paddingLeft: padding,
22 | paddingRight: padding,
23 | paddingStart: padding,
24 | paddingTop: padding,
25 | paddingVertical: padding,
26 | }
27 | }
28 |
29 | const zeroPadding: ViewStyle = overridePadding(0)
30 |
31 | export const genericStyles = StyleSheet.create({
32 | zeroMargin,
33 | zeroPadding,
34 | /**
35 | * As of React Native 0.60, merging padding algorithm doesn't
36 | * allow more specific spacing attributes to override more
37 | * generic ones. As such, we must override all.
38 | */
39 | zeroSpacing: { ...zeroMargin, ...zeroPadding },
40 | })
41 |
--------------------------------------------------------------------------------
/src/components/types.ts:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import { Document } from '@model/document'
3 | import { Toolbar } from './Toolbar'
4 | import { Images } from '@core/Images'
5 |
6 | export const OpsPropType = PropTypes.arrayOf(PropTypes.object)
7 |
8 | const documentShape: Record = {
9 | ops: OpsPropType,
10 | currentSelection: PropTypes.object,
11 | selectedTextAttributes: PropTypes.object,
12 | lastDiff: OpsPropType,
13 | schemaVersion: PropTypes.number,
14 | }
15 |
16 | const controlSpecsShape: Record = {
17 | IconComponent: PropTypes.func.isRequired,
18 | actionType: PropTypes.oneOfType([PropTypes.number, PropTypes.string, PropTypes.symbol]).isRequired,
19 | iconProps: PropTypes.object,
20 | actionOptions: PropTypes.any,
21 | }
22 |
23 | export const DocumentPropType = PropTypes.shape(documentShape)
24 |
25 | export const ToolbarLayoutPropType = PropTypes.arrayOf(
26 | PropTypes.oneOfType([PropTypes.symbol, PropTypes.shape(controlSpecsShape)]),
27 | )
28 |
29 | const imagesHookShape: Record, any> = {
30 | onImageAddedEvent: PropTypes.func,
31 | onImageRemovedEvent: PropTypes.func,
32 | }
33 |
34 | export const ImageHooksType = PropTypes.shape(imagesHookShape)
35 | export const TextTransformSpecsType = PropTypes.arrayOf(PropTypes.object)
36 |
--------------------------------------------------------------------------------
/src/core/Bridge.ts:
--------------------------------------------------------------------------------
1 | import { Attributes } from '@delta/attributes'
2 | import { Endpoint } from './Endpoint'
3 | import { Images } from './Images'
4 |
5 | /**
6 | * A set of definitions related to the {@link (Bridge:interface)} interface.
7 | *
8 | * @public
9 | */
10 | declare namespace Bridge {
11 | /**
12 | * An event which signals the intent to modify the content touched by current selection.
13 | */
14 | export type ControlEvent = 'APPLY_ATTRIBUTES_TO_SELECTION' | 'INSERT_OR_REPLACE_AT_SELECTION'
15 |
16 | /**
17 | * Block content to insert.
18 | */
19 | export interface ImageElement {
20 | type: 'image'
21 | description: Images.Description
22 | }
23 |
24 | export interface TextElement {
25 | type: 'text'
26 | content: string
27 | }
28 |
29 | /**
30 | * Content to insert.
31 | */
32 | export type Element = ImageElement | TextElement
33 |
34 | /**
35 | * Listener to selected text attributes changes.
36 | */
37 | export type SelectedAttributesChangeListener = (selectedAttributes: Attributes.Map) => void
38 |
39 | /**
40 | * Listener to attribute overrides.
41 | *
42 | */
43 | export type AttributesOverrideListener = (attributeName: string, attributeValue: Attributes.GenericValue) => void
44 |
45 | /**
46 | * Listener to line type overrides.
47 | *
48 | */
49 | export type LineTypeOverrideListener = (lineType: Attributes.LineType) => void
50 |
51 | /**
52 | *
53 | * @internal
54 | */
55 | export type InsertOrReplaceAtSelectionListener = (element: Element) => void
56 |
57 | /**
58 | * An object representing an area of events happening by the mean of external controls.
59 | *
60 | * @remarks
61 | *
62 | * This object exposes methods to trigger such events, and react to internal events.
63 | */
64 | export interface ControlEventDomain {
65 | /**
66 | * Insert an element at cursor or replace if selection exists.
67 | *
68 | * @internal
69 | */
70 | insertOrReplaceAtSelection: (element: Element) => void
71 |
72 | /**
73 | * Switch the given attribute's value depending on the current selection.
74 | *
75 | * @param attributeName - The name of the attribute to edit.
76 | * @param attributeValue - The value of the attribute to edit. Assigning `null` clears any former truthy value.
77 | */
78 | applyTextTransformToSelection: (attributeName: string, attributeValue: Attributes.TextValue) => void
79 | }
80 |
81 | /**
82 | * An object representing an area of events happening inside the {@link (Typer:class)}.
83 | *
84 | * @privateRemarks
85 | *
86 | * This object exposes methods to trigger such events, and react to external events.
87 | *
88 | * @internal
89 | */
90 | export interface SheetEventDomain {
91 | /**
92 | * Listen to text attributes alterations in selection.
93 | */
94 | addApplyTextTransformToSelectionListener: (owner: object, listener: AttributesOverrideListener) => void
95 |
96 | /**
97 | * Listen to insertions of text or blocks at selection.
98 | */
99 | addInsertOrReplaceAtSelectionListener: (
100 | owner: object,
101 | listener: InsertOrReplaceAtSelectionListener,
102 | ) => void
103 |
104 | /**
105 | * Dereference all listeners registered for this owner.
106 | */
107 | release: (owner: object) => void
108 | }
109 | }
110 |
111 | /**
112 | * An abstraction responsible for event dispatching between the {@link (Typer:class)} and external controls.
113 | *
114 | * @remarks It also provide a uniform access to custom rendering logic.
115 | *
116 | * @internalRemarks
117 | *
118 | * We are only exporting the type to force consumers to use the build function.
119 | *
120 | * @public
121 | */
122 | interface Bridge {
123 | /**
124 | * Get {@link (Bridge:namespace).SheetEventDomain | sheetEventDom}.
125 | *
126 | * @internal
127 | */
128 | getSheetEventDomain: () => Bridge.SheetEventDomain
129 | /**
130 | * Get this bridge {@link (Bridge:namespace).ControlEventDomain}.
131 | *
132 | * @remarks
133 | *
134 | * The returned object can be used to react from and trigger {@link (Typer:class)} events.
135 | */
136 | getControlEventDomain: () => Bridge.ControlEventDomain
137 | /**
138 | * End of the bridge's lifecycle.
139 | *
140 | * @remarks
141 | *
142 | * One would typically call this method during `componentWillUnmout` hook.
143 | */
144 | release: () => void
145 | }
146 |
147 | // eslint-disable-next-line @typescript-eslint/class-name-casing
148 | class _Bridge implements Bridge {
149 | private outerEndpoint = new Endpoint()
150 |
151 | private controlEventDom: Bridge.ControlEventDomain = {
152 | insertOrReplaceAtSelection: (element: Bridge.Element) => {
153 | this.outerEndpoint.emit('INSERT_OR_REPLACE_AT_SELECTION', element)
154 | },
155 | applyTextTransformToSelection: (attributeName: string, attributeValue: Attributes.GenericValue) => {
156 | this.outerEndpoint.emit('APPLY_ATTRIBUTES_TO_SELECTION', attributeName, attributeValue)
157 | },
158 | }
159 |
160 | private sheetEventDom: Bridge.SheetEventDomain = {
161 | addApplyTextTransformToSelectionListener: (owner: object, listener: Bridge.AttributesOverrideListener) => {
162 | this.outerEndpoint.addListener(owner, 'APPLY_ATTRIBUTES_TO_SELECTION', listener)
163 | },
164 | addInsertOrReplaceAtSelectionListener: (
165 | owner: object,
166 | listener: Bridge.InsertOrReplaceAtSelectionListener,
167 | ) => {
168 | this.outerEndpoint.addListener(owner, 'INSERT_OR_REPLACE_AT_SELECTION', listener)
169 | },
170 | release: (owner: object) => {
171 | this.outerEndpoint.release(owner)
172 | },
173 | }
174 |
175 | public constructor() {
176 | this.sheetEventDom = Object.freeze(this.sheetEventDom)
177 | this.controlEventDom = Object.freeze(this.controlEventDom)
178 | }
179 |
180 | public getSheetEventDomain(): Bridge.SheetEventDomain {
181 | return this.sheetEventDom
182 | }
183 |
184 | public getControlEventDomain(): Bridge.ControlEventDomain {
185 | return this.controlEventDom
186 | }
187 |
188 | /**
189 | * End of the bridge's lifecycle.
190 | *
191 | * @remarks
192 | *
193 | * One would typically call this method during `componentWillUnmout` hook.
194 | */
195 | public release() {
196 | this.outerEndpoint.removeAllListeners()
197 | }
198 | }
199 |
200 | /**
201 | * Build a bridge instance.
202 | *
203 | * @public
204 | */
205 | function buildBridge(): Bridge {
206 | return new _Bridge()
207 | }
208 |
209 | const BridgeStatic = _Bridge
210 | const Bridge = {}
211 |
212 | export { Bridge, buildBridge, BridgeStatic }
213 |
--------------------------------------------------------------------------------
/src/core/Endpoint.ts:
--------------------------------------------------------------------------------
1 | import { EventEmitter, ListenerFn } from 'eventemitter3'
2 |
3 | interface ListenerDescriptor {
4 | listener: L
5 | eventType: E
6 | }
7 |
8 | export class Endpoint {
9 | private owners = new WeakMap