├── .circleci └── config.yml ├── .editorconfig ├── .eslintrc.js ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── question.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .prettierrc.js ├── .releaserc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── api-extractor.json ├── babel.config.js ├── build.babel.config.js ├── commitlint.config.js ├── etc └── typer.api.md ├── greenkeeper.json ├── images ├── qr-debugger.png ├── qr-showcase.png └── screenshot.png ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── components │ ├── BlockController.ts │ ├── DocumentRenderer.tsx │ ├── GenericBlockInput │ │ ├── ImageBlockInput.tsx │ │ ├── TextBlockInput │ │ │ ├── TextChangeSession.ts │ │ │ ├── TextChangeSessionBehavior.ts │ │ │ └── index.tsx │ │ ├── index.tsx │ │ └── types.ts │ ├── GenericBlockView │ │ ├── ImageBlockView.tsx │ │ ├── TextBlockView.tsx │ │ ├── index.tsx │ │ └── types.d.ts │ ├── Print.ts │ ├── RichText.tsx │ ├── Toolbar.tsx │ ├── Typer.tsx │ ├── __tests__ │ │ ├── Print-test.tsx │ │ ├── RichText-test.tsx │ │ ├── Toolbar-test.tsx │ │ └── Typer-test.tsx │ ├── defaults.ts │ ├── styles.ts │ └── types.ts ├── core │ ├── Bridge.ts │ ├── Endpoint.ts │ ├── Images.tsx │ ├── Transforms.ts │ └── __tests__ │ │ └── Transforms-test.ts ├── delta │ ├── DeltaBuffer.ts │ ├── DeltaChangeContext.ts │ ├── DeltaDiffComputer.ts │ ├── DocumentDelta.ts │ ├── DocumentDeltaAtomicUpdate.ts │ ├── LineWalker.ts │ ├── Selection.ts │ ├── Text.ts │ ├── __tests__ │ │ ├── DocumentDelta-test.ts │ │ ├── LineWalker-test.ts │ │ ├── Selection-test.ts │ │ ├── Text-test.ts │ │ └── diff-test.ts │ ├── attributes.ts │ ├── diff.ts │ ├── generic.ts │ ├── lines.ts │ └── operations.ts ├── hooks │ ├── use-bridge.ts │ └── use-document.ts ├── index.ts ├── model │ ├── Block.ts │ ├── BlockAssembler.ts │ ├── __tests__ │ │ ├── Block-test.ts │ │ ├── BlockAssembler-test.ts │ │ ├── blocks-test.ts │ │ └── document-test.ts │ ├── blocks.ts │ └── document.ts └── test │ ├── delta.ts │ ├── document.ts │ └── vdom.ts ├── tsconfig-build.json ├── tsconfig.json └── types ├── tsdoc-metadata.json └── typer.d.ts /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 # use CircleCI 2.0 2 | jobs: # a collection of steps 3 | build: # runs not using Workflows must have a `build` job as entry point 4 | docker: # run the steps with Docker 5 | - image: circleci/node:lts 6 | steps: # a collection of executable commands 7 | - checkout # special step to check out source code to working directory 8 | - run: 9 | name: update-npm 10 | command: 'sudo npm install -g npm@latest' 11 | - restore_cache: # special step to restore the dependency cache 12 | # Read about caching dependencies: https://circleci.com/docs/2.0/caching/ 13 | key: dependency-cache-{{ checksum "package.json" }} 14 | - run: 15 | name: install-npm-wee 16 | command: npm install 17 | - save_cache: # special step to save the dependency cache 18 | key: dependency-cache-{{ checksum "package.json" }} 19 | paths: 20 | - ./node_modules 21 | - run: 22 | name: 'validate:typescript' 23 | command: 'npm run validate:typescript' 24 | - run: 25 | name: 'validate:lint' 26 | command: 'npm run validate:lint' 27 | - run: 28 | name: 'test:jest' 29 | command: 'npm run test:jest' 30 | - run: # run coverage report 31 | name: code-coverage 32 | command: 'npx codecov --token="$CODECOV_TOKEN"' 33 | - run: 34 | name: 'validate:api' 35 | command: 'npm run validate:api' 36 | 37 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | # EditorConfig is awesome: https://EditorConfig.org 3 | 4 | # top-most EditorConfig file 5 | root = true 6 | 7 | # Unix-style newlines with a newline ending every file 8 | [*] 9 | end_of_line = lf 10 | insert_final_newline = true 11 | 12 | # Matches multiple files with brace expansion notation 13 | # Set default charset 14 | [*.{js,ts,tsx,json,jsx}] 15 | charset = utf-8 16 | indent_size = 2 17 | indent_style = space 18 | 19 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | "react-hooks" 4 | ], 5 | extends: [ 6 | "@typeskill/eslint-config", // Uses the recommended rules from typeskill 7 | ], 8 | parserOptions: { 9 | project: './tsconfig.json' // change tsconfig to whichever appropriate config file 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.json linguist-language=JSON-with-Comments 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 Report a bug 3 | about: Report a reproducible or regression bug. 4 | labels: 'bug' 5 | --- 6 | 7 | ## Environment 8 | 9 | - @typeskill/typer: 10 | 11 | 12 | 13 | ``` 14 | ``` 15 | 16 | ## Tested Devices 17 | 18 | 22 | 23 | - iPhone X, ios 12.3: **** 24 | - One Plus 5, Cyanogen 9.3: **** 25 | - Android emulator v29.0.11.0, Android 8.0: **** 26 | - XCode simulator 11.0, ios 12.1: **** 27 | 28 | ## Description 29 | 30 | 31 | 32 | 33 | ## Reproduction 34 | 35 | 36 | 37 | 50 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: ✨ Feature request 3 | about: Suggest an idea. 4 | labels: 'enhancement' 5 | --- 6 | 7 | ## Describe the Feature 8 | 9 | 10 | ## Possible Implementations 11 | 12 | 13 | ## Related Issues 14 | 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 💬 Question 3 | about: You need help with the library. 4 | labels: 'question' 5 | --- 6 | 7 | ## Ask your Question 8 | 9 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | 4 | 5 | 6 | # Test Plan 7 | 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | .jest 4 | .watchmanconfig 5 | temp 6 | node_modules 7 | lib 8 | coverage 9 | 10 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | trailingComma: "all", 4 | singleQuote: true, 5 | printWidth: 120, 6 | tabWidth: 2 7 | } 8 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "@semantic-release/commit-analyzer", 4 | "@semantic-release/release-notes-generator", 5 | "@semantic-release/changelog", 6 | "@semantic-release/npm", 7 | "@semantic-release/github", 8 | [ 9 | "@semantic-release/git", 10 | { 11 | "assets": ["CHANGELOG.md", "package.json"], 12 | "message": "chore(release): ${nextRelease.version} [skip ci] \n\n${nextRelease.notes}" 13 | } 14 | ] 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ## [1.0.0](https://github.com/typeskill/typeskill/compare/v0.11.0-alpha.0...v1.0.0) (2020-02-19) 6 | 7 | ### Bug Fixes 8 | 9 | * **package:** update ramda to version 0.27.0 ([b80ef9a](https://github.com/typeskill/typeskill/commit/b80ef9a)) 10 | * unexhaustive hook dependency ([c1ae14d](https://github.com/typeskill/typeskill/commit/c1ae14d)) 11 | 12 | 13 | ### Features 14 | 15 | * add useDocument and useBridge hooks ([0e96b53](https://github.com/typeskill/typeskill/commit/0e96b53)) 16 | 17 | ## [0.11.0-alpha.0](https://github.com/typeskill/typeskill/compare/v0.10.0-beta.19...v0.11.0-alpha.0) (2019-10-04) 18 | 19 | 20 | ### ⚠ BREAKING CHANGES 21 | 22 | * removed `disableMultipleAttributeEdits` Typer prop. 23 | Since this behavior has been proven default on iOS, Typeskill will try 24 | to enforce the same one on Android by default. To make the API more 25 | explicit, it has been found best to rename the prop to narrow the scope 26 | to Android. 27 | 28 | ### Bug Fixes 29 | 30 | * add missing hook dependency ([fa84b1c](https://github.com/typeskill/typeskill/commit/fa84b1c)) 31 | * overriding selection iOS doesn't work ([8f38a0b](https://github.com/typeskill/typeskill/commit/8f38a0b)) 32 | 33 | 34 | ### Features 35 | 36 | * add `androidDisableMultipleAttributeEdits` Typer prop ([348eecb](https://github.com/typeskill/typeskill/commit/348eecb)) 37 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributing to React Native Typeskill 3 | 4 | ## Development Process 5 | 6 | All work on React Native Typeskill happens directly on GitHub. Contributors send pull requests which go through a review process. 7 | 8 | > **Working on your first pull request?** You can learn how from this *free* series: [How to Contribute to an Open Source Project on GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github). 9 | 10 | 1. Fork the repo and create your branch from `master` (a guide on [how to fork a repository](https://help.github.com/articles/fork-a-repo/)). 11 | 2. Run `yarn` or `npm install` to install all required dependencies. 12 | 3. Now you are ready to make your changes! 13 | 14 | ## Tests & Verifications 15 | 16 | > TO BE COMPLETED 17 | 18 | ## Sending a pull request 19 | 20 | When you're sending a pull request: 21 | 22 | * Prefer small pull requests focused on one change. 23 | * Verify that all tests and validations are passing. 24 | * Follow the pull request template when opening a pull request. 25 | 26 | ## Commit message convention 27 | 28 | This project complies with [Conventional Commits](https://www.conventionalcommits.org/en). We prefix our commit messages with one of the following to signify the kind of change: 29 | 30 | * **build**: Changes that affect the build system or external dependencies. 31 | * **ci**, **chore**: Changes to our CI configuration files and scripts. 32 | * **docs**: Documentation only changes. 33 | * **feat**: A new feature. 34 | * **fix**: A bug fix. 35 | * **perf**: A code change that improves performance. 36 | * **refactor**: A code change that neither fixes a bug nor adds a feature. 37 | * **style**: Changes that do not affect the meaning of the code. 38 | * **test**: Adding missing tests or correcting existing tests. 39 | 40 | ## Releasing 41 | 42 | Full versions: 43 | 44 | ```bash 45 | npm run release 46 | ``` 47 | 48 | Prereleases: 49 | 50 | ```bash 51 | npm run release -- --skip.changelog=true --prerelease alpha 52 | ``` 53 | 54 | ## Reporting issues 55 | 56 | You can report issues on our [bug tracker](https://github.com/typeskill/typer/issues). Please search for existing issues and follow the issue template when opening an issue. 57 | 58 | ## License 59 | 60 | By contributing to React Native Typeskill, you agree that your contributions will be licensed under the **MIT** license. 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-present, Jules Samuel Randolph. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | @typeskill/typer 4 | 5 |

6 |

7 | 8 | Typeskill, the Operational-Transform Based (React) Native Rich Text Library. 9 | 10 |

11 |

12 | 13 | 14 | 15 | 16 | 17 | scheduled features 18 | 19 |

20 |

21 | 22 | Circle CI 23 | 24 | 25 | Code coverage 26 | 27 | 28 | open bugs 29 | 30 | Greenkeeper badge 31 | 32 | vulnerabilities 33 | 34 |

35 |

36 | 37 | npm install --save @typeskill/typer 38 | 39 |

40 |

41 | Typeskill screenshot 42 | 43 |

44 |

45 | 46 | Give it a try on Expo 47 | 48 |

49 | 50 | Expo QR code 51 | 52 |
53 | You can also run it locally in seconds 54 |

55 | 56 | ## Features & design principles 57 | 58 | ### Design 59 | 60 | - Extensively **modular** architecture: Typeskill handles the logic, you chose the layout; 61 | - No bloated/clumsy `WebView` ; this library only relies on (React) **Native** components; 62 | - Fully [controlled components](https://reactjs.org/docs/forms.html#controlled-components); 63 | - Based on the reliable [Delta](https://github.com/quilljs/delta) **operational transform** library from [quilljs](https://github.com/quilljs). 64 | 65 | ### Features 66 | 67 | - Support for **arbitrary embedded contents**; 68 | - Support for **arbitrary controllers** with the `Bridge` class; 69 | - JSON-**serializable** rich content. 70 | 71 | 72 | 73 | ## Trying locally 74 | 75 | *Prerequisite: you must have `npm` and `expo-cli` globally installed* 76 | 77 | ``` bash 78 | git clone https://github.com/typeskill/examples/tree/master 79 | cd examples/expo-showcase 80 | npm install 81 | expo start 82 | ``` 83 | 84 | ## Architecture & example 85 | 86 | ### Introduction 87 | 88 | The library exposes two components to render documents: 89 | 90 | - The `Typer` component is responsible for **editing** a document; 91 | - The `Print` component is responsible for **displaying** a document. 92 | 93 | ### Definitions 94 | 95 | - A *document* is a JSON-serializable object describing rich content; 96 | - A *document renderer* is any controlled component which renders a document—i.e. `Typer` or `Print`; 97 | - The *master component* is referred to as the component containing and controlling the document renderer; 98 | - A *document control* is any controlled component owned by the master component capable of altering the document—i.e. `Typer` or `Toolbar`; 99 | - An *external [document] control* is any document control which is not a document renderer—i.e. `Toolbar` or any custom control. 100 | 101 | ### The shape of a Document 102 | 103 | A document is an object describing rich content and the current selection. Its `op` field is an array of operational transforms implemented with [delta library](https://github.com/quilljs/delta). Its `schemaVersion` guarantees retro-compatibility in the future and, if needed, utilities to convert from one version to the other. 104 | 105 | To explore the structure in seconds, the easiest way is with the debugger: [`@typeskill/debugger`](https://github.com/typeskill/debugger). 106 | 107 | ### Controlled components 108 | 109 | Document renderers and controls are **[controlled components](https://reactjs.org/docs/forms.html#controlled-components)**, which means you need to define how to store the state from a master component, or through a store architecture such as a Redux. [You can study `Editor.tsx`, a minimal example master component.](https://github.com/typeskill/examples/blob/master/expo-minimal/src/Editor.tsx) 110 | 111 | ### A domain of shared events 112 | 113 | Document renderers need an invariant `Bridge` instance prop. 114 | The bridge has two responsibilities: 115 | 116 | - To convey actions such as *insert an image at selection* or *change text attributes in selection* from external controls; 117 | - To notify selection attributes changes to external controls. 118 | 119 | A `Bridge` instance must be hold by the master component, and can be shared with any external control such as `Toolbar` to operate on the document. 120 | 121 | **Remarks** 122 | 123 | - The `Bridge` constructor **is not exposed**. You must consume the `buildBridge` function or `useBridge` hook instead; 124 | - To grasp how the bridge is interfaced with the `Toolbar` component, you can [read its implementation](src/components/Toolbar.tsx). 125 | 126 | ### Robustness 127 | 128 | This decoupled design has the following advantages: 129 | 130 | - the logic can be tested independently from React components; 131 | - the library consumer can integrate the library to fit its graphical and architectural design; 132 | - support for arbitrary content in the future. 133 | 134 | ### Minimal example 135 | 136 | Bellow is a simplified snippet [from the minimal expo example](https://github.com/typeskill/examples/tree/master/expo-minimal) to show you how the `Toolbar` can be interfaced with the `Typer` component. 137 | You need a linked `react-native-vector-icons` or `@expo/vector-icons` if you are on expo to make this example work. 138 | 139 | ```jsx 140 | import React from 'react'; 141 | import { View } from 'react-native'; 142 | import { 143 | Typer, 144 | Toolbar, 145 | DocumentControlAction, 146 | buildVectorIconControlSpec, 147 | useBridge, 148 | useDocument, 149 | } from '@typeskill/typer'; 150 | /** NON EXPO **/ 151 | import { MaterialCommunityIcons } from 'react-native-vector-icons/MaterialCommunityIcons'; 152 | /** EXPO **/ 153 | // import { MaterialCommunityIcons } from '@expo/vector-icons' 154 | 155 | function buildMaterialControlSpec(actionType, name) { 156 | return buildVectorIconControlSpec(MaterialCommunityIcons, actionType, name); 157 | } 158 | 159 | const toolbarLayout = [ 160 | buildMaterialControlSpec( 161 | DocumentControlAction.SELECT_TEXT_BOLD, 162 | 'format-bold', 163 | ), 164 | buildMaterialControlSpec( 165 | DocumentControlAction.SELECT_TEXT_ITALIC, 166 | 'format-italic', 167 | ), 168 | buildMaterialControlSpec( 169 | DocumentControlAction.SELECT_TEXT_UNDERLINE, 170 | 'format-underline', 171 | ), 172 | buildMaterialControlSpec( 173 | DocumentControlAction.SELECT_TEXT_STRIKETHROUGH, 174 | 'format-strikethrough-variant', 175 | ), 176 | ]; 177 | 178 | export function Editor() { 179 | const [document, setDocument] = useDocument(); 180 | const bridge = useBridge(); 181 | return ( 182 | 183 | 189 | 190 | 191 | ); 192 | } 193 | ``` 194 | 195 | ### API Contract 196 | 197 | You need to comply with this contract to avoid bugs: 198 | 199 | - The `Bridge` instance should be instantiated by the master component with `buildBridge`, during mount or with `useBridge` hook; 200 | - There should be exactly one `Bridge` instance for one document renderer. 201 | 202 | ## API Reference 203 | 204 | [**Typescript definitions**](types/typer.d.ts) provide an exhaustive and curated documentation reference. The comments are [100% compliant with tsdoc](https://github.com/microsoft/tsdoc) and generated with Microsoft famous [API Extractor](https://api-extractor.com/) utility. [**These definitions follow semantic versioning.**](https://semver.org/) 205 | 206 | Please note that `props` definitions are namespaced. For example, if you are looking at `Toolbar` component definitions, you should look for `Props` definition inside `Toolbar` namespace. 207 | 208 | ## Inspecting and reporting bugs 209 | 210 | [`@typeskill/debugger`](https://github.com/typeskill/debugger) is a tool to inspect and reproduce bugs. If you witness a bug, please try a reproduction on the debugger prior to reporting it. 211 | 212 | ## Customizing 213 | 214 | ### Integrating your image picker 215 | 216 | Typeskill won't chose a picker on your behalf, as it would break its commitment to modular design. 217 | You can check [`Editor.tsx` component](https://github.com/typeskill/examples/blob/master/expo-showcase/src/Editor.tsx) from [the showcase expo example](https://github.com/typeskill/examples/tree/master/expo-showcase) to see how to integrate your image picker. 218 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['module:metro-react-native-babel-preset'], 3 | plugins: [ 4 | ['@babel/plugin-proposal-decorators', { legacy: true }], 5 | '@babel/plugin-proposal-class-properties', 6 | '@babel/plugin-proposal-object-rest-spread', 7 | ], 8 | } 9 | -------------------------------------------------------------------------------- /build.babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['module:metro-react-native-babel-preset'], 3 | plugins: [ 4 | [ 5 | 'module-resolver', 6 | { 7 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 8 | root: ['./src'], 9 | alias: { 10 | '@delta': './src/delta', 11 | '@test': './src/test', 12 | '@core': './src/core', 13 | '@model': './src/model', 14 | '@components': './src/components', 15 | '@hooks': './src/hooks' 16 | }, 17 | }, 18 | ], 19 | ['@babel/plugin-proposal-decorators', { legacy: true }], 20 | '@babel/plugin-proposal-class-properties', 21 | '@babel/plugin-proposal-object-rest-spread', 22 | ], 23 | ignore: ['**/__tests__/**'], 24 | } 25 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {extends: ['@commitlint/config-conventional']} 2 | -------------------------------------------------------------------------------- /etc/typer.api.md: -------------------------------------------------------------------------------- 1 | ## API Report File for "@typeskill/typer" 2 | 3 | > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). 4 | 5 | ```ts 6 | 7 | import { Component } from 'react'; 8 | import { ComponentType } from 'react'; 9 | import { FunctionComponent } from 'react'; 10 | import { ScrollViewProps } from 'react-native'; 11 | import { StyleProp } from 'react-native'; 12 | import { TextStyle } from 'react-native'; 13 | import { ViewStyle } from 'react-native'; 14 | 15 | // @public 16 | export namespace Attributes { 17 | export type GenericValue = object | TextValue | undefined; 18 | export type LineType = 'normal' | 'quoted'; 19 | export interface Map { 20 | // (undocumented) 21 | readonly [k: string]: GenericValue; 22 | } 23 | export type TextValue = boolean | string | number | null; 24 | } 25 | 26 | // @public 27 | export interface BlockOp extends GenericOp { 28 | readonly attributes?: Attributes.Map; 29 | readonly insert: T; 30 | } 31 | 32 | // @public 33 | export namespace Bridge { 34 | export type AttributesOverrideListener = (attributeName: string, attributeValue: Attributes.GenericValue) => void; 35 | export type ControlEvent = 'APPLY_ATTRIBUTES_TO_SELECTION' | 'INSERT_OR_REPLACE_AT_SELECTION'; 36 | export interface ControlEventDomain { 37 | applyTextTransformToSelection: (attributeName: string, attributeValue: Attributes.TextValue) => void; 38 | // @internal 39 | insertOrReplaceAtSelection: (element: Element) => void; 40 | } 41 | export type Element = ImageElement | TextElement; 42 | export interface ImageElement { 43 | // (undocumented) 44 | description: Images.Description; 45 | // (undocumented) 46 | type: 'image'; 47 | } 48 | // @internal (undocumented) 49 | export type InsertOrReplaceAtSelectionListener = (element: Element) => void; 50 | export type LineTypeOverrideListener = (lineType: Attributes.LineType) => void; 51 | export type SelectedAttributesChangeListener = (selectedAttributes: Attributes.Map) => void; 52 | // @internal 53 | export interface SheetEventDomain { 54 | addApplyTextTransformToSelectionListener: (owner: object, listener: AttributesOverrideListener) => void; 55 | addInsertOrReplaceAtSelectionListener: (owner: object, listener: InsertOrReplaceAtSelectionListener) => void; 56 | release: (owner: object) => void; 57 | } 58 | // (undocumented) 59 | export interface TextElement { 60 | // (undocumented) 61 | content: string; 62 | // (undocumented) 63 | type: 'text'; 64 | } 65 | } 66 | 67 | // @public 68 | export interface Bridge { 69 | getControlEventDomain: () => Bridge.ControlEventDomain; 70 | // @internal 71 | getSheetEventDomain: () => Bridge.SheetEventDomain; 72 | release: () => void; 73 | } 74 | 75 | // @public (undocumented) 76 | export const Bridge: {}; 77 | 78 | // @public 79 | export function buildBridge(): Bridge; 80 | 81 | // @public 82 | export function buildEmptyDocument(): Document; 83 | 84 | // @public 85 | export function buildVectorIconControlSpec(IconComponent: ComponentType, actionType: A, name: string, options?: Pick, 'actionOptions' | 'iconProps'>): Toolbar.GenericControlSpec; 86 | 87 | // @public 88 | export function cloneDocument(content: Document): Document; 89 | 90 | // @public 91 | export const CONTROL_SEPARATOR: unique symbol; 92 | 93 | // @public (undocumented) 94 | export const defaultTextTransforms: Transforms.GenericSpec[]; 95 | 96 | // @public 97 | export interface Document { 98 | readonly currentSelection: SelectionShape; 99 | readonly lastDiff: GenericOp[]; 100 | readonly ops: GenericOp[]; 101 | readonly schemaVersion: number; 102 | readonly selectedTextAttributes: Attributes.Map; 103 | } 104 | 105 | // @public 106 | export enum DocumentControlAction { 107 | INSERT_IMAGE_AT_SELECTION = 4, 108 | SELECT_TEXT_BOLD = 0, 109 | SELECT_TEXT_ITALIC = 1, 110 | SELECT_TEXT_STRIKETHROUGH = 3, 111 | SELECT_TEXT_UNDERLINE = 2 112 | } 113 | 114 | // @public 115 | export interface DocumentRendererProps { 116 | contentContainerStyle?: StyleProp; 117 | document: Document; 118 | documentStyle?: StyleProp; 119 | ImageComponent?: Images.Component; 120 | maxMediaBlockHeight?: number; 121 | maxMediaBlockWidth?: number; 122 | ScrollView?: ComponentType; 123 | scrollViewProps?: ScrollViewProps; 124 | spacing?: number; 125 | style?: StyleProp; 126 | textStyle?: StyleProp; 127 | textTransformSpecs?: Transforms.Specs<'text'>; 128 | } 129 | 130 | // @public (undocumented) 131 | export interface FocusableInput { 132 | focus: () => void; 133 | } 134 | 135 | // @public 136 | export type GenericControlAction = string | symbol | number; 137 | 138 | // @public 139 | export interface GenericOp { 140 | readonly attributes?: Attributes.Map; 141 | // @internal 142 | readonly delete?: number; 143 | readonly insert?: string | object; 144 | // @internal 145 | readonly retain?: number; 146 | } 147 | 148 | // @public 149 | export interface GenericRichContent { 150 | // (undocumented) 151 | readonly length: () => number; 152 | readonly ops: GenericOp[]; 153 | } 154 | 155 | // @public 156 | export interface ImageKind extends Images.Description { 157 | // (undocumented) 158 | kind: 'image'; 159 | } 160 | 161 | // @public 162 | export type ImageOp = BlockOp>; 163 | 164 | // @public 165 | export namespace Images { 166 | // (undocumented) 167 | export type Component = ComponentType>; 168 | // (undocumented) 169 | export interface ComponentProps { 170 | readonly description: Description; 171 | readonly printDimensions: Dimensions; 172 | } 173 | // (undocumented) 174 | export interface Description { 175 | // (undocumented) 176 | readonly height: number; 177 | // (undocumented) 178 | readonly source: Source; 179 | // (undocumented) 180 | readonly width: number; 181 | } 182 | // (undocumented) 183 | export interface Dimensions { 184 | // (undocumented) 185 | readonly height: number; 186 | // (undocumented) 187 | readonly width: number; 188 | } 189 | export interface Hooks { 190 | readonly onImageAddedEvent?: (description: Description) => void; 191 | readonly onImageRemovedEvent?: (description: Description) => void; 192 | } 193 | // (undocumented) 194 | export interface StandardSource { 195 | // (undocumented) 196 | uri: string; 197 | } 198 | } 199 | 200 | // @public 201 | export namespace Print { 202 | export type Props = DocumentRendererProps; 203 | } 204 | 205 | // @public 206 | export class Print extends Component> { 207 | } 208 | 209 | // @public 210 | export interface SelectionShape { 211 | readonly end: number; 212 | readonly start: number; 213 | } 214 | 215 | // @public 216 | export interface TextOp extends GenericOp { 217 | readonly attributes?: Attributes.Map; 218 | readonly insert?: string; 219 | } 220 | 221 | // @public 222 | export namespace Toolbar { 223 | export type DocumentControlSpec = GenericControlSpec; 224 | // (undocumented) 225 | export interface GenericControlSpec { 226 | actionOptions?: any; 227 | actionType: A; 228 | IconComponent: ComponentType; 229 | iconProps?: T extends Toolbar.VectorIconMinimalProps ? Toolbar.VectorIconMinimalProps : Partial; 230 | } 231 | export interface IconButtonProps extends IconButtonSpecs { 232 | // (undocumented) 233 | IconComponent: ComponentType; 234 | // (undocumented) 235 | iconProps?: object; 236 | // (undocumented) 237 | onPress?: () => void; 238 | // (undocumented) 239 | selected: boolean; 240 | // (undocumented) 241 | style?: StyleProp; 242 | } 243 | // (undocumented) 244 | export interface IconButtonSpecs { 245 | activeButtonBackgroundColor: string; 246 | activeButtonColor: string; 247 | iconSize: number; 248 | inactiveButtonBackgroundColor: string; 249 | inactiveButtonColor: string; 250 | } 251 | export type Layout = (DocumentControlSpec | typeof CONTROL_SEPARATOR | GenericControlSpec)[]; 252 | export interface Props extends Partial { 253 | bridge: Bridge; 254 | buttonSpacing?: number; 255 | contentContainerStyle?: StyleProp; 256 | document: Document; 257 | layout: Layout; 258 | onInsertImageError?: (e: Error) => void; 259 | onPressCustomControl?: (actionType: A, actionOptions?: any) => void; 260 | pickOneImage?: (options?: ImageOptions) => Promise>; 261 | separatorColor?: string; 262 | style?: StyleProp; 263 | } 264 | export interface TextControlMinimalIconProps { 265 | color?: string; 266 | size?: number; 267 | } 268 | export interface VectorIconMinimalProps { 269 | name: string; 270 | } 271 | } 272 | 273 | // @public 274 | export class Toolbar extends Component> { 275 | IconButton: FunctionComponent; 276 | } 277 | 278 | // @public 279 | export namespace Transforms { 280 | export type BoolSpec = GenericSpec; 281 | // @internal 282 | export interface Dict { 283 | // (undocumented) 284 | [attributeName: string]: GenericSpec[]; 285 | } 286 | export interface GenericSpec { 287 | activeAttributeValue: A; 288 | activeStyle: T extends 'block' ? ViewStyle : TextStyle; 289 | attributeName: string; 290 | } 291 | // (undocumented) 292 | export type Specs = GenericSpec[]; 293 | export type TargetType = 'block' | 'text'; 294 | export type TextAttributeName = 'bold' | 'italic' | 'textDecoration'; 295 | } 296 | 297 | // @public 298 | export class Transforms { 299 | constructor(textTransformSpecs: Transforms.GenericSpec[]); 300 | // @internal 301 | getStylesFromOp(op: TextOp): StyleProp; 302 | } 303 | 304 | // @public 305 | export namespace Typer { 306 | export interface Props extends DocumentRendererProps { 307 | androidDisableMultipleAttributeEdits?: boolean; 308 | bridge: Bridge; 309 | debug?: boolean; 310 | disableSelectionOverrides?: boolean; 311 | imageHooks?: Images.Hooks; 312 | onDocumentUpdate?: (nextDocumentContent: Document) => void; 313 | readonly?: boolean; 314 | underlayColor?: string; 315 | } 316 | } 317 | 318 | // @public 319 | export class Typer extends Component> implements FocusableInput { 320 | // (undocumented) 321 | focus: () => void; 322 | } 323 | 324 | // @public 325 | export function useBridge(deps?: unknown[]): Bridge; 326 | 327 | // @public 328 | export function useDocument(initialDocument?: Document): [Document, import("react").Dispatch>]; 329 | 330 | 331 | ``` 332 | -------------------------------------------------------------------------------- /greenkeeper.json: -------------------------------------------------------------------------------- 1 | { 2 | "groups": { 3 | "default": { 4 | "packages": [ 5 | "package.json" 6 | ], 7 | "ignore": [ 8 | "react" 9 | ] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /images/qr-debugger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeskill/typer/55a6d51211d1903eca27857dbd723db686fccd74/images/qr-debugger.png -------------------------------------------------------------------------------- /images/qr-showcase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeskill/typer/55a6d51211d1903eca27857dbd723db686fccd74/images/qr-showcase.png -------------------------------------------------------------------------------- /images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeskill/typer/55a6d51211d1903eca27857dbd723db686fccd74/images/screenshot.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'react-native', 3 | roots: [''], 4 | moduleNameMapper: { 5 | '^@components/(.*)$': '/src/components/$1', 6 | '^@core/(.*)$': '/src/core/$1', 7 | '^@delta/(.*)$': '/src/delta/$1', 8 | '^@model/(.*)$': '/src/model/$1', 9 | '^@test/(.*)$': '/src/test/$1', 10 | }, 11 | modulePathIgnorePatterns: ['npm-cache', '.npm', 'examples', 'lib'], 12 | transformIgnorePatterns: ['node_modules/?!(ramda)'], 13 | // This is the only part which you can keep 14 | // from the above linked tutorial's config: 15 | cacheDirectory: '.jest/cache', 16 | collectCoverage: true, 17 | coverageReporters: ['json', 'lcov', 'clover'], 18 | } 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@typeskill/typer", 3 | "version": "1.0.0", 4 | "repository": "git@github.com:typeskill/typeskill.git", 5 | "description": "Operational-Transform Based (React) Native Rich Text Editor", 6 | "author": "Jules Randolph ", 7 | "private": false, 8 | "license": "MIT", 9 | "scripts": { 10 | "autolint": "eslint --ext .ts --ext .tsx 'src/' --fix", 11 | "build": "npm run build:clean; npm run build:types && npm run build:js && npm run build:api", 12 | "build:clean": "rimraf lib", 13 | "build:types": "ttsc --project tsconfig-build.json", 14 | "build:js": "babel src --config-file ./build.babel.config.js --out-dir lib --extensions \".ts,.tsx\" --source-maps inline", 15 | "build:docs": "api-documenter markdown --input-folder temp --output-folder docs", 16 | "build:api": "api-extractor run --local --verbose", 17 | "prepare": "npm run build", 18 | "test": "npm run validate:typescript && npm run validate:lint && npm run test:jest && npm run validate:api", 19 | "test:jest": "jest", 20 | "validate:typescript": "tsc --project ./ --noEmit", 21 | "validate:lint": "eslint --ext .ts --ext .tsx 'src/'", 22 | "validate:api": "npm run build:types && npm run build:api", 23 | "release": "standard-version", 24 | "semantic-release": "semantic-release" 25 | }, 26 | "files": [ 27 | "lib/**/*.js", 28 | "types/**/*.ts", 29 | "!lib/test" 30 | ], 31 | "main": "lib/index.js", 32 | "types": "types/typer.d.ts", 33 | "keywords": [ 34 | "react native", 35 | "rich text", 36 | "editor", 37 | "redactor", 38 | "text", 39 | "typeskill", 40 | "quilljs", 41 | "delta", 42 | "expo" 43 | ], 44 | "dependencies": { 45 | "@types/diff": "^4.0.2", 46 | "@types/invariant": "^2.2.31", 47 | "autobind-decorator": "~2.4.0", 48 | "diff": "^4.0.2", 49 | "eventemitter3": "^4.0.0", 50 | "invariant": "~2.2.4", 51 | "prop-types": "~15.7.2", 52 | "quill-delta": "~4.2.1", 53 | "ramda": "~0.27.0" 54 | }, 55 | "devDependencies": { 56 | "@babel/cli": "^7.8.4", 57 | "@babel/core": "^7.8.4", 58 | "@babel/plugin-proposal-class-properties": "^7.8.3", 59 | "@babel/plugin-proposal-decorators": "^7.8.3", 60 | "@babel/plugin-proposal-object-rest-spread": "^7.8.3", 61 | "@babel/plugin-syntax-dynamic-import": "^7.8.3", 62 | "@babel/plugin-transform-async-to-generator": "^7.8.3", 63 | "@babel/plugin-transform-destructuring": "^7.8.3", 64 | "@babel/plugin-transform-flow-strip-types": "^7.8.3", 65 | "@babel/plugin-transform-modules-commonjs": "^7.8.3", 66 | "@babel/plugin-transform-parameters": "^7.8.4", 67 | "@babel/plugin-transform-react-jsx": "^7.8.3", 68 | "@babel/plugin-transform-spread": "^7.8.3", 69 | "@babel/preset-env": "^7.8.4", 70 | "@babel/preset-typescript": "^7.8.3", 71 | "@babel/runtime": "^7.8.4", 72 | "@commitlint/cli": "^8.3.5", 73 | "@commitlint/config-conventional": "^8.3.4", 74 | "@microsoft/api-documenter": "^7.7.12", 75 | "@microsoft/api-extractor": "~7.4.7", 76 | "@react-native-community/eslint-config": "^0.0.7", 77 | "@semantic-release/changelog": "^5.0.0", 78 | "@semantic-release/commit-analyzer": "^8.0.1", 79 | "@semantic-release/github": "^7.0.3", 80 | "@semantic-release/npm": "^7.0.3", 81 | "@semantic-release/release-notes-generator": "^9.0.0", 82 | "@types/jest": "^25.1.2", 83 | "@types/ramda": "^0.26.41", 84 | "@types/react": "^16.9.20", 85 | "@types/react-native": "^0.61.16", 86 | "@types/react-test-renderer": "^16.9.2", 87 | "@typescript-eslint/eslint-plugin": "^2.20.0", 88 | "@typescript-eslint/parser": "^2.20.0", 89 | "@typeskill/eslint-config": "^3.0.2", 90 | "@zerollup/ts-transform-paths": "^1.7.11", 91 | "babel-jest": "^25.1.0", 92 | "babel-plugin-module-resolver": "^4.0.0", 93 | "babel-preset-jest": "^25.1.0", 94 | "codecov": "^3.6.5", 95 | "eslint": "^6.8.0", 96 | "eslint-config-prettier": "^6.10.0", 97 | "eslint-plugin-prettier": "^3.1.2", 98 | "eslint-plugin-react": "^7.18.3", 99 | "eslint-plugin-react-hooks": "^2.4.0", 100 | "husky": "^4.2.3", 101 | "jest": "^25.1.0", 102 | "metro-react-native-babel-preset": "^0.58.0", 103 | "prettier": "^1.19.1", 104 | "react": "~16.9.0", 105 | "react-native": "^0.61.5", 106 | "react-test-renderer": "~16.9.0", 107 | "rimraf": "^3.0.2", 108 | "semantic-release": "^17.0.4", 109 | "standard-version": "^7.1.0", 110 | "ttypescript": "^1.5.10", 111 | "typescript": "3.6.5" 112 | }, 113 | "peerDependencies": { 114 | "react": ">=16.8.0", 115 | "react-native": "*" 116 | }, 117 | "jest": { 118 | "preset": "react-native" 119 | }, 120 | "husky": { 121 | "hooks": { 122 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/components/BlockController.ts: -------------------------------------------------------------------------------- 1 | import { Block } from '@model/Block' 2 | import { Document } from '@model/document' 3 | import { SelectionShape } from '@delta/Selection' 4 | import { DocumentDeltaAtomicUpdate } from '@delta/DocumentDeltaAtomicUpdate' 5 | import { ImageOp } from '@delta/operations' 6 | import { Images } from '@core/Images' 7 | 8 | export interface DocumentProvider { 9 | getDocument: () => Document 10 | updateDocument: (document: Document) => void 11 | getImageHooks: () => Images.Hooks 12 | overrideSelection: (overridingSelection: SelectionShape) => void 13 | } 14 | 15 | export class BlockController { 16 | private block: Block 17 | private provider: DocumentProvider 18 | 19 | public constructor(block: Block, provider: DocumentProvider) { 20 | this.block = block 21 | this.provider = provider 22 | } 23 | 24 | private getDocument(): Document { 25 | return this.provider.getDocument() 26 | } 27 | 28 | private updateDocumentContent(document: Document) { 29 | this.provider.updateDocument(document) 30 | } 31 | 32 | private onBlockDeletion() { 33 | if (this.block.kind === 'image') { 34 | const [imageOp] = this.block.getSelectedOps(this.getDocument()) as [ImageOp] 35 | const hooks = this.provider.getImageHooks() 36 | hooks.onImageRemovedEvent && hooks.onImageRemovedEvent(imageOp.insert) 37 | } 38 | } 39 | 40 | public updateSelectionInBlock(blockScopedSelection: SelectionShape, override?: boolean) { 41 | const nextDocument = this.block.updateSelection(blockScopedSelection, this.getDocument()) 42 | this.updateDocumentContent(nextDocument) 43 | if (override) { 44 | this.provider.overrideSelection(nextDocument.currentSelection) 45 | } 46 | } 47 | 48 | public applyAtomicDeltaUpdateInBlock(documentDeltaUpdate: DocumentDeltaAtomicUpdate) { 49 | this.updateDocumentContent(this.block.applyAtomicDeltaUpdate(documentDeltaUpdate, this.getDocument())) 50 | } 51 | 52 | public selectBlock() { 53 | this.updateDocumentContent(this.block.select(this.getDocument())) 54 | } 55 | 56 | public removeCurrentBlock() { 57 | this.updateDocumentContent(this.block.remove(this.getDocument())) 58 | this.onBlockDeletion() 59 | } 60 | 61 | public insertOrReplaceTextAtSelection(character: string) { 62 | const actionWillDeleteBlock = this.block.isEntirelySelected(this.getDocument()) 63 | this.updateDocumentContent( 64 | this.block.insertOrReplaceAtSelection({ type: 'text', content: character }, this.getDocument()), 65 | ) 66 | actionWillDeleteBlock && this.onBlockDeletion() 67 | } 68 | 69 | public removeOneBeforeBlock() { 70 | this.updateDocumentContent(this.block.removeOneBefore(this.getDocument())) 71 | } 72 | 73 | public moveAfterBlock() { 74 | this.updateDocumentContent(this.block.moveAfter(this.getDocument())) 75 | } 76 | 77 | public moveBeforeBlock() { 78 | const nextDoc = this.block.moveBefore(this.getDocument()) 79 | this.updateDocumentContent(nextDoc) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/components/DocumentRenderer.tsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent, ComponentType, ReactNode } from 'react' 2 | import { DocumentPropType, TextTransformSpecsType } from './types' 3 | import PropTypes from 'prop-types' 4 | import { Document } from '@model/document' 5 | import { 6 | StyleSheet, 7 | StyleProp, 8 | ViewStyle, 9 | TextStyle, 10 | ViewPropTypes, 11 | LayoutChangeEvent, 12 | ScrollViewProps, 13 | ScrollView, 14 | View, 15 | } from 'react-native' 16 | import { BlockAssembler } from '@model/BlockAssembler' 17 | import { genericStyles, overridePadding } from './styles' 18 | import { boundMethod } from 'autobind-decorator' 19 | import { Block } from '@model/Block' 20 | import { GenericBlockView } from './GenericBlockView' 21 | import { Images } from '@core/Images' 22 | import { Transforms } from '@core/Transforms' 23 | import { defaults } from './defaults' 24 | 25 | export interface DocumentRendererState { 26 | containerWidth: number | null 27 | } 28 | 29 | /** 30 | * A generic interface for components displaying {@link Document | document}. 31 | * 32 | * @remarks There are 3 styles props: 33 | * 34 | * ``` 35 | * +------------------------------+ 36 | * | style (ScrollView) | 37 | * | +--------------------------+ | 38 | * | | contentContainerStyle | | 39 | * | | +----------------------+ | | 40 | * | | | documentStyle | | | 41 | * | | | | | | 42 | * ``` 43 | * 44 | * @public 45 | */ 46 | export interface DocumentRendererProps { 47 | /** 48 | * The {@link Document | document} to display. 49 | */ 50 | document: Document 51 | 52 | /** 53 | * The image component to render. 54 | * 55 | * @remarks The component MUST fit within the passed {@link Images.ComponentProps.printDimensions} prop. 56 | */ 57 | ImageComponent?: Images.Component 58 | 59 | /** 60 | * A collection of text transforms. 61 | */ 62 | textTransformSpecs?: Transforms.Specs<'text'> 63 | 64 | /** 65 | * Default text style. 66 | */ 67 | textStyle?: StyleProp 68 | 69 | /** 70 | * The max width of a media block. 71 | * 72 | * @remarks If the container width is smaller than this width, the first will be used to frame media. 73 | */ 74 | maxMediaBlockWidth?: number 75 | 76 | /** 77 | * The max height of a media block. 78 | */ 79 | maxMediaBlockHeight?: number 80 | 81 | /** 82 | * The spacing unit. 83 | * 84 | * @remarks It is used: 85 | * 86 | * - Between two adjacent blocks; 87 | * - Between container and document print. 88 | */ 89 | spacing?: number 90 | 91 | /** 92 | * Component style. 93 | */ 94 | style?: StyleProp 95 | 96 | /** 97 | * Style applied to the content container. 98 | * 99 | * @remarks This prop MUST NOT contain padding or margin rules. Such spacing rules will be zero-ed. 100 | * Instead, {@link DocumentRendererProps.spacing | `spacing`} prop will add spacing between the edge of the scrollview and container. 101 | */ 102 | contentContainerStyle?: StyleProp 103 | 104 | /** 105 | * Styles applied to the closest view encompassing rich content. 106 | * 107 | * @remarks This prop MUST NOT contain padding rules. Such padding rules will be zero-ed. Instead, use margin rules. 108 | */ 109 | documentStyle?: StyleProp 110 | /** 111 | * Any {@link react-native#ScrollView} props you wish to pass. 112 | * 113 | * @remarks 114 | * 115 | * - Do not pass `style` prop as it will be overriden by this component `style` props; 116 | * - Do not pass `keyboardShouldPersistTaps` because it will be forced to `"always"`. 117 | */ 118 | scrollViewProps?: ScrollViewProps 119 | /** 120 | * The component to replace RN default {@link react-native#ScrollView}. 121 | */ 122 | ScrollView?: ComponentType 123 | } 124 | 125 | const contentRendererStyles = StyleSheet.create({ 126 | scroll: { 127 | flex: 1, 128 | }, 129 | documentStyle: { 130 | flexGrow: 1, 131 | justifyContent: 'flex-start', 132 | alignItems: 'stretch', 133 | }, 134 | contentContainer: { 135 | flex: 1, 136 | flexDirection: 'row', 137 | alignSelf: 'stretch', 138 | justifyContent: 'center', 139 | }, 140 | }) 141 | 142 | /** 143 | * @internal 144 | */ 145 | export class DocumentRenderer< 146 | P extends DocumentRendererProps, 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 unknown>[]>() 10 | private domain = new EventEmitter() 11 | 12 | public addListener(owner: object, eventType: InnerEventType, listener: ListenerFn) { 13 | this.domain.addListener(eventType, listener) 14 | const listeners = this.owners.get(owner) || [] 15 | listeners.push({ 16 | eventType, 17 | listener, 18 | }) 19 | this.owners.set(owner, listeners) 20 | } 21 | 22 | public emit(eventType: InnerEventType, ...payload: unknown[]): void { 23 | this.domain.emit(eventType, ...payload) 24 | } 25 | 26 | public release(owner: object) { 27 | const descriptors = this.owners.get(owner) 28 | if (descriptors) { 29 | for (const { listener, eventType } of descriptors) { 30 | this.domain.removeListener(eventType, listener) 31 | } 32 | } 33 | this.owners.delete(owner) 34 | } 35 | 36 | public removeAllListeners() { 37 | this.domain.removeAllListeners() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/core/Images.tsx: -------------------------------------------------------------------------------- 1 | import React, { ComponentType } from 'react' 2 | import { Image as RNImage } from 'react-native' 3 | 4 | export const StandardImageComponent = ({ 5 | description, 6 | printDimensions: dimensions, 7 | }: Images.ComponentProps) => { 8 | return 9 | } 10 | 11 | /** 12 | * A set of definitions related to images. 13 | * 14 | * @public 15 | */ 16 | export declare namespace Images { 17 | export interface StandardSource { 18 | uri: string 19 | } 20 | export interface Description { 21 | readonly source: Source 22 | readonly width: number 23 | readonly height: number 24 | } 25 | 26 | export interface Dimensions { 27 | readonly width: number 28 | readonly height: number 29 | } 30 | 31 | export type Component = ComponentType> 32 | 33 | export interface ComponentProps { 34 | /** 35 | * The dimensions this component MUST occupy. 36 | */ 37 | readonly printDimensions: Dimensions 38 | /** 39 | * The image description. 40 | */ 41 | readonly description: Description 42 | } 43 | 44 | /** 45 | * An object used to locate and render images. 46 | */ 47 | export interface Hooks { 48 | /** 49 | * Callback fired when an image has been successfully inserted. 50 | */ 51 | readonly onImageAddedEvent?: (description: Description) => void 52 | /** 53 | * Callback fired when an image has been removed. 54 | */ 55 | readonly onImageRemovedEvent?: (description: Description) => void 56 | } 57 | } 58 | 59 | /** 60 | * 61 | * @param originalDimensions - The image size. 62 | * @param containerWidth - The display container width. 63 | * @param userMaxHeight - The user provided max height. 64 | * @param userMaxWidth - The user provided max width. 65 | * 66 | * @internal 67 | */ 68 | export function computeImageFrame( 69 | originalDimensions: Images.Dimensions, 70 | containerWidth: number, 71 | userMaxHeight = Infinity, 72 | userMaxWidth = Infinity, 73 | ): Images.Dimensions { 74 | const { width, height } = originalDimensions 75 | const imageRatio = width / height 76 | const realMaxWidth = Math.min(userMaxWidth, containerWidth) 77 | const scaledWidthDimensions = { 78 | width: realMaxWidth, 79 | height: realMaxWidth / imageRatio, 80 | } 81 | if (scaledWidthDimensions.height > userMaxHeight) { 82 | return { 83 | width: userMaxHeight * imageRatio, 84 | height: userMaxHeight, 85 | } 86 | } 87 | return scaledWidthDimensions 88 | } 89 | -------------------------------------------------------------------------------- /src/core/Transforms.ts: -------------------------------------------------------------------------------- 1 | import prop from 'ramda/es/prop' 2 | import groupBy from 'ramda/es/groupBy' 3 | import { StyleProp, TextStyle, ViewStyle } from 'react-native' 4 | import invariant from 'invariant' 5 | import { TextOp } from '@delta/operations' 6 | import { Attributes } from '@delta/attributes' 7 | 8 | const attributeNameGetter = prop('attributeName') as ( 9 | t: Transforms.GenericSpec, 10 | ) => string 11 | 12 | export function textTransformListToDict( 13 | list: Transforms.GenericSpec[], 14 | ): Transforms.Dict { 15 | return groupBy(attributeNameGetter)(list) as Transforms.Dict 16 | } 17 | 18 | /** 19 | * A set of definitions related to text and arbitrary content transforms. 20 | * 21 | * @public 22 | */ 23 | declare namespace Transforms { 24 | /** 25 | * The target type of a transform. 26 | */ 27 | export type TargetType = 'block' | 'text' 28 | /** 29 | * A {@link (Transforms:namespace).GenericSpec} which `attributeActiveValue` is `true`. 30 | * 31 | * @public 32 | */ 33 | export type BoolSpec = GenericSpec 34 | /** 35 | * A mapping of attribute names with their corresponding transformation description. 36 | * 37 | * @internal 38 | */ 39 | export interface Dict { 40 | [attributeName: string]: GenericSpec[] 41 | } 42 | /** 43 | * Default text attributes names. 44 | * 45 | * @public 46 | */ 47 | export type TextAttributeName = 'bold' | 'italic' | 'textDecoration' 48 | 49 | /** 50 | * Description of a generic transform. 51 | * 52 | * @public 53 | */ 54 | export interface GenericSpec { 55 | /** 56 | * The name of the attribute. 57 | * 58 | * @remarks 59 | * 60 | * Multiple {@link (Transforms:namespace).GenericSpec} can share the same `attributeName`. 61 | */ 62 | attributeName: string 63 | /** 64 | * The value of the attribute when this transform is active. 65 | */ 66 | activeAttributeValue: A 67 | /** 68 | * The style applied to the target block when this transform is active. 69 | */ 70 | activeStyle: T extends 'block' ? ViewStyle : TextStyle 71 | } 72 | 73 | export type Specs = GenericSpec[] 74 | } 75 | 76 | /** 77 | * An entity which responsibility is to provide styles from text transforms. 78 | * 79 | * @public 80 | */ 81 | class Transforms { 82 | private textTransformsDict: Transforms.Dict 83 | 84 | public constructor(textTransformSpecs: Transforms.GenericSpec[]) { 85 | this.textTransformsDict = textTransformListToDict(textTransformSpecs) 86 | } 87 | 88 | /** 89 | * Produce react styles from a text operation. 90 | * 91 | * @param op - text op. 92 | * 93 | * @internal 94 | */ 95 | public getStylesFromOp(op: TextOp): StyleProp { 96 | const styles: StyleProp = [] 97 | if (op.attributes) { 98 | for (const attributeName of Object.keys(op.attributes)) { 99 | if (op.attributes != null && attributeName !== '$type') { 100 | const attributeValue = op.attributes[attributeName] 101 | let match = false 102 | if (attributeValue !== null) { 103 | for (const candidate of this.textTransformsDict[attributeName] || []) { 104 | if (candidate.activeAttributeValue === attributeValue) { 105 | styles.push(candidate.activeStyle) 106 | match = true 107 | } 108 | } 109 | invariant( 110 | match, 111 | `A Text Transform must be specified for attribute "${attributeName}" with value ${JSON.stringify( 112 | attributeValue, 113 | )}`, 114 | ) 115 | } 116 | } 117 | } 118 | } 119 | return styles 120 | } 121 | } 122 | 123 | export { Transforms } 124 | 125 | export const booleanTransformBase = { 126 | activeAttributeValue: true as true, 127 | } 128 | 129 | export const boldTransform: Transforms.BoolSpec<'text'> = { 130 | ...booleanTransformBase, 131 | attributeName: 'bold', 132 | activeStyle: { 133 | fontWeight: 'bold', 134 | }, 135 | } 136 | 137 | export const italicTransform: Transforms.BoolSpec<'text'> = { 138 | ...booleanTransformBase, 139 | attributeName: 'italic', 140 | activeStyle: { 141 | fontStyle: 'italic', 142 | }, 143 | } 144 | 145 | export const underlineTransform: Transforms.GenericSpec<'underline', 'text'> = { 146 | activeAttributeValue: 'underline', 147 | attributeName: 'textDecoration', 148 | activeStyle: { 149 | textDecorationStyle: 'solid', 150 | textDecorationLine: 'underline', 151 | }, 152 | } 153 | 154 | export const strikethroughTransform: Transforms.GenericSpec<'strikethrough', 'text'> = { 155 | activeAttributeValue: 'strikethrough', 156 | attributeName: 'textDecoration', 157 | activeStyle: { 158 | textDecorationStyle: 'solid', 159 | textDecorationLine: 'line-through', 160 | }, 161 | } 162 | 163 | /** 164 | * @public 165 | */ 166 | export const defaultTextTransforms: Transforms.GenericSpec[] = [ 167 | boldTransform, 168 | italicTransform, 169 | underlineTransform, 170 | strikethroughTransform, 171 | ] 172 | -------------------------------------------------------------------------------- /src/core/__tests__/Transforms-test.ts: -------------------------------------------------------------------------------- 1 | import { Transforms, textTransformListToDict } from '@core/Transforms' 2 | import { 3 | defaultTextTransforms, 4 | boldTransform, 5 | italicTransform, 6 | underlineTransform, 7 | strikethroughTransform, 8 | } from '@core/Transforms' 9 | 10 | describe('@core/TextTransformsRegistry', () => { 11 | describe('textTransformListToDict', () => { 12 | it('should transform list to dictionnary', () => { 13 | const dict = textTransformListToDict(defaultTextTransforms) 14 | expect(dict).toEqual({ 15 | bold: [boldTransform], 16 | italic: [italicTransform], 17 | textDecoration: [underlineTransform, strikethroughTransform], 18 | }) 19 | }) 20 | }) 21 | describe('getStylesFromOp', () => { 22 | it('should merge styles from OP', () => { 23 | const registry = new Transforms(defaultTextTransforms) 24 | expect( 25 | registry.getStylesFromOp({ 26 | insert: 'A', 27 | attributes: { 28 | textDecoration: 'underline', 29 | bold: true, 30 | italic: true, 31 | }, 32 | }), 33 | ).toEqual( 34 | expect.arrayContaining([ 35 | { 36 | textDecorationStyle: 'solid', 37 | textDecorationLine: 'underline', 38 | }, 39 | { 40 | fontWeight: 'bold', 41 | }, 42 | { 43 | fontStyle: 'italic', 44 | }, 45 | ]), 46 | ) 47 | }) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /src/delta/DeltaBuffer.ts: -------------------------------------------------------------------------------- 1 | import Delta from 'quill-delta' 2 | 3 | export class DeltaBuffer { 4 | private chunks: Delta[] = [] 5 | 6 | public push(...delta: Delta[]) { 7 | this.chunks.push(...delta) 8 | } 9 | 10 | public compose() { 11 | return this.chunks.reduce((prev, curr) => prev.concat(curr), new Delta()) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/delta/DeltaChangeContext.ts: -------------------------------------------------------------------------------- 1 | import { Selection } from './Selection' 2 | 3 | export class DeltaChangeContext { 4 | public readonly selectionBeforeChange: Selection 5 | public readonly selectionAfterChange: Selection 6 | 7 | public constructor(selectionBeforeChange: Selection, selectionAfterChange: Selection) { 8 | this.selectionAfterChange = selectionAfterChange 9 | this.selectionBeforeChange = selectionBeforeChange 10 | } 11 | 12 | public lowerLimit() { 13 | return Math.min(this.selectionAfterChange.start, this.selectionBeforeChange.start) 14 | } 15 | 16 | public upperLimit() { 17 | return Math.max(this.selectionAfterChange.end, this.selectionBeforeChange.end) 18 | } 19 | 20 | public deleteTraversal(): Selection { 21 | return Selection.fromBounds( 22 | Math.min(this.selectionBeforeChange.start, this.selectionAfterChange.start), 23 | this.selectionBeforeChange.end, 24 | ) 25 | } 26 | 27 | public isInsertion() { 28 | return this.selectionBeforeChange.start < this.selectionAfterChange.end 29 | } 30 | 31 | public isDeletion() { 32 | return this.selectionBeforeChange.end > this.lowerLimit() 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/delta/DeltaDiffComputer.ts: -------------------------------------------------------------------------------- 1 | import Delta from 'quill-delta' 2 | import { DocumentDelta } from './DocumentDelta' 3 | import { Attributes, mergeAttributesRight } from './attributes' 4 | import { DeltaChangeContext } from './DeltaChangeContext' 5 | import { Text } from './Text' 6 | import { Selection } from './Selection' 7 | import { makeDiffDelta } from './diff' 8 | 9 | export enum NormalizeOperation { 10 | INSERT_LINE_TYPE_PREFIX, 11 | INVESTIGATE_DELETION, 12 | CHECK_LINE_TYPE_PREFIX, 13 | } 14 | 15 | export interface DeltaDiffReport { 16 | delta: Delta 17 | } 18 | 19 | interface TextDiffContext { 20 | readonly textAttributes: Attributes.Map 21 | readonly lineAttributes: Attributes.Map 22 | readonly lineTypeBeforeChange: Attributes.LineType 23 | readonly context: DeltaChangeContext 24 | readonly oldText: Text 25 | readonly newText: Text 26 | } 27 | 28 | export interface DeltaDiffModel { 29 | readonly oldText: string 30 | readonly newText: string 31 | readonly context: DeltaChangeContext 32 | readonly cursorTextAttributes: Attributes.Map 33 | } 34 | 35 | export class DeltaDiffComputer { 36 | private readonly diffContext: TextDiffContext 37 | 38 | public constructor(model: DeltaDiffModel, delta: DocumentDelta) { 39 | const { context, cursorTextAttributes, newText: newTextRaw, oldText: oldTextRaw } = model 40 | const selectedTextAttributes = delta.getSelectedTextAttributes(context.selectionBeforeChange) 41 | const selectionBeforeChangeLength = context.selectionBeforeChange.end - context.selectionBeforeChange.start 42 | const textAttributes = selectionBeforeChangeLength 43 | ? selectedTextAttributes 44 | : mergeAttributesRight(selectedTextAttributes, cursorTextAttributes) 45 | const lineTypeBeforeChange = delta.getLineTypeInSelection(context.selectionBeforeChange) 46 | const oldText = new Text(oldTextRaw) 47 | const newText = new Text(newTextRaw) 48 | const lineAttributes = lineTypeBeforeChange === 'normal' ? {} : { $type: lineTypeBeforeChange } 49 | this.diffContext = { 50 | context, 51 | oldText, 52 | newText, 53 | textAttributes, 54 | lineAttributes, 55 | lineTypeBeforeChange, 56 | } 57 | } 58 | 59 | private computeGenericDelta(originalText: Text, diffContext: TextDiffContext): Delta { 60 | const { context, newText, textAttributes } = diffContext 61 | const lineBeforeChangeSelection = originalText.getSelectionEncompassingLines(context.selectionBeforeChange) 62 | const lineAfterChangeSelection = newText.getSelectionEncompassingLines(context.selectionAfterChange) 63 | const lineChangeContext = new DeltaChangeContext(lineBeforeChangeSelection, lineAfterChangeSelection) 64 | const selectionTraversalBeforeChange = lineChangeContext.deleteTraversal() 65 | const selectionTraversalAfterChange = Selection.between( 66 | selectionTraversalBeforeChange.start, 67 | lineAfterChangeSelection.end, 68 | ) 69 | const textBeforeChange = originalText.select(selectionTraversalBeforeChange) 70 | const textAfterChange = newText.select(selectionTraversalAfterChange) 71 | const delta = new Delta() 72 | .retain(selectionTraversalBeforeChange.start) 73 | .concat(makeDiffDelta(textBeforeChange.raw, textAfterChange.raw, textAttributes)) 74 | .retain(originalText.length - selectionTraversalBeforeChange.end) 75 | return delta 76 | } 77 | 78 | public toDeltaDiffReport(): DeltaDiffReport { 79 | const { oldText } = this.diffContext 80 | const delta = this.computeGenericDelta(oldText, this.diffContext) 81 | return { delta } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/delta/DocumentDelta.ts: -------------------------------------------------------------------------------- 1 | import Delta from 'quill-delta' 2 | import { getTextAttributes, Attributes } from './attributes' 3 | import { Selection, SelectionShape } from './Selection' 4 | import { GenericOp } from './operations' 5 | import mergeRight from 'ramda/es/mergeRight' 6 | import pickBy from 'ramda/es/pickBy' 7 | import head from 'ramda/es/head' 8 | import { GenericRichContent, extractTextFromDelta } from './generic' 9 | import { DeltaDiffComputer } from './DeltaDiffComputer' 10 | import { DeltaChangeContext } from './DeltaChangeContext' 11 | import { DocumentLine, LineWalker } from './LineWalker' 12 | import { DocumentDeltaAtomicUpdate } from './DocumentDeltaAtomicUpdate' 13 | 14 | export class DocumentDelta implements GenericRichContent { 15 | public get ops() { 16 | return this.delta.ops 17 | } 18 | 19 | private delta: Delta 20 | private text: string | null = null 21 | 22 | public constructor(arg?: GenericOp[] | DocumentDelta | Delta) { 23 | this.delta = arg instanceof DocumentDelta ? new Delta(arg.delta) : new Delta(arg) 24 | } 25 | 26 | private getText(): string { 27 | if (this.text !== null) { 28 | return this.text 29 | } 30 | this.text = extractTextFromDelta(this.delta) 31 | return this.text 32 | } 33 | 34 | /** 35 | * @returns a Selection which encompasses all characters in lines traversed by incoming `selection` 36 | * 37 | * @param selection - The selection touching lines. 38 | */ 39 | private getSelectionEncompassingLines(selection: Selection): Selection { 40 | let accumulatedLength = 0 41 | let newSelectionStart = selection.start 42 | let newSelectionEnd = selection.end 43 | let isUpperBoundFrozen = false 44 | this.delta.eachLine(l => { 45 | if (selection.start > accumulatedLength - 1) { 46 | newSelectionStart = accumulatedLength 47 | } 48 | accumulatedLength += l.length() + 1 49 | if (selection.end < accumulatedLength && !isUpperBoundFrozen) { 50 | newSelectionEnd = accumulatedLength 51 | isUpperBoundFrozen = true 52 | } 53 | }) 54 | return Selection.fromBounds(newSelectionStart, newSelectionEnd) 55 | } 56 | 57 | /** 58 | * @param selection - The selection to extract the subdelta from. 59 | * @returns The DocumentDelta representing selection 60 | * 61 | */ 62 | public getSelected(selection: Selection): DocumentDelta { 63 | return this.create(this.delta.slice(selection.start, selection.end)) 64 | } 65 | 66 | public compose(delta: Delta): DocumentDelta { 67 | return this.create(this.delta.compose(delta)) 68 | } 69 | 70 | public create(delta: Delta): DocumentDelta { 71 | return new DocumentDelta(delta) 72 | } 73 | 74 | public length() { 75 | return this.delta.length() 76 | } 77 | 78 | public concat(delta: Delta | DocumentDelta) { 79 | return this.create(this.delta.concat(delta instanceof DocumentDelta ? delta.delta : delta)) 80 | } 81 | 82 | public eachLine(predicate: (line: DocumentLine) => void) { 83 | new LineWalker(this.delta).eachLine(predicate) 84 | } 85 | 86 | /** 87 | * Compute a diff between this document delta text and return the resulting atomic update. 88 | * The first one is the strict result of applying the text diff, while the second one 89 | * it the result of applying normalization rules (i.e. prefixes rules for text modifying line types). 90 | * 91 | * @remarks 92 | * 93 | * `cursorTextAttributes` will by applied to inserted characters if and only if `deltaChangeContext.selectionBeforeChange` is of length 0. 94 | * 95 | * @param newText - The changed text. 96 | * @param deltaChangeContext - The context in which the change occurred. 97 | * @param cursorTextAttributes - Text attributes at cursor. 98 | * @returns The resulting atomic update from applying the text diff. 99 | */ 100 | public applyTextDiff( 101 | newText: string, 102 | deltaChangeContext: DeltaChangeContext, 103 | cursorTextAttributes: Attributes.Map = {}, 104 | ): DocumentDeltaAtomicUpdate { 105 | const oldText = this.getText() 106 | const computer = new DeltaDiffComputer( 107 | { 108 | cursorTextAttributes, 109 | newText, 110 | oldText, 111 | context: deltaChangeContext, 112 | }, 113 | this, 114 | ) 115 | const { delta } = computer.toDeltaDiffReport() 116 | return new DocumentDeltaAtomicUpdate(this.compose(delta), delta, deltaChangeContext.selectionAfterChange) 117 | } 118 | 119 | /** 120 | * Determine the line type of given selection. 121 | * 122 | * @remarks 123 | * 124 | * **Pass algorithm**: 125 | * 126 | * If each and every line has its `$type` set to one peculiar value, return this value. 127 | * Otherwise, return `"normal"`. 128 | * 129 | * @param selection - the selection to which line type should be inferred. 130 | * @returns the line type encompassing the whole selection. 131 | */ 132 | public getLineTypeInSelection(selection: Selection): Attributes.LineType { 133 | // TODO inspect inconsistencies between this getSelectionEncompassingLines and Text::getSelectionEncompassingLine 134 | const selected = this.getSelected(this.getSelectionEncompassingLines(selection)) 135 | const lineAttributes: Attributes.Map[] = [] 136 | selected.delta.eachLine((l, a) => { 137 | lineAttributes.push(a) 138 | }) 139 | const firstAttr = head(lineAttributes) 140 | let type: Attributes.LineType = 'normal' 141 | if (firstAttr) { 142 | if (firstAttr.$type == null) { 143 | return 'normal' 144 | } 145 | type = firstAttr.$type as Attributes.LineType 146 | } 147 | const isType = lineAttributes.every(v => v.$type === type) 148 | if (isType) { 149 | return type 150 | } 151 | return 'normal' 152 | } 153 | 154 | /** 155 | * Returns the attributes encompassing the given selection. 156 | * This attribute should be used for user feedback after selection change. 157 | * 158 | * @remarks 159 | * 160 | * The returned attributes depend on the selection size: 161 | * 162 | * - When selection size is `0`, this function returns attributes of the closest character before cursor. 163 | * - When selection size is `1+`, this function returns a merge of each set of attributes (see merge algorithm bellow). 164 | * 165 | * **Merge algorithm**: 166 | * 167 | * - for an attribute to be merged in the remaining object, it must be present in every operation traversed by selection; 168 | * - operations consisting of one newline character insert should be ignored during the traversal, and thus line attributes ignored; 169 | * - if one attribute name has conflicting values within the selection, none of those values should be picked in the remaining object; 170 | * - any attribute name with a nil value (`null` or `undefined`) should be ignored in the remaining object. 171 | * 172 | * @param selection - the selection from which text attributes should be extracted 173 | * @returns The resulting merged object 174 | */ 175 | public getSelectedTextAttributes(selection: SelectionShape): Attributes.Map { 176 | let realSelection = Selection.fromShape(selection) 177 | if (selection.start === selection.end) { 178 | if (selection.start === 0) { 179 | return {} 180 | } 181 | realSelection = Selection.fromBounds(selection.start - 1, selection.end) 182 | } 183 | const deltaSelection = this.getSelected(realSelection) 184 | const attributesList = deltaSelection.delta 185 | .filter(op => typeof op.insert === 'string' && op.insert !== '\n') 186 | .map(op => op.attributes || {}) 187 | const attributes = attributesList.reduce(mergeRight, {}) 188 | const realAttributes = pickBy((value: Attributes.GenericValue, attributeName: string) => 189 | attributesList.every(localValue => localValue[attributeName] === value), 190 | )(attributes) 191 | return getTextAttributes(realAttributes) 192 | } 193 | 194 | /** 195 | * Switch the given attribute's value depending on the state of the given selection: 196 | * 197 | * - if **all characters** in the selection have the `attributeName` set to `attributeValue`, **clear** this attribute for all characters in this selection 198 | * - otherwise set `attributeName` to `attributeValue` for all characters in this selection 199 | * 200 | * @param selectionBeforeChange - The boundaries to which the transforms should be applied 201 | * @param attributeName - The attribute name to modify 202 | * @param attributeValue - The attribute value to assign 203 | */ 204 | public applyTextTransformToSelection( 205 | selectionBeforeChange: Selection, 206 | attributeName: string, 207 | attributeValue: Attributes.GenericValue, 208 | ): DocumentDeltaAtomicUpdate { 209 | const allOperationsMatchAttributeValue = this.getSelected(selectionBeforeChange).ops.every( 210 | op => !!op.attributes && op.attributes[attributeName] === attributeValue, 211 | ) 212 | if (allOperationsMatchAttributeValue) { 213 | const clearAllDelta = new Delta() 214 | clearAllDelta.retain(selectionBeforeChange.start) 215 | clearAllDelta.retain(selectionBeforeChange.length(), { [attributeName]: null }) 216 | return new DocumentDeltaAtomicUpdate(this.compose(clearAllDelta), clearAllDelta, selectionBeforeChange) 217 | } 218 | const replaceAllDelta = new Delta() 219 | replaceAllDelta.retain(selectionBeforeChange.start) 220 | replaceAllDelta.retain(selectionBeforeChange.length(), { [attributeName]: attributeValue }) 221 | return new DocumentDeltaAtomicUpdate(this.compose(replaceAllDelta), replaceAllDelta, selectionBeforeChange) 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/delta/DocumentDeltaAtomicUpdate.ts: -------------------------------------------------------------------------------- 1 | import { Selection } from './Selection' 2 | import { DocumentDelta } from './DocumentDelta' 3 | import Delta from 'quill-delta' 4 | 5 | export class DocumentDeltaAtomicUpdate { 6 | private readonly _selectionAfterChange: Selection 7 | public readonly delta: DocumentDelta 8 | public readonly diff: Delta 9 | public constructor(delta: DocumentDelta, diff: Delta, selectionAfterChange: Selection) { 10 | this._selectionAfterChange = selectionAfterChange 11 | this.delta = delta 12 | this.diff = diff 13 | } 14 | 15 | public get selectionAfterChange() { 16 | return this._selectionAfterChange 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/delta/LineWalker.ts: -------------------------------------------------------------------------------- 1 | import { GenericOp } from './operations' 2 | import { getLineType, GenericLine } from './lines' 3 | import Delta from 'quill-delta' 4 | import { GenericRichContent, isGenericDelta } from './generic' 5 | import { Selection } from './Selection' 6 | import { Attributes } from '@delta/attributes' 7 | 8 | export interface DocumentLine extends GenericLine { 9 | delta: Delta 10 | lineType: Attributes.LineType 11 | // lineTypeIndex: number 12 | } 13 | 14 | export class LineWalker { 15 | public readonly ops: GenericOp[] 16 | 17 | public constructor(arg: GenericOp[] | GenericRichContent) { 18 | this.ops = isGenericDelta(arg) ? arg.ops : arg 19 | } 20 | 21 | public eachLine(predicate: (line: DocumentLine) => void) { 22 | let firstLineCharAt = 0 23 | new Delta(this.ops).eachLine((delta, attributes, index) => { 24 | const beginningOfLineIndex = firstLineCharAt 25 | const endOfLineIndex = beginningOfLineIndex + delta.length() 26 | firstLineCharAt = endOfLineIndex + 1 // newline 27 | const lineType = getLineType(attributes) 28 | const lineRange = Selection.fromBounds(beginningOfLineIndex, endOfLineIndex) 29 | predicate({ 30 | lineRange, 31 | delta, 32 | lineType, 33 | index, 34 | }) 35 | }) 36 | } 37 | 38 | public getLines() { 39 | const lines: DocumentLine[] = [] 40 | this.eachLine(l => lines.push(l)) 41 | return lines 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/delta/Selection.ts: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant' 2 | 3 | /** 4 | * A serializable object representing a selection of items in the {@link (Typer:class)}. 5 | * 6 | * @public 7 | */ 8 | export interface SelectionShape { 9 | /** 10 | * **Inclusive** first item index in selection. 11 | */ 12 | readonly start: number 13 | /** 14 | * **Exclusive** last item index in selection. 15 | */ 16 | readonly end: number 17 | } 18 | 19 | /** 20 | * A class representing a range of character indexes. 21 | * This range can represent a selection of those characters. 22 | * 23 | */ 24 | export class Selection implements SelectionShape { 25 | public readonly start: number 26 | public readonly end: number 27 | private constructor(start: number, end?: number) { 28 | invariant(end === undefined || end - start >= 0, 'start must be equal or inferior to end') 29 | this.start = start 30 | this.end = typeof end === 'number' ? end : start 31 | } 32 | 33 | public static between(one: number, two: number) { 34 | return Selection.fromBounds(Math.min(one, two), Math.max(one, two)) 35 | } 36 | 37 | public static fromBounds(start: number, end?: number) { 38 | return new Selection(start, end) 39 | } 40 | 41 | public static fromShape({ start, end }: SelectionShape) { 42 | return new Selection(start, end) 43 | } 44 | 45 | /** 46 | * Informs wether or not an index touches this range. 47 | * 48 | * @remarks 49 | * 50 | * ```ts 51 | * const selection = Selection.fromBounds(1, 3) 52 | * selection.touchesIndex(0) // false 53 | * selection.touchesIndex(1) // true 54 | * selection.touchesIndex(2) // true 55 | * selection.touchesIndex(3) // true 56 | * selection.touchesIndex(4) // false 57 | * ``` 58 | * 59 | * @param selectionIndex - The index to test. 60 | */ 61 | public touchesIndex(selectionIndex: number): boolean { 62 | return selectionIndex >= this.start && selectionIndex <= this.end 63 | } 64 | 65 | /** 66 | * Informs wether or not a selection has at least one index in 67 | * common with another selection. 68 | * 69 | * @param selection - The selection to which this test should apply. 70 | */ 71 | public touchesSelection(selection: Selection): boolean { 72 | const lowerBound = selection.start 73 | const upperBound = selection.end 74 | return this.touchesIndex(lowerBound) || this.touchesIndex(upperBound) 75 | } 76 | 77 | public intersectionLength(selection: Selection) { 78 | const intersection = this.intersection(selection) 79 | return intersection ? intersection.length() : 0 80 | } 81 | 82 | /** 83 | * 84 | * @param selection - The selection to which this test should apply. 85 | */ 86 | public intersection(selection: Selection): Selection | null { 87 | const maximumMin = Math.max(this.start, selection.start) 88 | const minimumMax = Math.min(this.end, selection.end) 89 | if (maximumMin < minimumMax) { 90 | return Selection.fromBounds(maximumMin, minimumMax) 91 | } 92 | return null 93 | } 94 | 95 | public move(position: number): SelectionShape { 96 | const { start, end } = this 97 | return { 98 | start: start + position, 99 | end: end + position, 100 | } 101 | } 102 | 103 | public toShape(): SelectionShape { 104 | const { start, end } = this 105 | return { 106 | start, 107 | end, 108 | } 109 | } 110 | 111 | public length(): number { 112 | return this.end - this.start 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/delta/Text.ts: -------------------------------------------------------------------------------- 1 | import { Selection } from './Selection' 2 | import { GenericLine } from './lines' 3 | 4 | export interface TextLine extends GenericLine { 5 | text: string 6 | } 7 | 8 | /** 9 | * Intermediary entity between pure text and {@link quill-delta#Delta the Delta class}. 10 | * 11 | * @remarks 12 | * 13 | * A Text instance represents an absolute-positionned part of the document. 14 | * We refer to absolute coordinates as **Document coordinates** or index, and relative 15 | * coordinates as **Text coordiantes** or index. 16 | */ 17 | export class Text { 18 | public readonly raw: string 19 | public readonly beginningIndex: number = 0 20 | 21 | public constructor(rawText: string, beginningIndex?: number) { 22 | this.raw = rawText 23 | this.beginningIndex = beginningIndex || 0 24 | } 25 | 26 | private documentToTextIndex(documentIndex: number) { 27 | return documentIndex - this.beginningIndex 28 | } 29 | 30 | private textToDocumentIndex(textIndex: number) { 31 | return textIndex + this.beginningIndex 32 | } 33 | 34 | public get length() { 35 | return this.raw.length 36 | } 37 | 38 | /** 39 | * 40 | * @param from Document index of the first character to select. 41 | * @param to Document index of the last + 1 character to select. 42 | */ 43 | public substring(from: number, to: number): string { 44 | return this.raw.substring(this.documentToTextIndex(from), this.documentToTextIndex(to)) 45 | } 46 | 47 | /** 48 | * 49 | * @param documentIndex 50 | */ 51 | public charAt(documentIndex: number): string { 52 | return this.raw.charAt(this.documentToTextIndex(documentIndex)) 53 | } 54 | 55 | /** 56 | * 57 | * @param documentSelection Document coordinates of selection. 58 | * @returns A new {@link Text} instance which is a substring of `this` instance with Document coordiantes. 59 | */ 60 | public select(documentSelection: Selection): Text { 61 | return new Text(this.substring(documentSelection.start, documentSelection.end), documentSelection.start) 62 | } 63 | 64 | /** 65 | * Builds the selection matching lines touched by the param `selection`. 66 | * 67 | * @remarks 68 | * 69 | * For N characters, there are N+1 selection indexes. In the example bellow, 70 | * each character is surrounded by two cursor positions reprented with crosses (`†`). 71 | * 72 | * `†A†B†\n†C†D†\n†` 73 | * 74 | * **Encompassing** 75 | * 76 | * The selection encompassing a line always excludes the index referring to the end of the 77 | * trailing newline character. 78 | * In the example above, the selection `[1, 2]` would match this line: `[0, 2]` with characters `'AB'`. 79 | * The selection `[4, 5]` would match the line `[3, 5]` with characters `'CD'`. 80 | * 81 | * **Multiple lines** 82 | * 83 | * When multiple lines are touched by the param `selection`, indexes referring 84 | * to the end of the trailing newline character in sibling lines are included. 85 | * 86 | * Therefore, in the above example, applying the selection `[2, 3]` to this function 87 | * would result in the following selection: `[0, 5]`. 88 | * 89 | * @param documentSelection Document relative selection to which this algorithm apply. 90 | */ 91 | public getSelectionEncompassingLines(documentSelection: Selection): Selection { 92 | let textStart = documentSelection.start - this.beginningIndex 93 | let textEnd = documentSelection.end - this.beginningIndex 94 | while (textStart > 0 && this.raw.charAt(textStart - 1) !== '\n') { 95 | textStart -= 1 96 | } 97 | while (textEnd < this.raw.length && this.raw.charAt(textEnd) !== '\n') { 98 | textEnd += 1 99 | } 100 | return Selection.fromBounds(this.textToDocumentIndex(textStart), this.textToDocumentIndex(textEnd)) 101 | } 102 | 103 | /** 104 | * @returns A list of lines complying with the {@link GenericLine} contract. 105 | */ 106 | public getLines(): TextLine[] { 107 | let charIndex = this.beginningIndex - 1 108 | let lineIndex = -1 109 | const lines = this.raw.split('\n') 110 | return lines.map(text => { 111 | const start = charIndex + 1 112 | charIndex = start + text.length 113 | lineIndex += 1 114 | const lineRange = Selection.fromBounds(start, charIndex) 115 | return { 116 | text, 117 | lineRange, 118 | index: lineIndex, 119 | } 120 | }) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/delta/__tests__/LineWalker-test.ts: -------------------------------------------------------------------------------- 1 | import { mockDocumentDelta } from '@test/document' 2 | import { LineWalker } from '@delta/LineWalker' 3 | import zip from 'ramda/es/zip' 4 | 5 | describe('@delta/LineWalker', () => { 6 | describe('getLines', () => { 7 | it('should group lines by trailing newline characters', () => { 8 | const delta = mockDocumentDelta([{ insert: 'AB\nCD\n' }]) 9 | const walker = new LineWalker(delta) 10 | const lines = walker.getLines() 11 | expect(lines.length).toBe(2) 12 | }) 13 | it('should produce line ranges complying with the substring contract', () => { 14 | const textLines = ['AB', 'CD'] 15 | const fullText = 'AB\nCD\n' 16 | const delta = mockDocumentDelta([{ insert: fullText }]) 17 | const walker = new LineWalker(delta) 18 | const lines = walker.getLines() 19 | for (const [line, textLine] of zip(lines, textLines)) { 20 | expect(fullText.substring(line.lineRange.start, line.lineRange.end)).toBe(textLine) 21 | } 22 | }) 23 | it('should produce line ranges for which the character at range.end is a newline', () => { 24 | const fullText = 'AB\nCD\n' 25 | const delta = mockDocumentDelta([{ insert: fullText }]) 26 | const walker = new LineWalker(delta) 27 | const lines = walker.getLines() 28 | for (const line of lines) { 29 | expect(fullText.charAt(line.lineRange.end)).toBe('\n') 30 | } 31 | }) 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /src/delta/__tests__/Selection-test.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:no-unused-expression 2 | import { Selection } from '@delta/Selection' 3 | 4 | describe('@delta/Selection', () => { 5 | describe('constructor', () => { 6 | it('should throw when start lower then end', () => { 7 | expect(() => { 8 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 9 | // @ts-ignore 10 | new Selection(0, -1) 11 | }).toThrow() 12 | }) 13 | }) 14 | describe('touchesIndex', () => { 15 | it('should be true with index strictly equals selection end', () => { 16 | const selection = Selection.fromBounds(0, 1) 17 | expect(selection.touchesIndex(1)).toBe(true) 18 | }) 19 | it("should be false when index doesn't touch the edge of selection", () => { 20 | const selection = Selection.fromBounds(0, 1) 21 | expect(selection.touchesIndex(2)).toBe(false) 22 | }) 23 | }) 24 | describe('touchesSelection', () => { 25 | it('should be true when param selection touches the upper edge of selection', () => { 26 | const selection = Selection.fromBounds(0, 1) 27 | expect(selection.touchesSelection(Selection.fromBounds(1, 1))).toBe(true) 28 | }) 29 | it("should be false when param selection doesn't touch the edge of selection", () => { 30 | const selection = Selection.fromBounds(0, 1) 31 | expect(selection.touchesSelection(Selection.fromBounds(2, 2))).toBe(false) 32 | }) 33 | }) 34 | describe('intersection', () => { 35 | it('should return a selection of length 1 when selection 2 has one cell overlapping over selection 1', () => { 36 | const selection1 = Selection.fromBounds(0, 2) 37 | const selection2 = Selection.fromBounds(1, 3) 38 | expect(selection1.intersection(selection2)).toMatchObject({ 39 | start: 1, 40 | end: 2, 41 | }) 42 | }) 43 | it('should return null when selection 2 start equals selection 1 end', () => { 44 | const selection1 = Selection.fromBounds(0, 2) 45 | const selection2 = Selection.fromBounds(2, 3) 46 | expect(selection1.intersection(selection2)).toBeNull() 47 | }) 48 | it('should return null when selection 2 start is superior to selection 1 end', () => { 49 | const selection1 = Selection.fromBounds(0, 2) 50 | const selection2 = Selection.fromBounds(3, 4) 51 | expect(selection1.intersection(selection2)).toBeNull() 52 | }) 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /src/delta/__tests__/Text-test.ts: -------------------------------------------------------------------------------- 1 | import { Text } from '@delta/Text' 2 | import { Selection } from '@delta/Selection' 3 | import zip from 'ramda/es/zip' 4 | 5 | describe('@delta/Text', () => { 6 | describe('getSelectionEncompassingLines', () => { 7 | it('should encompass line when the start touches the end of line', () => { 8 | const text = new Text('ABC\nDEF\n') 9 | expect(text.getSelectionEncompassingLines(Selection.fromBounds(3))).toMatchObject({ 10 | start: 0, 11 | end: 3, 12 | }) 13 | }) 14 | it('should encompass line when the start touches the beginning of a line', () => { 15 | const text = new Text('ABC\nDEF\n') 16 | expect(text.getSelectionEncompassingLines(Selection.fromBounds(4))).toMatchObject({ 17 | start: 4, 18 | end: 7, 19 | }) 20 | }) 21 | it('should encompass two lines when start touches the end of a line and end touches the beginning of its sibling', () => { 22 | const text = new Text('ABC\nDEF\n') 23 | expect(text.getSelectionEncompassingLines(Selection.fromBounds(3, 4))).toMatchObject({ 24 | start: 0, 25 | end: 7, 26 | }) 27 | }) 28 | }) 29 | describe('select', () => { 30 | it('should return a Text instance wihch is an absolutely positionned substring of `this`', () => { 31 | const text = new Text('BCD', 1) 32 | const subText = text.select(Selection.fromBounds(1, 3)) 33 | expect(subText.raw).toBe('BC') 34 | expect(subText.beginningIndex).toBe(1) 35 | }) 36 | }) 37 | describe('substring', () => { 38 | it('should return a substring which origin is the beginning of index', () => { 39 | const text = new Text('BCD', 1) 40 | const subtext = text.substring(1, 3) 41 | expect(subtext).toBe('BC') 42 | }) 43 | }) 44 | describe('charAt', () => { 45 | it('should return a character document positionned', () => { 46 | const text = new Text('BCD', 1) 47 | expect(text.charAt(1)).toBe('B') 48 | }) 49 | }) 50 | describe('getLines', () => { 51 | it('should split lines by newline characters', () => { 52 | const fullText = 'AB\nCD' 53 | const text = new Text(fullText) 54 | expect(text.getLines().length).toBe(2) 55 | }) 56 | it('should produce line ranges complying with the substring contract', () => { 57 | const fullText = 'AB\nCD' 58 | const textLines = ['AB', 'CD'] 59 | const text = new Text(fullText) 60 | const lines = text.getLines() 61 | for (const [line, textLine] of zip(lines, textLines)) { 62 | expect(fullText.substring(line.lineRange.start, line.lineRange.end)).toBe(textLine) 63 | } 64 | }) 65 | it('should produce line ranges for which the character at range.end is a newline except for last line', () => { 66 | const fullText = 'AB\nCD' 67 | const text = new Text(fullText) 68 | const lines = text.getLines() 69 | for (const line of lines.slice(0, 1)) { 70 | expect(fullText.charAt(line.lineRange.end)).toBe('\n') 71 | } 72 | }) 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /src/delta/__tests__/diff-test.ts: -------------------------------------------------------------------------------- 1 | import { makeDiffDelta } from '@delta/diff' 2 | 3 | describe('@delta/diff', () => { 4 | describe('makeDiffDelta', () => { 5 | it('should find the minimal set of diff operations', () => { 6 | const text1 = 'A\n' 7 | const text2 = 'A\nB\n' 8 | expect(makeDiffDelta(text1, text2, {})).toEqual({ 9 | ops: [{ retain: 2 }, { insert: 'B\n' }], 10 | }) 11 | }) 12 | it('should not be greedy with words', () => { 13 | const text1 = 'A\nB C' 14 | const text2 = 'A\nB DEF' 15 | expect(makeDiffDelta(text1, text2, {})).toEqual({ 16 | ops: [{ retain: 4 }, { insert: 'DEF' }, { delete: 1 }], 17 | }) 18 | }) 19 | it('should apply text attributes to characters within a line', () => { 20 | const text1 = 'A\nBC' 21 | const text2 = 'A\nBCDEF' 22 | expect(makeDiffDelta(text1, text2, { weight: 'bold' })).toEqual({ 23 | ops: [{ retain: 4 }, { insert: 'DEF', attributes: { weight: 'bold' } }], 24 | }) 25 | }) 26 | it('should not apply text attributes to newline characters', () => { 27 | const text1 = 'A\nBC' 28 | const text2 = 'A\nBCDEF\nGHI' 29 | expect(makeDiffDelta(text1, text2, { weight: 'bold' })).toEqual({ 30 | ops: [ 31 | { retain: 4 }, 32 | { insert: 'DEF', attributes: { weight: 'bold' } }, 33 | { insert: '\n' }, 34 | { insert: 'GHI', attributes: { weight: 'bold' } }, 35 | ], 36 | }) 37 | }) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /src/delta/attributes.ts: -------------------------------------------------------------------------------- 1 | import mergeAll from 'ramda/es/mergeAll' 2 | import reject from 'ramda/es/reject' 3 | import isNil from 'ramda/es/isNil' 4 | import omit from 'ramda/es/omit' 5 | 6 | /** 7 | * A set of definitions for {@link GenericOp} attributes. 8 | * 9 | * @public 10 | */ 11 | export declare namespace Attributes { 12 | /** 13 | * Possible values for a text transform. 14 | * 15 | * @public 16 | */ 17 | export type TextValue = boolean | string | number | null 18 | 19 | /** 20 | * An attribute value. 21 | * 22 | * @public 23 | */ 24 | export type GenericValue = object | TextValue | undefined 25 | 26 | /** 27 | * A set of attributes applying to a {@link GenericOp}. 28 | * 29 | * @public 30 | */ 31 | export interface Map { 32 | readonly [k: string]: GenericValue 33 | } 34 | 35 | /** 36 | * A special text attribute value applied to a whole line. 37 | * 38 | * @remarks 39 | * 40 | * There can be only one text line type attribute active at once. 41 | * 42 | * @public 43 | */ 44 | export type LineType = 'normal' | 'quoted' 45 | } 46 | 47 | const rejectNil = reject(isNil) 48 | 49 | /** 50 | * Create a new object with the own properties of the first object merged with the own properties of the second object and so on. 51 | * If a key exists in both objects, the value from the endmost object will be used. 52 | * 53 | * @remarks 54 | * 55 | * `null` values are removed from the returned object. 56 | * 57 | * @param attributes - the attributes object to merge 58 | */ 59 | export function mergeAttributesRight(...attributes: Attributes.Map[]): Attributes.Map { 60 | return rejectNil(mergeAll(attributes)) 61 | } 62 | 63 | export const getTextAttributes = omit(['$type']) 64 | -------------------------------------------------------------------------------- /src/delta/diff.ts: -------------------------------------------------------------------------------- 1 | import { diffChars } from 'diff' 2 | import Delta from 'quill-delta' 3 | import { Attributes } from './attributes' 4 | 5 | function getDeltasFromTextDiff(oldText: string, newText: string, attributes?: Attributes.Map) { 6 | const changes = diffChars(oldText, newText) 7 | let delta = new Delta() 8 | for (const change of changes) { 9 | if (change.added) { 10 | const lines: string[] = change.value.split('\n') 11 | delta = lines.reduce((d, line, i) => { 12 | let next = d.insert(line, attributes) 13 | if (i < lines.length - 1) { 14 | next = next.insert('\n') 15 | } 16 | return next 17 | }, delta) 18 | } else if (change.removed && change.count) { 19 | delta = delta.delete(change.value.length) 20 | } else if (change.count) { 21 | delta = delta.retain(change.value.length) 22 | } 23 | } 24 | return delta 25 | } 26 | 27 | export function makeDiffDelta(oldText: string, nuText: string, textAttributes: Attributes.Map): Delta { 28 | return getDeltasFromTextDiff(oldText, nuText, textAttributes) 29 | } 30 | -------------------------------------------------------------------------------- /src/delta/generic.ts: -------------------------------------------------------------------------------- 1 | import { GenericOp } from './operations' 2 | import Delta from 'quill-delta' 3 | import hasPath from 'ramda/es/hasPath' 4 | 5 | /** 6 | * A generic interface for instances describing rich content. 7 | * 8 | * @public 9 | */ 10 | export interface GenericRichContent { 11 | /** 12 | * An array of operations. 13 | */ 14 | readonly ops: GenericOp[] 15 | /** 16 | * @returns The length of the underlying rich text representation. 17 | * This length represents the number of cursor positions in the document. 18 | */ 19 | readonly length: () => number 20 | } 21 | 22 | export function extractTextFromDelta(delta: GenericRichContent): string { 23 | return delta.ops.reduce( 24 | (acc: string, curr: GenericOp) => (typeof curr.insert === 'string' ? acc + curr.insert : acc), 25 | '', 26 | ) 27 | } 28 | 29 | export function isGenericDelta(arg: unknown): arg is GenericRichContent { 30 | return arg && hasPath(['ops'], arg) 31 | } 32 | 33 | export function isMutatingDelta(delta: GenericRichContent): boolean { 34 | const iterator = Delta.Op.iterator(delta.ops) 35 | let shouldOverride = false 36 | while (iterator.hasNext()) { 37 | const next = iterator.next() 38 | if (!next.retain || next.attributes != null) { 39 | shouldOverride = true 40 | break 41 | } 42 | } 43 | return shouldOverride 44 | } 45 | -------------------------------------------------------------------------------- /src/delta/lines.ts: -------------------------------------------------------------------------------- 1 | import { Selection } from './Selection' 2 | import { Attributes } from './attributes' 3 | 4 | /** 5 | * An interface representing a line of text. 6 | * 7 | * @remarks 8 | * 9 | * Given `documentText` the string representing all characters of a document and `line` 10 | * any instance complying with this interface extracted from `documentText`, the following must 11 | * be true: 12 | * 13 | * ```ts 14 | * documentText.substring(line.lineRange.start, line.lineRange.end) === extractTextFromDelta(line.delta) 15 | * ``` 16 | * @internal 17 | */ 18 | export interface GenericLine { 19 | index: number 20 | lineRange: Selection 21 | } 22 | 23 | export function isLineInSelection(selection: Selection, { lineRange }: GenericLine) { 24 | const { start: beginningOfLineIndex, end: endOfLineIndex } = lineRange 25 | return ( 26 | (selection.start >= beginningOfLineIndex && selection.start <= endOfLineIndex) || 27 | (selection.start <= endOfLineIndex && selection.end >= beginningOfLineIndex) 28 | ) 29 | } 30 | 31 | export function getLineType(lineAttributes?: Attributes.Map): Attributes.LineType { 32 | return lineAttributes && lineAttributes.$type ? (lineAttributes.$type as Attributes.LineType) : 'normal' 33 | } 34 | -------------------------------------------------------------------------------- /src/delta/operations.ts: -------------------------------------------------------------------------------- 1 | import { Attributes } from './attributes' 2 | import Op from 'quill-delta/dist/Op' 3 | import reduce from 'ramda/es/reduce' 4 | import { Images } from '@core/Images' 5 | 6 | /** 7 | * An atomic operation representing changes to a document. 8 | * 9 | * @remarks 10 | * 11 | * This interface is a redefinition of {@link quilljs-delta#Op}. 12 | * 13 | * @public 14 | */ 15 | export interface GenericOp { 16 | /** 17 | * A representation of inserted content. 18 | */ 19 | readonly insert?: string | object 20 | /** 21 | * A delete operation. 22 | * 23 | * @internal 24 | */ 25 | readonly delete?: number 26 | /** 27 | * A retain operation 28 | * 29 | * @internal 30 | */ 31 | readonly retain?: number 32 | /** 33 | * A set of attributes describing properties of the content. 34 | */ 35 | readonly attributes?: Attributes.Map 36 | } 37 | 38 | /** 39 | * An operation referring to text. 40 | * 41 | * @public 42 | */ 43 | export interface TextOp extends GenericOp { 44 | /** 45 | * {@inheritdoc GenericOp.insert} 46 | */ 47 | readonly insert?: string 48 | /** 49 | * {@inheritdoc GenericOp.attributes} 50 | */ 51 | readonly attributes?: Attributes.Map 52 | } 53 | 54 | /** 55 | * A description of an image to be persisted in the document. 56 | * 57 | * @public 58 | */ 59 | export interface ImageKind extends Images.Description { 60 | kind: 'image' 61 | } 62 | 63 | /** 64 | * An operation referring to an image. 65 | * 66 | * @public 67 | */ 68 | export type ImageOp = BlockOp> 69 | 70 | /** 71 | * An operation referring to a block. 72 | * 73 | * @public 74 | */ 75 | export interface BlockOp extends GenericOp { 76 | /** 77 | * {@inheritdoc GenericOp.insert} 78 | */ 79 | readonly insert: T 80 | /** 81 | * {@inheritdoc GenericOp.attributes} 82 | */ 83 | readonly attributes?: Attributes.Map 84 | } 85 | 86 | export function isTextOp(op: GenericOp): op is TextOp { 87 | return typeof op.insert === 'string' 88 | } 89 | 90 | export const computeOpsLength = reduce((curr: number, prev: GenericOp) => Op.length(prev) + curr, 0 as number) 91 | 92 | export function buildTextOp(text: string, attributes?: Attributes.Map) { 93 | return attributes 94 | ? { 95 | insert: text, 96 | attributes, 97 | } 98 | : { insert: text } 99 | } 100 | 101 | export function buildImageOp(description: Images.Description): ImageOp { 102 | return { 103 | insert: { 104 | kind: 'image', 105 | ...description, 106 | }, 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/hooks/use-bridge.ts: -------------------------------------------------------------------------------- 1 | import { buildBridge, Bridge } from '@core/Bridge' 2 | import { Images } from '@core/Images' 3 | import { useMemo } from 'react' 4 | 5 | /** 6 | * React hook which returns a bridge. 7 | * 8 | * @remarks One bridge instance should exist for one document renderer instance. 9 | * @param deps - A list of values which should trigger, on change, the creation of a new {@link (Bridge:interface)} instance. 10 | * @public 11 | */ 12 | export function useBridge(deps: unknown[] = []): Bridge { 13 | // eslint-disable-next-line react-hooks/exhaustive-deps 14 | return useMemo(() => buildBridge(), deps) 15 | } 16 | -------------------------------------------------------------------------------- /src/hooks/use-document.ts: -------------------------------------------------------------------------------- 1 | import { Document, buildEmptyDocument } from '@model/document' 2 | import { useState } from 'react' 3 | 4 | /** 5 | * React hook to store and update the document. 6 | * 7 | * @remarks If you just need an initial document value, use {@link buildEmptyDocument} instead. 8 | * 9 | * @param initialDocument - The initial value. 10 | * @public 11 | */ 12 | export function useDocument(initialDocument: Document = buildEmptyDocument()) { 13 | return useState(initialDocument) 14 | } 15 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Typeskill, the Operational-Transform Based (React) Native Rich Text library. 4 | * 5 | * @remarks 6 | * 7 | * **Introduction** 8 | * 9 | * The library exposes: 10 | * 11 | * - The {@link (Typer:class)} component, a support for editing {@link (Document:type)}; 12 | * - The {@link (Print:type)} component, a display for {@link (Document:type)}; 13 | * - The {@link (Toolbar:class)} component, which permits text transforms on current selection. 14 | * 15 | * **Controlled components** 16 | * 17 | * {@link (Typer:class)} and {@link (Print:type)} components are [controlled components](https://reactjs.org/docs/forms.html#controlled-components). 18 | * You need to pass them a {@link Document | `document`} prop which you can initialize with {@link buildEmptyDocument}. 19 | * 20 | * **Triggering actions from external controls** 21 | * 22 | * A {@link (Bridge:interface)} instance must be shared between a {@link (Typer:class)} and any control component such as {@link (Toolbar:class)}. 23 | * The {@link (Bridge:interface)} instance can be instantiated with {@link buildBridge}. 24 | * Actions can be triggered with the help of the object returned by {@link (Bridge:interface).getControlEventDomain}. 25 | * 26 | * Such actions include: 27 | * 28 | * - inserting media content; 29 | * - (un)setting text attributes (bold, italic). 30 | * 31 | * Selection change events can also be listened to with `add...Listener` methods. 32 | * {@link (Bridge:interface).release} must be call from the component holding a reference to the {@link (Bridge:interface)} instance, 33 | * during `componentWillUnmount` hook. 34 | * 35 | * @packageDocumentation 36 | */ 37 | 38 | /* Exported values */ 39 | export { Typer } from '@components/Typer' 40 | export { Print } from '@components/Print' 41 | export { Bridge, buildBridge } from '@core/Bridge' 42 | export { Toolbar, DocumentControlAction, CONTROL_SEPARATOR, buildVectorIconControlSpec } from '@components/Toolbar' 43 | export { useBridge } from '@hooks/use-bridge' 44 | export { useDocument } from '@hooks/use-document' 45 | export { Transforms, defaultTextTransforms } from '@core/Transforms' 46 | 47 | /* Exported types and interfaces */ 48 | export { GenericControlAction } from '@components/Toolbar' 49 | export { Attributes } from '@delta/attributes' 50 | export { GenericRichContent } from '@delta/generic' 51 | export { GenericOp, TextOp, ImageOp, BlockOp, ImageKind } from '@delta/operations' 52 | export { Document, buildEmptyDocument, cloneDocument } from '@model/document' 53 | export { SelectionShape } from '@delta/Selection' 54 | export { Images } from '@core/Images' 55 | export { DocumentRendererProps } from '@components/DocumentRenderer' 56 | export { FocusableInput } from '@components/GenericBlockInput' 57 | -------------------------------------------------------------------------------- /src/model/Block.ts: -------------------------------------------------------------------------------- 1 | import { BlockDescriptor } from './blocks' 2 | import { Document, applyTextTransformToSelection, buildEmptyDocument } from './document' 3 | import { SelectionShape, Selection } from '@delta/Selection' 4 | import Delta from 'quill-delta' 5 | import { ImageKind, GenericOp } from '@delta/operations' 6 | import { DocumentDelta } from '@delta/DocumentDelta' 7 | import { Attributes } from '@delta/attributes' 8 | import { Bridge } from '@core/Bridge' 9 | import { DocumentDeltaAtomicUpdate } from '@delta/DocumentDeltaAtomicUpdate' 10 | 11 | function elementToInsertion( 12 | element: Bridge.Element, 13 | document: Document, 14 | ): [ImageKind | string, Attributes.Map?] { 15 | if (element.type === 'text') { 16 | return [element.content, document.selectedTextAttributes] 17 | } 18 | const imageOpIns: ImageKind = { kind: 'image', ...element.description } 19 | return [imageOpIns] 20 | } 21 | 22 | function getSelectionAfterTransform(diff: Delta, document: Document): SelectionShape { 23 | const nextPosition = diff.transformPosition(document.currentSelection.start) 24 | return { 25 | start: nextPosition, 26 | end: nextPosition, 27 | } 28 | } 29 | 30 | // TODO handle cursor move attributes 31 | 32 | export class Block { 33 | public readonly descriptor: BlockDescriptor 34 | private blocks: Block[] 35 | 36 | public get kind() { 37 | return this.descriptor.kind 38 | } 39 | 40 | public constructor(descriptor: BlockDescriptor, blocks: Block[]) { 41 | this.descriptor = descriptor 42 | this.blocks = blocks 43 | } 44 | 45 | public isFirst(): boolean { 46 | return this.descriptor.blockIndex === 0 47 | } 48 | 49 | public isLast(): boolean { 50 | return this.descriptor.blockIndex === this.descriptor.maxBlockIndex 51 | } 52 | 53 | private applyCursorTranslationToDocument(position: number, document: Document): Document { 54 | const nextSelection: SelectionShape = { 55 | start: position, 56 | end: position, 57 | } 58 | return { 59 | ...document, 60 | currentSelection: nextSelection, 61 | } 62 | } 63 | 64 | private applyDiffToDocument(lastDiff: Delta, document: Document): Document { 65 | const current = new Delta(document.ops) 66 | const nextDelta = current.compose(lastDiff) 67 | const nextOps = nextDelta.ops 68 | const nextSelection = getSelectionAfterTransform(lastDiff, document) 69 | return { 70 | ...document, 71 | currentSelection: nextSelection, 72 | ops: nextOps, 73 | lastDiff: lastDiff.ops, 74 | } 75 | } 76 | 77 | /** 78 | * Mutate block-scoped ops in the document. 79 | * 80 | * @param blockScopedDiff - The diff delta to apply to current block. 81 | * @param document - The document. 82 | * 83 | * @returns The resulting document ops. 84 | */ 85 | private applyBlockScopedDiff(blockScopedDiff: Delta, document: Document): Document { 86 | return this.applyDiffToDocument( 87 | new Delta().retain(this.descriptor.selectableUnitsOffset).concat(blockScopedDiff), 88 | document, 89 | ) 90 | } 91 | 92 | private getPreviousBlock(): Block | null { 93 | if (this.isFirst()) { 94 | return null 95 | } 96 | return this.blocks[this.descriptor.blockIndex - 1] 97 | } 98 | 99 | private getNextBlock(): Block | null { 100 | if (this.isLast()) { 101 | return null 102 | } 103 | return this.blocks[this.descriptor.blockIndex + 1] 104 | } 105 | 106 | public getDocumentSelection(blockScopedSelection: SelectionShape): SelectionShape { 107 | return { 108 | start: blockScopedSelection.start + this.descriptor.selectableUnitsOffset, 109 | end: blockScopedSelection.end + this.descriptor.selectableUnitsOffset, 110 | } 111 | } 112 | 113 | public getBlockScopedSelection(documentSelection: SelectionShape): SelectionShape | null { 114 | const start = documentSelection.start - this.descriptor.selectableUnitsOffset 115 | const end = documentSelection.end - this.descriptor.selectableUnitsOffset 116 | if (start < 0 || end > this.descriptor.numOfSelectableUnits) { 117 | return null 118 | } 119 | return { 120 | start, 121 | end, 122 | } 123 | } 124 | 125 | public getSelectedOps(document: Document): GenericOp[] { 126 | const delta = new DocumentDelta(document.ops) 127 | return delta.getSelected(Selection.fromShape(document.currentSelection)).ops 128 | } 129 | 130 | private shouldFocusOnLeftEdge() { 131 | return this.kind === 'text' || this.descriptor.blockIndex === 0 132 | } 133 | 134 | public isFocused({ currentSelection }: Document): boolean { 135 | const { selectableUnitsOffset, numOfSelectableUnits } = this.descriptor 136 | const lowerBoundary = selectableUnitsOffset 137 | const upperBoundary = selectableUnitsOffset + numOfSelectableUnits 138 | const nextBlock = this.getNextBlock() 139 | const isCursor = currentSelection.end - currentSelection.start === 0 140 | const isCursorTouchingRightEdge = isCursor && currentSelection.end === upperBoundary 141 | const isCursorTouchingLeftEdge = isCursor && currentSelection.start === lowerBoundary 142 | if (isCursorTouchingRightEdge) { 143 | return nextBlock == null || !nextBlock.shouldFocusOnLeftEdge() 144 | } 145 | if (isCursorTouchingLeftEdge) { 146 | return this.shouldFocusOnLeftEdge() 147 | } 148 | return ( 149 | currentSelection.start >= lowerBoundary && 150 | currentSelection.start <= upperBoundary && 151 | currentSelection.end <= upperBoundary 152 | ) 153 | } 154 | 155 | public isEntirelySelected({ currentSelection: { start, end } }: Document) { 156 | return ( 157 | start === this.descriptor.selectableUnitsOffset && 158 | end === this.descriptor.selectableUnitsOffset + this.descriptor.numOfSelectableUnits 159 | ) 160 | } 161 | 162 | public updateTextAttributesAtSelection(document: Document): Document { 163 | const docDelta = new DocumentDelta(document.ops) 164 | const deltaAttributes = docDelta.getSelectedTextAttributes(Selection.fromShape(document.currentSelection)) 165 | return { 166 | ...document, 167 | selectedTextAttributes: deltaAttributes, 168 | } 169 | } 170 | 171 | public applyAtomicDeltaUpdate( 172 | { diff, selectionAfterChange }: DocumentDeltaAtomicUpdate, 173 | document: Document, 174 | ): Document { 175 | return { 176 | ...this.applyBlockScopedDiff(diff, document), 177 | currentSelection: this.getDocumentSelection(selectionAfterChange), 178 | } 179 | } 180 | 181 | public applyTextTransformToSelection( 182 | attributeName: string, 183 | attributeValue: Attributes.GenericValue, 184 | document: Document, 185 | ): Document { 186 | if (this.kind !== 'text') { 187 | return document 188 | } 189 | return { 190 | ...document, 191 | ...applyTextTransformToSelection(attributeName, attributeValue, document), 192 | } 193 | } 194 | 195 | /** 196 | * Insert element at selection. 197 | * 198 | * @remarks If selection is of length 1+, replace the selectable units encompassed by selection. 199 | * 200 | * @param element - The element to be inserted. 201 | * @param document - The document. 202 | * 203 | * @returns The resulting document. 204 | */ 205 | public insertOrReplaceAtSelection(element: Bridge.Element, document: Document): Document { 206 | const deletionLength = document.currentSelection.end - document.currentSelection.start 207 | const diff = new Delta() 208 | .retain(document.currentSelection.start) 209 | .delete(deletionLength) 210 | .insert(...elementToInsertion(element, document)) 211 | return this.applyDiffToDocument(diff, document) 212 | } 213 | 214 | /** 215 | * Remove this block. If this block is the first block, replace with default text block. 216 | * 217 | * @param document - The document to which it should apply. 218 | */ 219 | public remove(document: Document): Document { 220 | if (this.isFirst() && this.isLast()) { 221 | return buildEmptyDocument() 222 | } 223 | const diff = new Delta().retain(this.descriptor.selectableUnitsOffset).delete(this.descriptor.numOfSelectableUnits) 224 | return this.applyDiffToDocument(diff, document) 225 | } 226 | 227 | public updateSelection(blockScopedSelection: SelectionShape, document: Document): Document { 228 | const nextSelection = { 229 | start: this.descriptor.selectableUnitsOffset + blockScopedSelection.start, 230 | end: this.descriptor.selectableUnitsOffset + blockScopedSelection.end, 231 | } 232 | return { 233 | ...document, 234 | currentSelection: nextSelection, 235 | } 236 | } 237 | 238 | /** 239 | * Select the whole block. 240 | * 241 | * @param document - The document to which the mutation should apply. 242 | * 243 | * @returns The resulting document. 244 | */ 245 | public select(document: Document): Document { 246 | const nextSelection = { 247 | start: this.descriptor.selectableUnitsOffset, 248 | end: this.descriptor.selectableUnitsOffset + this.descriptor.numOfSelectableUnits, 249 | } 250 | return { 251 | ...document, 252 | currentSelection: nextSelection, 253 | } 254 | } 255 | 256 | /** 257 | * Remove one selectable unit before cursor. 258 | * 259 | * @param document The document to which the mutation should apply. 260 | * 261 | * @returns The resulting document. 262 | */ 263 | public removeOneBefore(document: Document): Document { 264 | if (this.isFirst()) { 265 | return document 266 | } 267 | const diff = new Delta().retain(this.descriptor.selectableUnitsOffset - 1).delete(1) 268 | const prevBlock = this.getPreviousBlock() as Block 269 | if (prevBlock.kind === 'image') { 270 | return prevBlock.select(document) 271 | } 272 | return this.applyDiffToDocument(diff, document) 273 | } 274 | 275 | public moveBefore(document: Document): Document { 276 | if (this.isFirst()) { 277 | return document 278 | } 279 | const positionBeforeBlock = this.descriptor.selectableUnitsOffset - 1 280 | return this.applyCursorTranslationToDocument(positionBeforeBlock, document) 281 | } 282 | 283 | public moveAfter(document: Document): Document { 284 | if (this.isLast()) { 285 | return document 286 | } 287 | const positionAfterBlock = this.descriptor.selectableUnitsOffset + this.descriptor.numOfSelectableUnits 288 | return this.applyCursorTranslationToDocument(positionAfterBlock, document) 289 | } 290 | } 291 | -------------------------------------------------------------------------------- /src/model/BlockAssembler.ts: -------------------------------------------------------------------------------- 1 | import { Block } from './Block' 2 | import { groupOpsByBlocks } from './blocks' 3 | import { Document } from './document' 4 | import { Bridge } from '@core/Bridge' 5 | import { Attributes } from '@delta/attributes' 6 | import { DocumentDelta } from '@delta/DocumentDelta' 7 | import { SelectionShape } from '@delta/Selection' 8 | 9 | /** 10 | * An object to manipulate blocks. 11 | */ 12 | export class BlockAssembler { 13 | private blocks: Block[] 14 | private document: Document 15 | public constructor(document: Document) { 16 | this.document = document 17 | this.blocks = groupOpsByBlocks(document.ops) 18 | } 19 | 20 | private getActiveBlock(): Block | null { 21 | for (const block of this.blocks) { 22 | if (block.isFocused(this.document)) { 23 | return block 24 | } 25 | } 26 | return null 27 | } 28 | 29 | public getBlocks(): Block[] { 30 | return this.blocks 31 | } 32 | 33 | public insertOrReplaceAtSelection(element: Bridge.Element): Document { 34 | const activeBlock = this.getActiveBlock() as Block 35 | return activeBlock.insertOrReplaceAtSelection(element, this.document) 36 | } 37 | 38 | public updateTextAttributesAtSelection(): Document { 39 | const document = this.document 40 | const docDelta = new DocumentDelta(document.ops) 41 | const deltaAttributes = docDelta.getSelectedTextAttributes(document.currentSelection) 42 | return { 43 | ...document, 44 | selectedTextAttributes: deltaAttributes, 45 | } 46 | } 47 | 48 | public applyTextTransformToSelection(attributeName: string, attributeValue: Attributes.GenericValue): Document { 49 | const activeBlock = this.getActiveBlock() as Block 50 | return activeBlock.applyTextTransformToSelection(attributeName, attributeValue, this.document) 51 | } 52 | 53 | public getActiveBlockScopedSelection(): SelectionShape { 54 | const activeBlock = this.getActiveBlock() as Block 55 | return activeBlock.getBlockScopedSelection(this.document.currentSelection) as SelectionShape 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/model/__tests__/Block-test.ts: -------------------------------------------------------------------------------- 1 | import { buildEmptyDocument, Document } from '@model/document' 2 | import { buildTextOp, GenericOp } from '@delta/operations' 3 | import { groupOpsByBlocks } from '@model/blocks' 4 | import { buildDummyImageOp } from '@test/document' 5 | 6 | describe('@model/Block', () => { 7 | function buildDocContentWithSel(start: number, end: number, ops?: GenericOp[]): Document { 8 | const obj = { 9 | ...buildEmptyDocument(), 10 | currentSelection: { start, end }, 11 | } 12 | return ops ? { ...obj, ops } : obj 13 | } 14 | function createContext(start: number, end: number, ops: GenericOp[]) { 15 | const doc: Document = buildDocContentWithSel(start, end, ops) 16 | const blocks = groupOpsByBlocks(ops) 17 | return { 18 | doc, 19 | blocks, 20 | } 21 | } 22 | describe('isFocused', () => { 23 | it('should return true when active area matches block', () => { 24 | const { blocks, doc } = createContext(0, 3, [buildTextOp('Hel')]) 25 | expect(blocks.length).toBe(1) 26 | const block = blocks[0] 27 | expect(block.isFocused(doc)).toBe(true) 28 | }) 29 | it('should return false when active area overflows block', () => { 30 | const { blocks, doc } = createContext(0, 4, [buildTextOp('Hel')]) 31 | expect(blocks.length).toBe(1) 32 | const block = blocks[0] 33 | expect(block.isFocused(doc)).toBe(false) 34 | }) 35 | it('should return false when active area is outside of block', () => { 36 | const { blocks, doc } = createContext(3, 4, [buildTextOp('Hel')]) 37 | expect(blocks.length).toBe(1) 38 | const block = blocks[0] 39 | expect(block.isFocused(doc)).toBe(false) 40 | }) 41 | it('should return false when selection is of length 0, touching the end of this block and the next block', () => { 42 | const ops = [buildDummyImageOp(), buildTextOp('Hel\n')] 43 | const { blocks, doc } = createContext(1, 1, ops) 44 | expect(blocks.length).toBe(2) 45 | const textBlock = blocks[1] 46 | const imageBlock = blocks[0] 47 | expect(textBlock.isFocused(doc)).toBe(true) 48 | expect(imageBlock.isFocused(doc)).toBe(false) 49 | }) 50 | }) 51 | describe('updateTextAttributesAtSelection', () => { 52 | it('should update text attributes', () => { 53 | const ops = [buildTextOp('Y', { bold: true }), buildTextOp('\n')] 54 | const { blocks, doc } = createContext(0, 1, ops) 55 | expect(blocks.length).toBe(1) 56 | const textBlock = blocks[0] 57 | expect(textBlock.updateTextAttributesAtSelection(doc).selectedTextAttributes).toMatchObject({ 58 | bold: true, 59 | }) 60 | }) 61 | }) 62 | describe('applyTextTransformToSelection', () => { 63 | it('should apply transform to a selection encompassing a text block', () => { 64 | const ops = [buildTextOp('Lol\n')] 65 | const { blocks, doc } = createContext(0, 3, ops) 66 | expect(blocks.length).toBe(1) 67 | const textBlock = blocks[0] 68 | expect(textBlock.applyTextTransformToSelection('bold', true, doc).ops).toMatchObject([ 69 | buildTextOp('Lol', { bold: true }), 70 | buildTextOp('\n'), 71 | ]) 72 | }) 73 | it('should not apply transform to a selection encompassing a non-text block', () => { 74 | const ops = [buildTextOp('Lol'), buildDummyImageOp(), buildTextOp('\n')] 75 | const { blocks, doc } = createContext(3, 4, ops) 76 | expect(blocks.length).toBe(3) 77 | const imageBlock = blocks[1] 78 | expect(imageBlock.applyTextTransformToSelection('bold', true, doc).ops).toMatchObject(ops) 79 | }) 80 | }) 81 | describe('getScopedSelection', () => { 82 | it('should return a selection which is relative to the block coordinates', () => { 83 | const ops = [buildTextOp('Lol'), buildDummyImageOp(), buildTextOp('\n')] 84 | const { blocks, doc } = createContext(3, 4, ops) 85 | expect(blocks.length).toBe(3) 86 | const imageBlock = blocks[1] 87 | expect(imageBlock.getBlockScopedSelection(doc.currentSelection)).toMatchObject({ start: 0, end: 1 }) 88 | }) 89 | }) 90 | describe('getSelectedOps', () => { 91 | it('should return ops which are selected in document', () => { 92 | const ops = [buildTextOp('Lol'), buildDummyImageOp(), buildTextOp('\n')] 93 | const { blocks, doc } = createContext(3, 4, ops) 94 | expect(blocks.length).toBe(3) 95 | const imageBlock = blocks[1] 96 | expect(imageBlock.getSelectedOps(doc)).toMatchObject([buildDummyImageOp()]) 97 | }) 98 | }) 99 | describe('isEntirelySelected', () => { 100 | it('should return true when the current selection exactly matches the block boundaries', () => { 101 | const ops = [buildTextOp('Lol'), buildDummyImageOp(), buildTextOp('\n')] 102 | const { blocks, doc } = createContext(3, 4, ops) 103 | expect(blocks.length).toBe(3) 104 | const imageBlock = blocks[1] 105 | expect(imageBlock.isEntirelySelected(doc)).toBe(true) 106 | }) 107 | it('should return false when the current selection partly matches the block boundaries', () => { 108 | const ops = [buildTextOp('Lol'), buildDummyImageOp(), buildTextOp('\n')] 109 | const { blocks, doc } = createContext(4, 4, ops) 110 | expect(blocks.length).toBe(3) 111 | const imageBlock = blocks[1] 112 | expect(imageBlock.isEntirelySelected(doc)).toBe(false) 113 | }) 114 | }) 115 | describe('insertOrReplaceAtSelection', () => { 116 | it('should replace when selection length is more then 0', () => { 117 | const ops = [buildTextOp('Lol'), buildDummyImageOp(), buildTextOp('\n')] 118 | const { blocks, doc } = createContext(3, 4, ops) 119 | expect(blocks.length).toBe(3) 120 | const imageBlock = blocks[1] 121 | expect(imageBlock.insertOrReplaceAtSelection({ type: 'text', content: 'L' }, doc).ops).toMatchObject([ 122 | buildTextOp('LolL\n'), 123 | ]) 124 | }) 125 | }) 126 | describe('remove', () => { 127 | it('should remove the whole block when not the first block', () => { 128 | const ops = [buildTextOp('Lol'), buildDummyImageOp(), buildTextOp('\n')] 129 | const { blocks, doc } = createContext(3, 4, ops) 130 | expect(blocks.length).toBe(3) 131 | const imageBlock = blocks[1] 132 | expect(imageBlock.remove(doc).ops).toMatchObject([buildTextOp('Lol\n')]) 133 | }) 134 | it('should replace the block with a default block when the only block', () => { 135 | const ops = [buildTextOp('L\n')] 136 | const { blocks, doc } = createContext(0, 2, ops) 137 | expect(blocks.length).toBe(1) 138 | const textBlock = blocks[0] 139 | expect(textBlock.remove(doc).ops).toMatchObject([buildTextOp('\n')]) 140 | }) 141 | }) 142 | }) 143 | -------------------------------------------------------------------------------- /src/model/__tests__/BlockAssembler-test.ts: -------------------------------------------------------------------------------- 1 | import { BlockAssembler } from '@model/BlockAssembler' 2 | import { buildEmptyDocument, Document } from '@model/document' 3 | import { buildTextOp } from '@delta/operations' 4 | import { buildDummyImageOp } from '@test/document' 5 | 6 | describe('@model/Document', () => { 7 | describe('updateTextAttributesAtSelection', () => { 8 | it('should not update attributes when selection matches a non-text block', () => { 9 | const document: Document = { 10 | ...buildEmptyDocument(), 11 | ops: [buildTextOp('L'), buildDummyImageOp()], 12 | currentSelection: { 13 | start: 2, 14 | end: 2, 15 | }, 16 | } 17 | const assembler = new BlockAssembler(document) 18 | expect(assembler.updateTextAttributesAtSelection().selectedTextAttributes).toMatchObject({}) 19 | }) 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /src/model/__tests__/blocks-test.ts: -------------------------------------------------------------------------------- 1 | import { groupOpsByBlocks, BlockDescriptor } from '@model/blocks' 2 | import { buildTextOp } from '@delta/operations' 3 | import prop from 'ramda/es/prop' 4 | import { buildDummyImageOp } from '@test/document' 5 | 6 | describe('@model/blocks', () => { 7 | describe('groupOpsByBlocks', () => { 8 | it('should group text ops together', () => { 9 | const blocks = groupOpsByBlocks([buildTextOp('Hello'), buildTextOp('Great')]) 10 | expect(blocks.length).toBe(1) 11 | const expectedDesc: BlockDescriptor = { 12 | blockIndex: 0, 13 | kind: 'text', 14 | startSliceIndex: 0, 15 | endSliceIndex: 2, 16 | opsSlice: [buildTextOp('Hello'), buildTextOp('Great')], 17 | numOfSelectableUnits: 10, 18 | selectableUnitsOffset: 0, 19 | maxBlockIndex: 1, 20 | } 21 | expect(blocks[0].descriptor).toMatchObject(expectedDesc) 22 | }) 23 | it('should split groups of different kind', () => { 24 | const helloOp = buildTextOp('Hello') 25 | const greatOp = buildTextOp('Great') 26 | const imgOp = buildDummyImageOp() 27 | const blocks = groupOpsByBlocks([helloOp, greatOp, imgOp]) 28 | expect(blocks.length).toBe(2) 29 | expect(blocks.map(prop('descriptor'))).toMatchObject([ 30 | { 31 | blockIndex: 0, 32 | kind: 'text', 33 | startSliceIndex: 0, 34 | endSliceIndex: 2, 35 | opsSlice: [helloOp, greatOp], 36 | numOfSelectableUnits: 10, 37 | selectableUnitsOffset: 0, 38 | }, 39 | { 40 | blockIndex: 1, 41 | kind: 'image', 42 | startSliceIndex: 2, 43 | endSliceIndex: 3, 44 | opsSlice: [imgOp], 45 | numOfSelectableUnits: 1, 46 | selectableUnitsOffset: 10, 47 | }, 48 | ] as BlockDescriptor[]) 49 | }) 50 | it('should handle empty ops', () => { 51 | const blocks = groupOpsByBlocks([buildTextOp('')]) 52 | expect(blocks.length).toBe(1) 53 | expect(blocks.map(prop('descriptor'))).toMatchObject([ 54 | { 55 | blockIndex: 0, 56 | kind: 'text', 57 | startSliceIndex: 0, 58 | endSliceIndex: 1, 59 | opsSlice: [buildTextOp('')], 60 | numOfSelectableUnits: 0, 61 | selectableUnitsOffset: 0, 62 | }, 63 | ] as BlockDescriptor[]) 64 | }) 65 | it('should create a new group for each sibling image', () => { 66 | const blocks = groupOpsByBlocks([buildDummyImageOp('A'), buildDummyImageOp('B')]) 67 | expect(blocks.length).toBe(2) 68 | expect(blocks.map(prop('descriptor'))).toMatchObject([ 69 | { 70 | blockIndex: 0, 71 | kind: 'image', 72 | startSliceIndex: 0, 73 | endSliceIndex: 1, 74 | opsSlice: [buildDummyImageOp('A')], 75 | numOfSelectableUnits: 1, 76 | selectableUnitsOffset: 0, 77 | }, 78 | { 79 | blockIndex: 1, 80 | kind: 'image', 81 | startSliceIndex: 1, 82 | endSliceIndex: 2, 83 | opsSlice: [buildDummyImageOp('B')], 84 | numOfSelectableUnits: 1, 85 | selectableUnitsOffset: 1, 86 | }, 87 | ] as BlockDescriptor[]) 88 | }) 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /src/model/__tests__/document-test.ts: -------------------------------------------------------------------------------- 1 | import { Document, applyTextTransformToSelection } from '@model/document' 2 | import { Selection } from '@delta/Selection' 3 | import mergeLeft from 'ramda/es/mergeLeft' 4 | 5 | describe('@model/document', () => { 6 | describe('applyTextTransformToSelection', () => { 7 | it('applying text attributes to empty selection should result in cursor attributes matching these attributes', () => { 8 | const document: Document = { 9 | currentSelection: Selection.fromBounds(1), 10 | ops: [{ insert: 'F' }], 11 | lastDiff: [], 12 | selectedTextAttributes: {}, 13 | schemaVersion: 1, 14 | } 15 | const diff = applyTextTransformToSelection('weight', 'bold', document) 16 | expect(diff.selectedTextAttributes).toMatchObject({ 17 | weight: 'bold', 18 | }) 19 | expect(diff.selectedTextAttributes).toMatchObject({ 20 | weight: 'bold', 21 | }) 22 | }) 23 | it('successively applying text attributes to empty selection should result in the merging of those textAttributesAtCursor', () => { 24 | const documentContent1: Document = { 25 | currentSelection: Selection.fromBounds(1), 26 | ops: [{ insert: 'F' }], 27 | lastDiff: [], 28 | selectedTextAttributes: {}, 29 | schemaVersion: 1, 30 | } 31 | const diff1 = applyTextTransformToSelection('weight', 'bold', documentContent1) 32 | const documentContent2 = mergeLeft(diff1, documentContent1) 33 | const diff2 = applyTextTransformToSelection('italic', true, documentContent2) 34 | expect(diff2.selectedTextAttributes).toMatchObject({ 35 | weight: 'bold', 36 | italic: true, 37 | }) 38 | }) 39 | it('setting cursor attributes should apply to inserted text', () => { 40 | const documentContent1: Document = { 41 | currentSelection: Selection.fromBounds(1, 2), 42 | ops: [{ insert: 'FP\n' }], 43 | lastDiff: [], 44 | selectedTextAttributes: {}, 45 | schemaVersion: 1, 46 | } 47 | const diff = applyTextTransformToSelection('weight', 'bold', documentContent1) 48 | expect(diff).toMatchObject({ 49 | ops: [{ insert: 'F' }, { insert: 'P', attributes: { weight: 'bold' } }, { insert: '\n' }], 50 | selectedTextAttributes: { weight: 'bold' }, 51 | }) 52 | }) 53 | it('unsetting cursor attributes should propagate to inserted text', () => { 54 | const documentContent1: Document = { 55 | currentSelection: Selection.fromBounds(1, 2), 56 | ops: [{ insert: 'F' }, { insert: 'P', attributes: { weight: 'bold' } }, { insert: '\n' }], 57 | selectedTextAttributes: { weight: 'bold' }, 58 | lastDiff: [], 59 | schemaVersion: 1, 60 | } 61 | const diff = applyTextTransformToSelection('weight', null, documentContent1) 62 | expect(diff).toMatchObject({ 63 | ops: [{ insert: 'FP\n' }], 64 | selectedTextAttributes: { 65 | weight: null, 66 | }, 67 | }) 68 | }) 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /src/model/blocks.ts: -------------------------------------------------------------------------------- 1 | import { GenericOp } from '@delta/operations' 2 | import last from 'ramda/es/last' 3 | import Op from 'quill-delta/dist/Op' 4 | import { Block } from './Block' 5 | 6 | export type BlockType = 'image' | 'text' 7 | 8 | export interface BlockDescriptor { 9 | /** 10 | * Inclusive begining of Op slice index. 11 | */ 12 | startSliceIndex: number 13 | /** 14 | * Exclusive end of Op slice index. 15 | */ 16 | endSliceIndex: number 17 | /** 18 | * The offset to apply to selection. 19 | */ 20 | selectableUnitsOffset: number 21 | /** 22 | * The number of selectable units. 23 | */ 24 | numOfSelectableUnits: number 25 | blockIndex: number 26 | maxBlockIndex: number 27 | kind: BlockType 28 | opsSlice: GenericOp[] 29 | } 30 | 31 | function opsToBlocks(blocks: Block[], currentValue: GenericOp, i: number, l: GenericOp[]): Block[] { 32 | if (currentValue.insert === undefined) { 33 | return blocks 34 | } 35 | const kind: BlockType = typeof currentValue.insert === 'string' ? 'text' : 'image' 36 | let lastGroup: Block = last(blocks) as Block 37 | const isFirstGroup = !lastGroup 38 | if (isFirstGroup) { 39 | lastGroup = new Block( 40 | { 41 | kind, 42 | opsSlice: [], 43 | startSliceIndex: 0, 44 | endSliceIndex: 0, 45 | numOfSelectableUnits: 0, 46 | selectableUnitsOffset: 0, 47 | blockIndex: 0, 48 | maxBlockIndex: l.length - 1, 49 | }, 50 | blocks, 51 | ) 52 | blocks.push(lastGroup) 53 | } 54 | const lastBlockDesc = lastGroup.descriptor 55 | if (lastBlockDesc.kind !== kind || (kind === 'image' && !isFirstGroup)) { 56 | const kindOps = [currentValue] 57 | const newGroup: Block = new Block( 58 | { 59 | kind, 60 | opsSlice: kindOps, 61 | numOfSelectableUnits: Op.length(currentValue), 62 | startSliceIndex: lastBlockDesc.endSliceIndex, 63 | endSliceIndex: lastBlockDesc.endSliceIndex + 1, 64 | blockIndex: lastBlockDesc.blockIndex + 1, 65 | selectableUnitsOffset: lastBlockDesc.numOfSelectableUnits + lastBlockDesc.selectableUnitsOffset, 66 | maxBlockIndex: l.length - 1, 67 | }, 68 | blocks, 69 | ) 70 | blocks.push(newGroup) 71 | } else { 72 | lastBlockDesc.opsSlice.push(currentValue) 73 | lastBlockDesc.numOfSelectableUnits += Op.length(currentValue) 74 | lastBlockDesc.endSliceIndex += 1 75 | } 76 | return blocks 77 | } 78 | 79 | export function groupOpsByBlocks(ops: GenericOp[]): Block[] { 80 | return ops.reduce(opsToBlocks, []) 81 | } 82 | -------------------------------------------------------------------------------- /src/model/document.ts: -------------------------------------------------------------------------------- 1 | import { Attributes } from '@delta/attributes' 2 | import { SelectionShape, Selection } from '@delta/Selection' 3 | import { GenericOp } from '@delta/operations' 4 | import clone from 'ramda/es/clone' 5 | import { DocumentDelta } from '@delta/DocumentDelta' 6 | import mergeLeft from 'ramda/es/mergeLeft' 7 | 8 | /** 9 | * A serializable object representing rich content. 10 | * 11 | * @public 12 | */ 13 | export interface Document { 14 | /** 15 | * A list of operations as per deltajs definition. 16 | */ 17 | readonly ops: GenericOp[] 18 | /** 19 | * A contiguous range of selectable items. 20 | */ 21 | readonly currentSelection: SelectionShape 22 | /** 23 | * The attributes encompassed by {@link Document.currentSelection} or the attributes at cursor. 24 | * `null` values represent attributes to be removed. 25 | */ 26 | readonly selectedTextAttributes: Attributes.Map 27 | /** 28 | * The diff ops which were used to produce current ops by combining previous ops. 29 | */ 30 | readonly lastDiff: GenericOp[] 31 | /** 32 | * The document shape versionning. 33 | * 34 | * @remarks This attribute might only change between major releases, and is intended to very rarely change. 35 | * It is also guaranteed that if there were any, the library would offer tools to handle schema migrations. 36 | * 37 | */ 38 | readonly schemaVersion: number 39 | } 40 | 41 | /** 42 | * An async callback aimed at updating the document. 43 | * 44 | * @param nextDocument - The next document. 45 | * 46 | * @public 47 | */ 48 | export type DocumentUpdater = (nextDocument: Document) => Promise 49 | 50 | /** 51 | * Build an empty document. 52 | * 53 | * @public 54 | */ 55 | export function buildEmptyDocument(): Document { 56 | return { 57 | currentSelection: { start: 0, end: 0 }, 58 | ops: [{ insert: '\n' }], 59 | selectedTextAttributes: {}, 60 | lastDiff: [], 61 | schemaVersion: 1, 62 | } 63 | } 64 | 65 | /** 66 | * Clone a peace of {@link Document | document}. 67 | * 68 | * @param content - The content to clone 69 | * 70 | * @public 71 | */ 72 | export function cloneDocument(content: Document): Document { 73 | return { 74 | ops: clone(content.ops), 75 | currentSelection: content.currentSelection, 76 | selectedTextAttributes: content.selectedTextAttributes, 77 | lastDiff: content.lastDiff, 78 | schemaVersion: content.schemaVersion, 79 | } 80 | } 81 | 82 | export function applyTextTransformToSelection( 83 | attributeName: string, 84 | attributeValue: Attributes.GenericValue, 85 | document: Document, 86 | ): Pick { 87 | const { currentSelection, ops, selectedTextAttributes } = document 88 | const delta = new DocumentDelta(ops) 89 | const selection = Selection.fromShape(currentSelection) 90 | // Apply transforms to selection range 91 | const userAttributes = { [attributeName]: attributeValue } 92 | const atomicUpdate = delta.applyTextTransformToSelection(selection, attributeName, attributeValue) 93 | const nextSelectedAttributes = mergeLeft(userAttributes, selectedTextAttributes) 94 | return { 95 | selectedTextAttributes: nextSelectedAttributes, 96 | ops: atomicUpdate.delta.ops, 97 | lastDiff: atomicUpdate.diff.ops, 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/test/delta.ts: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant' 2 | import { DeltaChangeContext } from '@delta/DeltaChangeContext' 3 | import { Selection } from '@delta/Selection' 4 | 5 | // A function to mock a change context 6 | export function mockDeltaChangeContext( 7 | beforeStart: number, 8 | afterStart: number, 9 | beforeEnd?: number, 10 | ): DeltaChangeContext { 11 | invariant(beforeEnd === undefined || beforeEnd > beforeStart, '') 12 | return new DeltaChangeContext(Selection.fromBounds(beforeStart, beforeEnd), Selection.fromBounds(afterStart)) 13 | } 14 | 15 | export function mockSelection(start: number, end?: number): Selection { 16 | return Selection.fromBounds(start, end) 17 | } 18 | -------------------------------------------------------------------------------- /src/test/document.ts: -------------------------------------------------------------------------------- 1 | import { DocumentDelta } from '@delta/DocumentDelta' 2 | import { GenericOp, buildImageOp, ImageOp } from '@delta/operations' 3 | 4 | export function mockDocumentDelta(ops?: GenericOp[]): DocumentDelta { 5 | return new DocumentDelta(ops) 6 | } 7 | 8 | export function buildDummyImageOp(uri = 'A'): ImageOp { 9 | return buildImageOp({ height: 10, width: 10, source: { uri } }) 10 | } 11 | -------------------------------------------------------------------------------- /src/test/vdom.ts: -------------------------------------------------------------------------------- 1 | import { NativeSyntheticEvent, TextInputSelectionChangeEventData } from 'react-native' 2 | import { ReactTestInstance } from 'react-test-renderer' 3 | 4 | export function mockSelectionChangeEvent( 5 | start: number, 6 | end: number, 7 | ): NativeSyntheticEvent { 8 | return { nativeEvent: { selection: { start, end } } } as NativeSyntheticEvent 9 | } 10 | 11 | export function flattenTextChild(instance: ReactTestInstance): string[] { 12 | const children: string[] = [] 13 | if (Array.isArray(instance.children)) { 14 | for (const inst of instance.children) { 15 | if (typeof inst !== 'string') { 16 | children.push(...flattenTextChild(inst)) 17 | } else { 18 | children.push(inst) 19 | } 20 | } 21 | } 22 | return children 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig-build.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/tsconfig", 3 | "extends": "./tsconfig.json", 4 | "exclude": ["node_modules", "**/__tests__/**", "src/test"], 5 | "compilerOptions": { 6 | "plugins": [ 7 | { 8 | "transform": "@zerollup/ts-transform-paths", 9 | "exclude": ["*"] 10 | } 11 | ], 12 | "esModuleInterop": true, 13 | "noEmit": false, 14 | "allowJs": false, 15 | "declaration": true, 16 | "declarationMap": true, 17 | "emitDeclarationOnly": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "target": "es5", 5 | "module": "commonjs", 6 | "experimentalDecorators": true, 7 | "allowSyntheticDefaultImports": true, 8 | "downlevelIteration": true, 9 | "strict": true, 10 | "esModuleInterop": true, 11 | "isolatedModules": false, 12 | "outDir": "./lib", 13 | "jsx": "react", 14 | "lib": ["es6"], 15 | "baseUrl": "./src", 16 | "paths": { 17 | "@components/*": ["components/*"], 18 | "@core/*": ["core/*"], 19 | "@delta/*": ["delta/*"], 20 | "@model/*": ["model/*"], 21 | "@test/*": ["test/*"], 22 | "@hooks/*": ["hooks/*"] 23 | }, 24 | "noEmit": true 25 | }, 26 | "include": ["src"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /types/tsdoc-metadata.json: -------------------------------------------------------------------------------- 1 | // This file is read by tools that parse documentation comments conforming to the TSDoc standard. 2 | // It should be published with your NPM package. It should not be tracked by Git. 3 | { 4 | "tsdocVersion": "0.12", 5 | "toolPackages": [ 6 | { 7 | "packageName": "@microsoft/api-extractor", 8 | "packageVersion": "7.4.7" 9 | } 10 | ] 11 | } 12 | --------------------------------------------------------------------------------