├── .eslintrc.json ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── babel.config.json ├── deepnotes-editor-demo.gif ├── example ├── .npmignore ├── deepnotes-editor.css ├── index.html ├── index.tsx ├── package-lock.json ├── package.json └── tsconfig.json ├── jest.config.js ├── package.json ├── postcss.config.js ├── src ├── Editor │ ├── __tests__ │ │ ├── block_with_its_descendants.tests.js │ │ ├── calculate_depth.tests.js │ │ ├── collapse_block.tests.js │ │ ├── has_collapsed_antecedents.tests.js │ │ ├── move.tests.js │ │ ├── on_tab.tests.js │ │ └── split_block.tests.js │ ├── add_empty_block_to_end.ts │ ├── block_creators.ts │ ├── calculate_depth.ts │ ├── collapse_expand_block.ts │ ├── components │ │ ├── Board.tsx │ │ ├── Disc.tsx │ │ ├── Editor.tsx │ │ ├── EditorDispatchContext.ts │ │ ├── Hashtag.tsx │ │ ├── Item.tsx │ │ ├── Link.tsx │ │ ├── Menu.tsx │ │ ├── SearchHighlight.tsx │ │ ├── disc_styles.module.css │ │ ├── editor_styles.global.css │ │ ├── editor_styles.module.css │ │ ├── item_styles.module.css │ │ ├── link_styles.module.css │ │ └── menu_styles.module.css │ ├── decorators.ts │ ├── encode_inline_style_ranges.js_backup │ ├── find_parent.ts │ ├── has_collapsed_antecedent.ts │ ├── make_corrections_to_node_and_its_descendants.ts │ ├── move.ts │ ├── paste_text.ts │ ├── pluck_goodies.ts │ ├── pos_generators.ts │ ├── recreate_parent_block_map.ts │ ├── sanitize_pos_and_depth_info.ts │ ├── split_block.ts │ ├── start_and_end_keys.ts │ ├── state_manager.ts │ ├── tab.ts │ └── tree_utils.ts ├── __mocks__ │ └── styleMock.js ├── button_styles.module.css ├── constants.ts ├── debounce.ts ├── fixtures │ └── playground_list.json ├── hooks │ ├── document-title.ts │ ├── previous.ts │ ├── save-data.ts │ ├── save_to_local_storage_web_worker.js │ └── worker.js ├── icons │ ├── ArrowLeft.tsx │ ├── ArrowRight.tsx │ ├── CheckMark.tsx │ ├── DotHorizontal.tsx │ ├── DownArrow.tsx │ ├── MinusSign.tsx │ ├── PlusSign.tsx │ ├── Star.tsx │ ├── arrow-left-big.svg │ ├── arrow-left.svg │ ├── arrow-right.svg │ ├── checkmark.svg │ ├── dot-horizontal.svg │ ├── down-arrow.svg │ ├── dropbox_logo.svg │ ├── menu.svg │ ├── minus-sign.svg │ ├── moon.svg │ ├── outbound.svg │ ├── plus-sign.svg │ ├── search-field-close.svg │ ├── search.svg │ ├── settings.svg │ ├── star.svg │ ├── sun.svg │ └── twitter.svg ├── index.tsx ├── load_from_db.ts ├── object_db.ts ├── testHelpers.ts └── typings.d.ts ├── tsconfig.json ├── tsdx.config.js └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint", "jest", "react", "react-hooks"], 5 | "extends": [ 6 | "eslint:recommended", 7 | "plugin:react/recommended", 8 | "plugin:@typescript-eslint/eslint-recommended", 9 | "plugin:@typescript-eslint/recommended" 10 | ], 11 | "rules": { 12 | "@typescript-eslint/no-empty-function": "off", 13 | "@typescript-eslint/explicit-function-return-type": "off", 14 | "@typescript-eslint/ban-ts-ignore": "off", 15 | "@typescript-eslint/camelcase": "off" 16 | }, 17 | "env": { 18 | "browser": true, 19 | "jest/globals": true 20 | }, 21 | "globals": { 22 | "process": true, 23 | "jest": true, 24 | "node": true 25 | } 26 | } 27 | 28 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | 7 | steps: 8 | - name: Begin CI... 9 | uses: actions/checkout@v2 10 | 11 | - name: Use Node 12 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: 12.x 15 | 16 | - name: Use cached node_modules 17 | uses: actions/cache@v1 18 | with: 19 | path: node_modules 20 | key: nodeModules-${{ hashFiles('**/yarn.lock') }} 21 | restore-keys: | 22 | nodeModules- 23 | 24 | - name: Install dependencies 25 | run: yarn install --frozen-lockfile 26 | env: 27 | CI: true 28 | 29 | - name: Lint 30 | run: yarn lint 31 | env: 32 | CI: true 33 | 34 | - name: Test 35 | run: yarn test --ci --coverage --maxWorkers=2 36 | env: 37 | CI: true 38 | 39 | - name: Build 40 | run: yarn build 41 | env: 42 | CI: true 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | Session.vim 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 80, 4 | "tabWidth": 2, 5 | "useTabs": false, 6 | "semi": true, 7 | "trailingComma": "all", 8 | "bracketSpacing": true, 9 | "jsxBracketSameLine": false 10 | } 11 | 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Mukesh Soni 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | `deepnotes-editor` is the editor used in [deepnotes.in](https://deepnotes.in). 2 | It's a clone of the [workflowy.com](https://workflowy.com) editor written in 3 | [draft-js](https://draftjs.org/). `deepnotes-editor` can be used as a react 4 | component. 5 | 6 | Here's a gif of how it works - 7 | 8 | ![deepnotes editor demo](deepnotes-editor-demo.gif) 9 | 10 | Why `deepnotes-editor`? 11 | 12 | - Supports infinitely nested lists 13 | - Every list item can be zoomed into. Therefore every list item can be thought 14 | of as a document in itself 15 | - Nested lists can be collapsed to reduce clutter 16 | - Powerful keyboard shortcuts so that you don't have to remove your hands from 17 | the keyboard when navigating the documents 18 | - Supports hashtags, automatic link detection and inline code block formatting 19 | - Bookmarking of any list item 20 | 21 | ### Desktop only 22 | `deepnotes-editor` doesn't work well on mobile browsers. That's because 23 | `draft-js` itself does not work well on mobile browsers. 24 | 25 | Usage - 26 | 27 | Install `deepnoter-editor` 28 | 29 | ```shell 30 | npm install deepnotes-editor # or yarn add deepnotes-editor 31 | ``` 32 | 33 | Use anywhere in your react codebase 34 | 35 | ``` 36 | import Editor from 'deepnotes-editor'; 37 | 38 | // it will look like it's not working without the css 39 | import 'deepnotes-editor/dist/deepnotes-editor.css'; 40 | 41 | // inside your dom heirarchy somewhere 42 |
43 | saveToDb(editorState)} /> 44 |
45 | ``` 46 | 47 | ## How to save editor state? 48 | You can use draft-js utilities to convert the `EditorState` returned from 49 | `onChange` prop. The prop returned is an [immutable-js](https://immutable-js.github.io/immutable-js/) value. 50 | 51 | 52 | ``` 53 | import { convertToRaw } from 'draft-js' 54 | 55 | function saveToDb(editorState) { 56 | const contentState = JSON.stringify(convertToRaw(contentState)), 57 | 58 | saveToDbOrLocalStorage(contentState); 59 | } 60 | ``` 61 | 62 | ## How to get back editor state from the saved content state? 63 | You can use `convertFromRaw` utility from `draft-js` to convert the content 64 | state json back to immutable-js `EditorState`. You have to use the 65 | `createDecorators` function which comes with `deepnotes-editor` so that the 66 | hashtags, links and code are highlighted properly. 67 | 68 | 69 | ``` 70 | import DeepnotesEditor, { createDecorators} from 'deepnotes-editor'; 71 | import 'deepnotes-editor/dist/deepnotes-editor.css'; 72 | 73 | const contentState = convertFromRaw(JSON.parse(backupContent)); 74 | const editorState = EditorState.createWithContent( 75 | contentState, 76 | createDecorators() 77 | ); 78 | 79 | // inside your render function 80 | return
81 | saveToDb(changedEditorState)}} 84 | /> 85 |
86 | ``` 87 | 88 | ### Customization or configuration 89 | These are the props `deepnotes-editor` accepts 90 | 91 | #### initialEditorState 92 | This prop can be used to initialize the editor with some saved state. The state 93 | is of the type `EditorState` from `draft-js`. See `draft-js` documentation for 94 | more details - https://draftjs.org/docs/quickstart-api-basics#controlling-rich-text 95 | 96 | P. S. - This component is not a controlled component. The state of the editor is 97 | maintained inside the component. If you change the zoomedInItemId, the editor 98 | will zoom into that item. But changing the initialEditorState between renders 99 | will not change the content of the editor to the new value of 100 | initialEditorState. 101 | 102 | #### initialZoomedInItemId 103 | If we want the editor to open zoomed in on some item. Very useful if you map the 104 | zoomedin items with urls and then if a user pastes or goes to a particular item 105 | directly, the editor can be also zoomed in to that item. 106 | 107 | #### searchText 108 | If you want to filter the items by some text 109 | 110 | #### onChange 111 | `onChange` is a function which is called with the new `EditorState` on every 112 | change. This can be used to save the new `EditorState` to local storage or to 113 | some persistent database. 114 | 115 | #### onRootChange 116 | This prop is called if the user zooms into a particular item. Please checkout 117 | workflowy.com to understand what zoom in means. 118 | 119 | #### onBookmarkClick 120 | If a user wants to bookmarks a particular zoomed in item. This can be used to 121 | build a bookmarking feature where the user can zoom to any of the bookmarked 122 | item. 123 | 124 | ### withToolbar 125 | If you don't want the menu/toolbar which shows up above the editor, you can set 126 | withToolbar to false. 127 | 128 | ## Development 129 | Install dependencies and start the build for the Editor component 130 | 131 | ```bash 132 | npm install # or yarn install 133 | npm start # or yarn start 134 | ``` 135 | 136 | This builds to `/dist` and runs the project in watch mode so any edits you save inside `src` causes a rebuild to `/dist`. 137 | 138 | This does not start a server. It only watches your files and builds and puts them in dist folder when any file in src directory changes. To view the editor in action, you need to ru na server inside the example directory. 139 | 140 | Then run the example inside another: 141 | 142 | ```bash 143 | cd example 144 | npm i # or yarn to install dependencies 145 | npm start # or yarn start 146 | ``` 147 | 148 | The example is served on http://localhost:1234. If that port is busy, parcel might try starting the server on some other port. 149 | 150 | The default example imports and live reloads whatever is in `/dist`, so if you are seeing an out of date component, make sure TSDX is running in watch mode like we recommend above. **No symlinking required**, [we use Parcel's aliasing](https://github.com/palmerhq/tsdx/pull/88/files). 151 | 152 | To do a one-off build, use `npm run build` or `yarn build`. 153 | 154 | To run tests, use `npm test` or `yarn test`. 155 | 156 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": true 8 | } 9 | } 10 | ] 11 | ], 12 | "plugins": ["@babel/plugin-proposal-class-properties"] 13 | } 14 | -------------------------------------------------------------------------------- /deepnotes-editor-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mukeshsoni/deepnotes-editor/217a78a645d41cefbd35459f51899ef49d95f6e6/deepnotes-editor-demo.gif -------------------------------------------------------------------------------- /example/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist -------------------------------------------------------------------------------- /example/deepnotes-editor.css: -------------------------------------------------------------------------------- 1 | .button_styles-module_button__2KbPc { 2 | border: none; 3 | background: transparent; 4 | } 5 | 6 | .button_styles-module_button__2KbPc:hover { 7 | background-color: #e2e8f0; 8 | } 9 | 10 | .button_styles-module_icon-button__2ddnV { 11 | padding: 0.5rem; 12 | border-radius: 9999px; 13 | color: var(--text-copy-primary); 14 | } 15 | 16 | .deepnotes-editor-theme-light { 17 | --bg-background-primary: white; 18 | --bg-background-secondary: #edf2f7; 19 | --bg-background-tertiary: #a0aec0; 20 | --bg-background-form: white; 21 | --text-copy-primary: #1a202c; 22 | --text-copy-secondary: #2d3748; 23 | --text-copy-tertiary: #718096; 24 | --border-border-color-primary: white; 25 | } 26 | 27 | .deepnotes-editor-theme-light .search-highlighted { 28 | background: #f0fff4; 29 | } 30 | 31 | .deepnotes-editor-theme-light .search-hover:hover { 32 | background: #f0fff4; 33 | } 34 | 35 | .deepnotes-editor-theme-dark .markdown-body { 36 | color: #24292e; 37 | } 38 | 39 | .deepnotes-editor-theme-dark .search-highlighted { 40 | background: #2d3748; 41 | } 42 | 43 | .deepnotes-editor-theme-dark .search-hover:hover { 44 | background: #2d3748; 45 | } 46 | 47 | .deepnotes-editor-theme-dark { 48 | --bg-background-primary: #232931; 49 | --bg-background-secondary: #393e46; 50 | --bg-background-tertiary: #4ecca3; 51 | --bg-background-form: #1a202c; 52 | --text-copy-primary: #f7fafc; 53 | --text-copy-secondary: #cbd5e0; 54 | --text-copy-tertiary: #a0aec0; 55 | --border-border-color-primary: #1a202c; 56 | } 57 | 58 | .deepnotes-editor-theme-dark .markdown-body { 59 | color: #cbd5e0; 60 | } 61 | 62 | .deepnotes-editor-theme-dark nav .active { 63 | border-bottom-width: 1px; 64 | --border-opacity: 1; 65 | border-color: #fff; 66 | border-color: rgba(255, 255, 255, var(--border-opacity)); 67 | } 68 | 69 | .editor_styles-module_container__3H-_W { 70 | display: flex; 71 | flex-direction: column; 72 | width: 100%; 73 | padding: 2.5rem; 74 | padding-top: 0.75rem; 75 | padding-left: 1.5rem; 76 | margin-bottom: 3rem; 77 | border-radius: 0.375rem; 78 | } 79 | 80 | .editor_styles-module_new-item-button__3yUxz { 81 | display: flex; 82 | align-self: flex-start; 83 | justify-content: center; 84 | flex-shrink: 0; 85 | padding: 0.5rem; 86 | margin-left: -0.75rem; 87 | border-radius: 9999px; 88 | } 89 | 90 | .editor_styles-module_new-item-button__3yUxz:hover { 91 | background-color: #e2e8f0; 92 | } 93 | 94 | .editor_styles-module_new-item-icon__3-ZVX { 95 | width: 0.75rem; 96 | height: 0.75rem; 97 | fill: currentColor; 98 | } 99 | 100 | .item_styles-module_item-base__3sOp0 { 101 | display: flex; 102 | align-items: center; 103 | position: relative; 104 | } 105 | 106 | .item_styles-module_completed___ObuM { 107 | color: #a0aec0; 108 | text-decoration: line-through; 109 | } 110 | 111 | .item_styles-module_zoomed-in-item__18pC6 { 112 | padding-left: 0.25rem; 113 | margin-bottom: 0.5rem; 114 | font-weight: 700; 115 | font-size: 1.5rem; 116 | } 117 | 118 | .item_styles-module_regular-item__23xsf { 119 | padding: 0.25rem; 120 | } 121 | 122 | .item_styles-module_small-text__u0K1d { 123 | font-size: 0.875rem; 124 | } 125 | 126 | .item_styles-module_item-container__3B3Jx { 127 | display: flex; 128 | align-items: center; 129 | justify-content: space-between; 130 | width: 100%; 131 | margin-left: 0.75rem; 132 | } 133 | 134 | .item_styles-module_mobile-collapse-button__3cd1_ { 135 | display: none; 136 | } 137 | 138 | .item_styles-module_depth-manager__1pfhr { 139 | width: 100%; 140 | padding-left: 2rem; 141 | border-left-width: 1px; 142 | border-color: var(--bg-background-secondary); 143 | --border-opacity: 0.5; 144 | } 145 | 146 | @media (max-width: 640px) { 147 | .item_styles-module_mobile-collapse-button__3cd1_ { 148 | display: flex; 149 | align-items: center; 150 | justify-content: center; 151 | margin-left: 0.75rem; 152 | } 153 | } 154 | 155 | .menu_styles-module_menu-container__1H_0Q { 156 | display: flex; 157 | align-items: center; 158 | justify-content: flex-end; 159 | width: 100%; 160 | padding: 0.5rem 1rem; 161 | margin-top: 0.75rem; 162 | background-color: white; 163 | position: -webkit-sticky; 164 | position: sticky; 165 | top: 0; 166 | z-index: 10; 167 | } 168 | 169 | .menu_styles-module_menu-left-container__Ac7Ac { 170 | --space-x-reverse: 0; 171 | display: flex; 172 | align-items: center; 173 | padding: 0 0.5rem; 174 | margin-right: 1.5rem; 175 | color: #718096; 176 | margin-right: calc(0.5rem * var(--space-x-reverse)); 177 | margin-left: calc(0.5rem * calc(1 - var(--space-x-reverse))); 178 | } 179 | 180 | .menu_styles-module_menu-right-container__2RbN3 { 181 | display: flex; 182 | align-items: center; 183 | } 184 | 185 | .menu_styles-module_menu-icon__jECU2 { 186 | width: 1rem; 187 | height: 1rem; 188 | } 189 | 190 | .menu_styles-module_menu-icon-large__2BwF4 { 191 | width: 1.25rem; 192 | height: 1.25rem; 193 | } 194 | 195 | .menu_styles-module_bookmarked__2CFZk { 196 | fill: currentColor; 197 | color: #d53f8c; 198 | } 199 | 200 | .menu_styles-module_not-bookmarked__1YiMp { 201 | stroke: currentColor; 202 | } 203 | 204 | [data-reach-dialog-overlay] { 205 | z-index: 100; 206 | } 207 | 208 | [data-reach-dialog-content] { 209 | position: relative; 210 | } 211 | 212 | [data-reach-dialog-content] > button.menu_styles-module_close-button__B-GJS { 213 | position: absolute; 214 | right: 10px; 215 | top: 0; 216 | padding: 5px; 217 | border: none; 218 | font-size: 150%; 219 | } 220 | 221 | @keyframes menu_styles-module_slide-down__1ZJ7b { 222 | 0% { 223 | opacity: 0; 224 | transform: translateY(-10px); 225 | } 226 | 100% { 227 | opacity: 1; 228 | transform: translateY(0); 229 | } 230 | } 231 | 232 | [data-reach-menu] { 233 | z-index: 100; 234 | } 235 | 236 | .menu_styles-module_slide-down__1ZJ7b[data-reach-menu-list] { 237 | border-radius: 5px; 238 | animation: menu_styles-module_slide-down__1ZJ7b 0.2s ease; 239 | } 240 | 241 | [data-reach-menu-item][data-selected] { 242 | background: #4a5568; 243 | } 244 | 245 | [data-reach-menu-button] { 246 | border: none; 247 | } 248 | 249 | [data-reach-dialog-content] { 250 | height: 80vh; 251 | } 252 | 253 | @media only screen and (max-width: 600px) { 254 | [data-reach-dialog-content] { 255 | width: 80vw; 256 | height: 80vh; 257 | } 258 | 259 | [data-reach-menu-item] { 260 | padding-top: 15px; 261 | padding-bottom: 15px; 262 | } 263 | } 264 | 265 | 266 | .link_styles-module_link__3R12D { 267 | color: var(--text-copy-tertiary); 268 | text-decoration: underline; 269 | overflow-wrap: break-word; 270 | white-space: normal; 271 | cursor: pointer; 272 | } 273 | 274 | .link_styles-module_link__3R12D:visited { 275 | color: var(--text-copy-tertiary); 276 | } 277 | 278 | .link_styles-module_link__3R12D:hover { 279 | color: #97266d; 280 | } 281 | 282 | .disc_styles-module_disc-container__3t72L { 283 | position: relative; 284 | display: flex; 285 | align-items: center; 286 | } 287 | 288 | .disc_styles-module_collapse-button__116tC { 289 | position: absolute; 290 | top: 0; 291 | left: 0; 292 | width: 2rem; 293 | height: 1.5rem; 294 | margin-left: -2rem; 295 | border: none; 296 | opacity: 0; 297 | cursor: pointer; 298 | } 299 | 300 | .disc_styles-module_collapse-button__116tC:hover { 301 | opacity: 1; 302 | } 303 | 304 | .disc_styles-module_down-arrow-icon__2wGmr { 305 | transition-property: transform; 306 | transition-duration: 100ms; 307 | --transform-translate-x: 0; 308 | --transform-translate-y: 0; 309 | --transform-rotate: 0; 310 | --transform-skew-x: 0; 311 | --transform-skew-y: 0; 312 | --transform-scale-x: 1; 313 | --transform-scale-y: 1; 314 | -webkit-transform: translateX(var(--transform-translate-x)) translateY(var(--transform-translate-y)) rotate(var(--transform-rotate)) skewX(var(--transform-skew-x)) skewY(var(--transform-skew-y)) scaleX(var(--transform-scale-x)) scaleY(var(--transform-scale-y)); 315 | transform: translateX(var(--transform-translate-x)) translateY(var(--transform-translate-y)) rotate(var(--transform-rotate)) skewX(var(--transform-skew-x)) skewY(var(--transform-skew-y)) scaleX(var(--transform-scale-x)) scaleY(var(--transform-scale-y)); 316 | fill: currentColor; 317 | color: var(--text-copy-primary); 318 | } 319 | 320 | .disc_styles-module_collapsed-down-arrow-icon__2c6c9 { 321 | --transform-rotate: -90deg; 322 | } 323 | 324 | .disc_styles-module_disc__MiMX- { 325 | display: flex; 326 | align-items: center; 327 | justify-content: center; 328 | width: 1.25rem; 329 | height: 1.25rem; 330 | border-radius: 9999px; 331 | cursor: pointer; 332 | } 333 | 334 | .disc_styles-module_disc__MiMX-:hover { 335 | background-color: var(--bg-background-tertiary); 336 | } 337 | 338 | .disc_styles-module_disc__MiMX-.disc_styles-module_disc-collapsed__2A4lQ { 339 | background-color: var(--bg-background-secondary); 340 | } 341 | 342 | .disc_styles-module_disc__MiMX-.disc_styles-module_disc-collapsed__2A4lQ:hover { 343 | background-color: var(--bg-background-tertiary); 344 | } 345 | 346 | .disc_styles-module_disc-icon__1QagH { 347 | fill: currentColor; 348 | color: var(--text-copy-secondary); 349 | } 350 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Playground 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import 'react-app-polyfill/ie11'; 2 | import * as React from 'react'; 3 | import * as ReactDOM from 'react-dom'; 4 | import Editor from '../.'; 5 | 6 | // If i import the deepnotes-editor.css file directly from ../dist/, it 7 | // doesn't apply any of the tailwind css. Don't know why. 8 | import './deepnotes-editor.css'; 9 | 10 | const App = () => { 11 | return ( 12 |
13 | 14 |
15 | ); 16 | }; 17 | 18 | ReactDOM.render(, document.getElementById('root')); 19 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "parcel index.html", 8 | "build": "parcel build index.html" 9 | }, 10 | "dependencies": { 11 | "react-app-polyfill": "^1.0.0" 12 | }, 13 | "alias": { 14 | "react": "../node_modules/react", 15 | "react-dom": "../node_modules/react-dom/profiling", 16 | "scheduler/tracing": "../node_modules/scheduler/tracing-profiling" 17 | }, 18 | "devDependencies": { 19 | "@types/react": "^16.9.11", 20 | "@types/react-dom": "^16.8.4", 21 | "parcel": "^1.12.3", 22 | "typescript": "^3.4.5" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": false, 4 | "target": "es5", 5 | "module": "commonjs", 6 | "jsx": "react", 7 | "moduleResolution": "node", 8 | "noImplicitAny": false, 9 | "noUnusedLocals": false, 10 | "noUnusedParameters": false, 11 | "removeComments": true, 12 | "strictNullChecks": true, 13 | "preserveConstEnums": true, 14 | "sourceMap": true, 15 | "lib": ["es2015", "es2016", "dom"], 16 | "baseUrl": ".", 17 | "types": ["node"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testMatch: ['**/__tests__/**/*.tests.js'], 3 | transform: { '^.+\\.tsx?$': ['ts-jest'], '^.+\\.jsx?$': 'babel-jest' }, 4 | moduleNameMapper: { 5 | '\\.(css|less)$': '/src/__mocks__/styleMock.js', 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.6", 3 | "license": "MIT", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "files": [ 7 | "dist", 8 | "src" 9 | ], 10 | "engines": { 11 | "node": ">=10" 12 | }, 13 | "scripts": { 14 | "start": "tsdx watch", 15 | "build": "tsdx build", 16 | "test": "tsdx test --passWithNoTests", 17 | "test:watch": "tsdx test --watch", 18 | "lint": "tsdx lint", 19 | "prepare": "tsdx build" 20 | }, 21 | "peerDependencies": { 22 | "react": ">=16" 23 | }, 24 | "husky": { 25 | "hooks": { 26 | "pre-commit": "tsdx lint" 27 | } 28 | }, 29 | "prettier": { 30 | "printWidth": 80, 31 | "semi": true, 32 | "singleQuote": true, 33 | "trailingComma": "es5" 34 | }, 35 | "name": "deepnotes-editor", 36 | "author": "Mukesh Soni", 37 | "module": "dist/deepnotes-editor.esm.js", 38 | "repository": { 39 | "type": "git", 40 | "url": "https://github.com/mukeshsoni/deepnotes-editor" 41 | }, 42 | "bugs": { 43 | "url": "https://github.com/mukeshsoni/deepnotes-editor/issues" 44 | }, 45 | "homepage": "https://github.com/mukeshsoni/deepnotes-editor", 46 | "devDependencies": { 47 | "@types/classnames": "^2.2.10", 48 | "@types/draft-js": "^0.10.43", 49 | "@types/linkify-it": "^2.1.0", 50 | "@types/react": "^16.9.46", 51 | "@types/react-dom": "^16.9.8", 52 | "deep-equal": "^2.0.3", 53 | "deep-object-diff": "^1.1.0", 54 | "husky": "^4.2.5", 55 | "react": "^16.13.1", 56 | "react-dom": "^16.13.1", 57 | "rollup-plugin-postcss": "^3.1.5", 58 | "ts-jest": "^26.2.0", 59 | "tsdx": "^0.13.2", 60 | "tslib": "^2.0.1", 61 | "typescript": "^3.9.7" 62 | }, 63 | "dependencies": { 64 | "@reach/menu-button": "^0.10.5", 65 | "classnames": "^2.2.6", 66 | "draft-js": "^0.11.6", 67 | "escape-string-regexp": "^4.0.0", 68 | "linkify-it": "^3.0.2", 69 | "memoize-one": "^5.1.1" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // it looks like tsdx already has support for css-modules using some other 3 | // rollup plugin. If i use postcss-modules plugin again, the classnames 4 | // for module css are generated twice. Someone is creating a hashed version 5 | // of a class and then postcss-modules plugin creates another classname 6 | // from that hashed classname 7 | modules: false, 8 | plugins: { 9 | // 'postcss-modules': { 10 | // globalModulePaths: [ 11 | // // Put your global css file paths. 12 | // /.*global\.css$/, 13 | // 'src/tailwind_generated.css', 14 | // ], 15 | // }, 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /src/Editor/__tests__/block_with_its_descendants.tests.js: -------------------------------------------------------------------------------- 1 | import { getBlocksWithItsDescendants } from '../tree_utils'; 2 | import { sampleStateLarge, getBlock } from '../../testHelpers'; 3 | import pluckGoodies from '../pluck_goodies'; 4 | 5 | describe("block with it's children", () => { 6 | it("should get the block along with it's children", () => { 7 | const editorState = sampleStateLarge(); 8 | const { blockMap } = pluckGoodies(editorState); 9 | 10 | let blockWithItsChildren = getBlocksWithItsDescendants( 11 | blockMap, 12 | getBlock(editorState, 9).getKey() 13 | ); 14 | 15 | expect(blockWithItsChildren.count()).toBe(13); 16 | expect(blockWithItsChildren.toArray()[0].getText()).toBe('Things to try'); 17 | expect(blockWithItsChildren.toArray()[12].getText()).toBe( 18 | 'Learn the keyboard shortcuts (they make everything super fast)' 19 | ); 20 | 21 | // select the first block which should select all blocks since everything 22 | // else is inside this block 23 | blockWithItsChildren = getBlocksWithItsDescendants( 24 | blockMap, 25 | getBlock(editorState, 1).getKey() 26 | ); 27 | 28 | expect(blockWithItsChildren.count()).toBe(49); 29 | }); 30 | 31 | it('should return nothing if the block does not exist', () => { 32 | const editorState = sampleStateLarge(); 33 | const { blockMap } = pluckGoodies(editorState); 34 | 35 | const blockWithItsChildren = getBlocksWithItsDescendants(blockMap, 'abcd'); 36 | 37 | expect(blockWithItsChildren.count()).toBe(0); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/Editor/__tests__/calculate_depth.tests.js: -------------------------------------------------------------------------------- 1 | import pluckGoodies from '../pluck_goodies'; 2 | import { calculateDepth } from '../calculate_depth'; 3 | 4 | import { getBlocks, sampleStateLarge } from '../../testHelpers'; 5 | 6 | describe('calculateDepth', () => { 7 | it('should calculate depth for a given block correctly', () => { 8 | const editorState = sampleStateLarge(); 9 | const blocks = getBlocks(editorState); 10 | const { blockMap } = pluckGoodies(editorState); 11 | 12 | expect(calculateDepth(blockMap, blocks[0].getKey())).toEqual(-1); 13 | expect(calculateDepth(blockMap, blocks[1].getKey())).toEqual(0); 14 | expect(calculateDepth(blockMap, blocks[2].getKey())).toEqual(1); 15 | expect(calculateDepth(blockMap, blocks[3].getKey())).toEqual(1); 16 | expect(calculateDepth(blockMap, blocks[6].getKey())).toEqual(2); 17 | expect(calculateDepth(blockMap, blocks[9].getKey())).toEqual(3); 18 | expect(calculateDepth(blockMap, blocks[10].getKey())).toEqual(4); 19 | expect(calculateDepth(blockMap, blocks[17].getKey())).toEqual(4); 20 | expect(calculateDepth(blockMap, blocks[18].getKey())).toEqual(5); 21 | expect(calculateDepth(blockMap, blocks[22].getKey())).toEqual(3); 22 | expect(calculateDepth(blockMap, blocks[23].getKey())).toEqual(4); 23 | expect(calculateDepth(blockMap, blocks[32].getKey())).toEqual(3); 24 | expect(calculateDepth(blockMap, blocks[33].getKey())).toEqual(4); 25 | expect(calculateDepth(blockMap, blocks[38].getKey())).toEqual(3); 26 | expect(calculateDepth(blockMap, blocks[39].getKey())).toEqual(4); 27 | expect(calculateDepth(blockMap, blocks[42].getKey())).toEqual(4); 28 | expect(calculateDepth(blockMap, blocks[46].getKey())).toEqual(3); 29 | expect(calculateDepth(blockMap, blocks[47].getKey())).toEqual(4); 30 | expect(calculateDepth(blockMap, blocks[49].getKey())).toEqual(4); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/Editor/__tests__/collapse_block.tests.js: -------------------------------------------------------------------------------- 1 | import { expandBlock, collapseBlock } from '../collapse_expand_block'; 2 | 3 | import { getBlock, sampleStateLarge } from '../../testHelpers'; 4 | 5 | describe('collapse block', () => { 6 | it('should collapse the block we ask it to', () => { 7 | let editorState = sampleStateLarge(); 8 | let blockToCollapseIndex = 9; 9 | 10 | expect( 11 | getBlock(editorState, blockToCollapseIndex).getIn(['data', 'collapsed']) 12 | ).toBe(undefined); 13 | 14 | editorState = collapseBlock( 15 | editorState, 16 | getBlock(editorState, blockToCollapseIndex).getKey() 17 | ); 18 | 19 | expect( 20 | getBlock(editorState, blockToCollapseIndex).getIn(['data', 'collapsed']) 21 | ).toBe(true); 22 | 23 | blockToCollapseIndex = 1; 24 | editorState = collapseBlock( 25 | editorState, 26 | getBlock(editorState, blockToCollapseIndex).getKey() 27 | ); 28 | 29 | expect( 30 | getBlock(editorState, blockToCollapseIndex).getIn(['data', 'collapsed']) 31 | ).toBe(true); 32 | }); 33 | 34 | it('should not collapse a block if that block does not have children', () => { 35 | let editorState = sampleStateLarge(); 36 | const blockToCollapseIndex = 2; 37 | 38 | expect( 39 | getBlock(editorState, blockToCollapseIndex).getIn(['data', 'collapsed']) 40 | ).toBe(undefined); 41 | 42 | editorState = collapseBlock( 43 | editorState, 44 | getBlock(editorState, blockToCollapseIndex).getKey() 45 | ); 46 | 47 | expect( 48 | getBlock(editorState, blockToCollapseIndex).getIn(['data', 'collapsed']) 49 | ).toBe(undefined); 50 | }); 51 | 52 | it('should keep a collapsed block collapsed', () => { 53 | let editorState = sampleStateLarge(); 54 | const blockToCollapseIndex = 1; 55 | 56 | expect( 57 | getBlock(editorState, blockToCollapseIndex).getIn(['data', 'collapsed']) 58 | ).toBe(undefined); 59 | 60 | editorState = collapseBlock( 61 | editorState, 62 | getBlock(editorState, blockToCollapseIndex).getKey() 63 | ); 64 | expect( 65 | getBlock(editorState, blockToCollapseIndex).getIn(['data', 'collapsed']) 66 | ).toBe(true); 67 | editorState = collapseBlock( 68 | editorState, 69 | getBlock(editorState, blockToCollapseIndex).getKey() 70 | ); 71 | expect( 72 | getBlock(editorState, blockToCollapseIndex).getIn(['data', 'collapsed']) 73 | ).toBe(true); 74 | }); 75 | }); 76 | 77 | describe('expand block', () => { 78 | it('should expand the block we ask it to', () => { 79 | let editorState = sampleStateLarge(); 80 | const blockToExpandIndex = 1; 81 | 82 | expect( 83 | getBlock(editorState, blockToExpandIndex).getIn(['data', 'collapsed']) 84 | ).toBe(undefined); 85 | 86 | editorState = collapseBlock( 87 | editorState, 88 | getBlock(editorState, blockToExpandIndex).getKey() 89 | ); 90 | editorState = expandBlock( 91 | editorState, 92 | getBlock(editorState, blockToExpandIndex).getKey() 93 | ); 94 | 95 | expect( 96 | getBlock(editorState, blockToExpandIndex).getIn(['data', 'collapsed']) 97 | ).toBe(false); 98 | }); 99 | 100 | it('should not expand the block if it is does not have children', () => { 101 | let editorState = sampleStateLarge(); 102 | const blockToExpandIndex = 2; 103 | 104 | expect( 105 | getBlock(editorState, blockToExpandIndex).getIn(['data', 'collapsed']) 106 | ).toBe(undefined); 107 | 108 | editorState = collapseBlock( 109 | editorState, 110 | getBlock(editorState, blockToExpandIndex).getKey() 111 | ); 112 | editorState = expandBlock( 113 | editorState, 114 | getBlock(editorState, blockToExpandIndex).getKey() 115 | ); 116 | 117 | expect( 118 | getBlock(editorState, blockToExpandIndex).getIn(['data', 'collapsed']) 119 | ).toBe(undefined); 120 | }); 121 | 122 | it('should keep an expanded block expanded', () => { 123 | let editorState = sampleStateLarge(); 124 | const blockToExpandIndex = 5; 125 | 126 | expect( 127 | getBlock(editorState, blockToExpandIndex).getIn(['data', 'collapsed']) 128 | ).toBe(undefined); 129 | 130 | editorState = collapseBlock( 131 | editorState, 132 | getBlock(editorState, blockToExpandIndex).getKey() 133 | ); 134 | editorState = expandBlock( 135 | editorState, 136 | getBlock(editorState, blockToExpandIndex).getKey() 137 | ); 138 | expect( 139 | getBlock(editorState, blockToExpandIndex).getIn(['data', 'collapsed']) 140 | ).toBe(false); 141 | editorState = expandBlock( 142 | editorState, 143 | getBlock(editorState, blockToExpandIndex).getKey() 144 | ); 145 | expect( 146 | getBlock(editorState, blockToExpandIndex).getIn(['data', 'collapsed']) 147 | ).toBe(false); 148 | }); 149 | }); 150 | -------------------------------------------------------------------------------- /src/Editor/__tests__/has_collapsed_antecedents.tests.js: -------------------------------------------------------------------------------- 1 | import { hasCollapsedAntecedent } from '../has_collapsed_antecedent'; 2 | 3 | import { collapseBlock } from '../collapse_expand_block'; 4 | 5 | import { getBlock, sampleStateLarge } from '../../testHelpers'; 6 | import pluckGoodies from '../pluck_goodies'; 7 | 8 | describe('hasCollapsedAntecedents: function to test if an item has a collapsed ancestor', () => { 9 | it('should return false when item does not have any collapsed ancestors', () => { 10 | const editorState = sampleStateLarge(); 11 | const { blockMap } = pluckGoodies(editorState); 12 | 13 | expect( 14 | hasCollapsedAntecedent(blockMap, getBlock(editorState, 2).getKey()) 15 | ).toBe(false); 16 | expect( 17 | hasCollapsedAntecedent(blockMap, getBlock(editorState, 10).getKey()) 18 | ).toBe(false); 19 | expect( 20 | hasCollapsedAntecedent(blockMap, getBlock(editorState, 30).getKey()) 21 | ).toBe(false); 22 | expect( 23 | hasCollapsedAntecedent(blockMap, getBlock(editorState, 49).getKey()) 24 | ).toBe(false); 25 | }); 26 | 27 | it('returns false if the block is not present in blockMap', () => { 28 | const editorState = sampleStateLarge(); 29 | const { blockMap } = pluckGoodies(editorState); 30 | 31 | expect(hasCollapsedAntecedent(blockMap, 'abcd')).toBe(false); 32 | }); 33 | 34 | it('should return true even if the direct parent is not collapsed', () => { 35 | let editorState = sampleStateLarge(); 36 | 37 | let blockMap = pluckGoodies(editorState).blockMap; 38 | expect( 39 | hasCollapsedAntecedent(blockMap, getBlock(editorState, 10).getKey()) 40 | ).toBe(false); 41 | // collapse block with text 'Things to try' 42 | editorState = collapseBlock(editorState, getBlock(editorState, 8).getKey()); 43 | blockMap = pluckGoodies(editorState).blockMap; 44 | 45 | // block outside collapsed block will return false 46 | expect( 47 | hasCollapsedAntecedent(blockMap, getBlock(editorState, 7).getKey()) 48 | ).toBe(false); 49 | expect( 50 | hasCollapsedAntecedent(blockMap, getBlock(editorState, 10).getKey()) 51 | ).toBe(true); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/Editor/__tests__/move.tests.js: -------------------------------------------------------------------------------- 1 | import { ROOT_KEY, BASE_POS } from '../../constants'; 2 | import { getEmptySlateState } from '../block_creators'; 3 | import { moveCurrentBlockUp, moveCurrentBlockDown } from '../move.ts'; 4 | 5 | import { 6 | getBlock, 7 | assertParentId, 8 | assertBlockPos, 9 | assertBlockDepth, 10 | assertBlockText, 11 | moveFocus, 12 | sampleStateLarge, 13 | } from '../../testHelpers.ts'; 14 | 15 | function moveBlockUp(editorState, blockIndex, zoomedInItemId) { 16 | editorState = moveFocus(editorState, blockIndex, 0); 17 | editorState = moveCurrentBlockUp(editorState, zoomedInItemId); 18 | return editorState; 19 | } 20 | 21 | function moveBlockDown(editorState, blockIndex, zoomedInItemId) { 22 | editorState = moveFocus(editorState, blockIndex, 0); 23 | editorState = moveCurrentBlockDown(editorState, zoomedInItemId); 24 | return editorState; 25 | } 26 | 27 | class Tester { 28 | constructor() { 29 | this.editorState = getEmptySlateState(ROOT_KEY); 30 | } 31 | 32 | moveBlockUp = (blockIndex, zoomedInItemIndex) => { 33 | const zoomedInBlock = getBlock(this.editorState, zoomedInItemIndex); 34 | const zoomedInItemId = zoomedInBlock ? zoomedInBlock.getKey() : undefined; 35 | 36 | this.editorState = moveBlockUp( 37 | this.editorState, 38 | blockIndex, 39 | zoomedInItemId 40 | ); 41 | return this; 42 | }; 43 | 44 | moveBlockDown = (blockIndex, zoomedInItemIndex) => { 45 | const zoomedInBlock = getBlock(this.editorState, zoomedInItemIndex); 46 | const zoomedInItemId = zoomedInBlock ? zoomedInBlock.getKey() : undefined; 47 | 48 | this.editorState = moveBlockDown( 49 | this.editorState, 50 | blockIndex, 51 | zoomedInItemId 52 | ); 53 | return this; 54 | }; 55 | 56 | loadSampleStateLarge = () => { 57 | this.editorState = sampleStateLarge(); 58 | return this; 59 | }; 60 | 61 | assertParentId = (blockIndex, parentIndex) => { 62 | const parent = getBlock(this.editorState, parentIndex); 63 | const parentId = parent.getKey(); 64 | 65 | assertParentId(this.editorState, blockIndex, parentId); 66 | return this; 67 | }; 68 | 69 | assertBlockPos = (blockIndex, pos) => { 70 | assertBlockPos(this.editorState, blockIndex, pos); 71 | return this; 72 | }; 73 | 74 | assertBlockDepth = (blockToIndentIndex, expectedDepth) => { 75 | assertBlockDepth(this.editorState, blockToIndentIndex, expectedDepth); 76 | return this; 77 | }; 78 | 79 | assertBlockText = (blockNumber, expectedText) => { 80 | assertBlockText(this.editorState, blockNumber, expectedText); 81 | return this; 82 | }; 83 | } 84 | 85 | describe('move related functions', () => { 86 | it('should move block up', () => { 87 | const blockToMoveIndex = 4; 88 | const blockText = 'Every document can contain infinite documents.'; 89 | 90 | new Tester() 91 | .loadSampleStateLarge() 92 | .assertBlockText(blockToMoveIndex, blockText) 93 | .moveBlockUp(blockToMoveIndex) 94 | .assertBlockText(blockToMoveIndex, 'Every bullet is a document.') 95 | .assertBlockText(blockToMoveIndex - 1, blockText); 96 | }); 97 | 98 | it('should move block down', () => { 99 | const blockToMoveIndex = 3; 100 | 101 | new Tester() 102 | .loadSampleStateLarge() 103 | .assertBlockText(blockToMoveIndex, 'Every bullet is a document.') 104 | .assertParentId(blockToMoveIndex, 1) 105 | .moveBlockDown(blockToMoveIndex) 106 | .assertBlockText( 107 | blockToMoveIndex, 108 | 'Every document can contain infinite documents.' 109 | ) 110 | .assertBlockText(blockToMoveIndex + 1, 'Every bullet is a document.') 111 | .assertParentId(blockToMoveIndex + 1, 1); 112 | }); 113 | 114 | it('should move item up to a different parents, if possible', () => { 115 | const blockToMoveIndex = 6; 116 | const blockIndexAfterMove = 5; 117 | const blockText = 118 | 'It lets you easily organize hundreds of thousands of notes, ideas and projects.'; 119 | 120 | new Tester() 121 | .loadSampleStateLarge() 122 | .assertBlockText(blockToMoveIndex, blockText) 123 | .assertParentId(blockToMoveIndex, 5) 124 | .moveBlockUp(blockToMoveIndex) 125 | .assertBlockText(blockIndexAfterMove, blockText) 126 | .assertParentId(blockIndexAfterMove, 4) 127 | .assertBlockPos(blockIndexAfterMove, BASE_POS); 128 | }); 129 | 130 | it('should move item down to a different parent as first child, if possible', () => { 131 | const blockToMoveIndex = 18; 132 | const blockIndexAfterMove = 19; 133 | const blockText = 134 | '#important You can share any bullet with different people, no matter where it is. This is the most flexible sharing model in any tool. #fractal'; 135 | 136 | new Tester() 137 | .loadSampleStateLarge() 138 | .assertBlockText(blockToMoveIndex, blockText) 139 | .assertParentId(blockToMoveIndex, 17) 140 | .moveBlockDown(blockToMoveIndex) 141 | // it should move to new parent as the first child 142 | .assertBlockText(blockIndexAfterMove, blockText); 143 | // .assertParentId(blockIndexAfterMove, 18); 144 | }); 145 | 146 | it('should not move block away from current parent if the parent is zoomed in', () => { 147 | const blockToMoveIndex = 6; 148 | const zoomedInItemIndex = 5; 149 | const blockIndexAfterMove = 6; 150 | const blockText = 151 | 'It lets you easily organize hundreds of thousands of notes, ideas and projects.'; 152 | 153 | new Tester() 154 | .loadSampleStateLarge() 155 | .assertBlockText(blockToMoveIndex, blockText) 156 | .assertParentId(blockToMoveIndex, 5) 157 | .moveBlockUp(blockToMoveIndex, zoomedInItemIndex) 158 | .assertBlockText(blockIndexAfterMove, blockText) 159 | .assertParentId(blockIndexAfterMove, 5); 160 | }); 161 | }); 162 | -------------------------------------------------------------------------------- /src/Editor/__tests__/on_tab.tests.js: -------------------------------------------------------------------------------- 1 | import deepEqual from 'deep-equal'; 2 | import { updatedDiff } from 'deep-object-diff'; 3 | import { convertToRaw } from 'draft-js'; 4 | 5 | import { 6 | getBlock, 7 | assertParentId, 8 | assertBlockPos, 9 | assertBlockDepth, 10 | assertBlockText, 11 | moveFocus, 12 | sampleStateLarge, 13 | } from '../../testHelpers.ts'; 14 | 15 | import { getEmptySlateState } from '../block_creators'; 16 | import { onTab } from '../tab.ts'; 17 | import { ROOT_KEY, BASE_POS } from '../../constants'; 18 | import DB, { arrToObj } from '../../object_db.ts'; 19 | import { loadFromDb } from '../../load_from_db'; 20 | 21 | const MAX_DEPTH = 10; 22 | 23 | function indentBlock(editorState, blockIndex, zoomedInItemId) { 24 | editorState = moveFocus(editorState, blockIndex, 0); 25 | editorState = onTab(editorState, MAX_DEPTH, zoomedInItemId); 26 | return editorState; 27 | } 28 | 29 | function dedentBlock(editorState, blockIndex, zoomedInItemId) { 30 | editorState = moveFocus(editorState, blockIndex, 0); 31 | editorState = onTab(editorState, MAX_DEPTH, zoomedInItemId, true); 32 | return editorState; 33 | } 34 | 35 | class Tester { 36 | constructor() { 37 | this.editorState = getEmptySlateState(ROOT_KEY); 38 | } 39 | 40 | indentBlock = (blockIndex, zoomedInItemIndex) => { 41 | const zoomedInBlock = getBlock(this.editorState, zoomedInItemIndex); 42 | const zoomedInItemId = zoomedInBlock ? zoomedInBlock.getKey() : undefined; 43 | 44 | this.editorState = indentBlock( 45 | this.editorState, 46 | blockIndex, 47 | zoomedInItemId 48 | ); 49 | return this; 50 | }; 51 | 52 | dedentBlock = (blockIndex, zoomedInItemIndex) => { 53 | const zoomedInBlock = getBlock(this.editorState, zoomedInItemIndex); 54 | const zoomedInItemId = zoomedInBlock ? zoomedInBlock.getKey() : undefined; 55 | 56 | this.editorState = dedentBlock( 57 | this.editorState, 58 | blockIndex, 59 | zoomedInItemId 60 | ); 61 | return this; 62 | }; 63 | 64 | loadSampleStateLarge = () => { 65 | this.editorState = sampleStateLarge(); 66 | return this; 67 | }; 68 | 69 | assertParentId = (blockIndex, parentIndex) => { 70 | const parent = getBlock(this.editorState, parentIndex); 71 | const parentId = parent.getKey(); 72 | 73 | assertParentId(this.editorState, blockIndex, parentId); 74 | return this; 75 | }; 76 | 77 | assertBlockPos = (blockIndex, pos) => { 78 | assertBlockPos(this.editorState, blockIndex, pos); 79 | return this; 80 | }; 81 | 82 | assertBlockDepth = (blockToIndentIndex, expectedDepth) => { 83 | assertBlockDepth(this.editorState, blockToIndentIndex, expectedDepth); 84 | return this; 85 | }; 86 | 87 | assertBlockText = (blockNumber, expectedText) => { 88 | assertBlockText(this.editorState, blockNumber, expectedText); 89 | return this; 90 | }; 91 | } 92 | 93 | describe('onTab function', () => { 94 | describe('check depth after indent operation', () => { 95 | it('should change depth of item onTab', () => { 96 | const blockToIndentIndex = 3; 97 | new Tester() 98 | .loadSampleStateLarge() 99 | .assertBlockDepth(blockToIndentIndex, 2) 100 | .indentBlock(blockToIndentIndex) 101 | .assertBlockDepth(blockToIndentIndex, 3); 102 | }); 103 | 104 | it('should do nothing if tab was pressed on a zoomedin item', () => { 105 | const blockToIndentIndex = 3; 106 | 107 | new Tester() 108 | .loadSampleStateLarge() 109 | .assertBlockDepth(blockToIndentIndex, 2) 110 | .indentBlock(blockToIndentIndex, blockToIndentIndex) 111 | .assertBlockDepth(blockToIndentIndex, 2); 112 | }); 113 | 114 | it('should do nothing to depth if tab was pressed on the first child which also has children', () => { 115 | const blockToIndentIndex = 9; 116 | 117 | new Tester() 118 | .loadSampleStateLarge() 119 | .assertBlockDepth(blockToIndentIndex, 4) 120 | .indentBlock(blockToIndentIndex) 121 | .assertBlockDepth(blockToIndentIndex, 4); 122 | }); 123 | 124 | it('should do nothing if tab is pressed on first child of a zoomed in item', () => { 125 | const blockToIndentIndex = 10; 126 | new Tester() 127 | .loadSampleStateLarge() 128 | .assertBlockDepth(blockToIndentIndex, 5) 129 | .indentBlock(blockToIndentIndex, blockToIndentIndex - 1) 130 | .assertBlockDepth(blockToIndentIndex, 5); 131 | }); 132 | }); 133 | 134 | describe('check depth after dedent operation', () => { 135 | it('should change depth of item shift+tab', () => { 136 | const blockToDedentIndex = 3; 137 | const dedentedBlockAfterDedentIndex = 49; 138 | 139 | new Tester() 140 | .loadSampleStateLarge() 141 | .assertBlockDepth(blockToDedentIndex, 2) 142 | .dedentBlock(blockToDedentIndex) 143 | .assertBlockDepth(dedentedBlockAfterDedentIndex, 1) 144 | .assertBlockText( 145 | dedentedBlockAfterDedentIndex, 146 | 'Every bullet is a document.' 147 | ); 148 | }); 149 | 150 | it('should do nothing if shift+tab was pressed on a zoomedin item', () => { 151 | const blockToDedentIndex = 3; 152 | 153 | new Tester() 154 | .loadSampleStateLarge() 155 | .assertBlockDepth(blockToDedentIndex, 2) 156 | .dedentBlock(blockToDedentIndex, blockToDedentIndex) 157 | .assertBlockDepth(blockToDedentIndex, 2); 158 | }); 159 | 160 | it('should decrease depth if tab was pressed on the first child which also has children', () => { 161 | const blockToDedentIndex = 9; 162 | const dedentedBlockAfterDedentIndex = 37; 163 | 164 | new Tester() 165 | .loadSampleStateLarge() 166 | .assertBlockDepth(blockToDedentIndex, 4) 167 | .dedentBlock(blockToDedentIndex) 168 | // .assertBlockDepth(dedentedBlockAfterDedentIndex, 3) 169 | .assertBlockText(dedentedBlockAfterDedentIndex, 'Things to try'); 170 | }); 171 | 172 | it('should do nothing if shift+tab is pressed on first child of a zoomed in item', () => { 173 | const blockToDedentIndex = 10; 174 | 175 | new Tester() 176 | .loadSampleStateLarge() 177 | .assertBlockDepth(blockToDedentIndex, 5) 178 | .dedentBlock(blockToDedentIndex, blockToDedentIndex) 179 | .assertBlockDepth(blockToDedentIndex, 5); 180 | }); 181 | }); 182 | 183 | describe('parentId and pos updates due to onTab', () => { 184 | it('should update parentId to previous sibling if previous sibling is at same level', () => { 185 | const blockToIndentIndex = 3; 186 | 187 | new Tester() 188 | .loadSampleStateLarge() 189 | .assertParentId(blockToIndentIndex, 1) 190 | .assertBlockPos(blockToIndentIndex, 2 * BASE_POS) 191 | .indentBlock(blockToIndentIndex) 192 | .assertParentId(blockToIndentIndex, 2) 193 | .assertBlockPos(blockToIndentIndex, BASE_POS); 194 | }); 195 | 196 | it('should update parentId for a dedent operation', () => { 197 | const blockToDedentIndex = 3; 198 | const dedentedBlockAfterDedentIndex = 49; 199 | 200 | new Tester() 201 | .loadSampleStateLarge() 202 | .assertParentId(blockToDedentIndex, 1) 203 | .assertBlockPos(blockToDedentIndex, 2 * BASE_POS) 204 | .dedentBlock(blockToDedentIndex) 205 | .assertParentId(dedentedBlockAfterDedentIndex, 0) 206 | .assertBlockPos(dedentedBlockAfterDedentIndex, 2 * BASE_POS); 207 | }); 208 | 209 | // item 22 in our data is one with the text 210 | // "Things real people have done with WorkFlowy" 211 | // When we indent that item, it becomes the child of "Things to try". 212 | // The position is not BASE_POS here 213 | it('should update parentId and pos correctly for item 22', () => { 214 | const blockToIndentIndex = 22; 215 | 216 | new Tester() 217 | .loadSampleStateLarge() 218 | .assertParentId(blockToIndentIndex, 8) 219 | .assertBlockPos(blockToIndentIndex, 2 * BASE_POS) 220 | .indentBlock(blockToIndentIndex) 221 | .assertParentId(blockToIndentIndex, 9) 222 | .assertBlockPos(blockToIndentIndex, 12 * BASE_POS); 223 | }); 224 | 225 | it('property based test - should test a series of indent/dedent operation combination', () => { 226 | const editorState = sampleStateLarge(); 227 | const contentState = editorState.getCurrentContent(); 228 | const blockMap = contentState.getBlockMap(); 229 | const totalOperations = 10; 230 | 231 | function randomIntFromInterval(min, max) { 232 | // min and max included 233 | return Math.floor(Math.random() * (max - min + 1) + min); 234 | } 235 | 236 | // generate a list of 100-200 item indexes. We will send an operation of 237 | // indent or dedent to each of those indexes one after another. 238 | const indices = Array(totalOperations) 239 | .fill(0) 240 | .map(() => randomIntFromInterval(1, blockMap.count() - 1)); 241 | 242 | const operations = ['indentBlock', 'dedentBlock']; 243 | // Also generate in advance the list of operations we will perform 244 | const operationsList = indices.map(blockIndex => [ 245 | blockIndex, 246 | operations[randomIntFromInterval(0, 1)], 247 | ]); 248 | let numOperations = totalOperations; 249 | let lastFailingOperationNum = totalOperations; 250 | let lastSuccessNumOperations = 0; 251 | 252 | while (true && numOperations > 0) { 253 | console.log({ 254 | totalOperations, 255 | numOperations, 256 | lastFailingOperationNum, 257 | lastSuccessNumOperations, 258 | }); 259 | const finalEditorState = operationsList 260 | .slice(0, numOperations) 261 | .reduce((acc, [blockIndex, selectedOperation]) => { 262 | // console.log(selectedOperation, blockIndex, i); 263 | if (selectedOperation === 'indentBlock') { 264 | return indentBlock(acc, blockIndex); 265 | } else { 266 | return dedentBlock(acc, blockIndex); 267 | } 268 | }, editorState); 269 | 270 | // we will store our final state blocks in an object database 271 | // And then try to load it back to our app as blocks array using our 272 | // loadFromDb function 273 | // It should match the blocks which we can get from finalContentState 274 | const finalContentState = finalEditorState.getCurrentContent(); 275 | const finalBlocksArray = convertToRaw(finalContentState).blocks; 276 | const db = new DB(arrToObj(finalBlocksArray, 'key')); 277 | const blocksFromDb = loadFromDb(db, ROOT_KEY); 278 | 279 | // if things look good, let's try to increasing numOperations and see 280 | // if that fails. Only if numOperations < totalOperations 281 | if (deepEqual(blocksFromDb, finalBlocksArray)) { 282 | if (numOperations === totalOperations) { 283 | console.log('everything looks good'); 284 | break; 285 | } else { 286 | lastSuccessNumOperations = numOperations; 287 | 288 | numOperations = Math.ceil( 289 | (lastSuccessNumOperations + lastFailingOperationNum) / 2 290 | ); 291 | } 292 | } else { 293 | lastFailingOperationNum = numOperations; 294 | 295 | if (lastSuccessNumOperations + 1 === lastFailingOperationNum) { 296 | console.log( 297 | 'Found a minimal case for failing', 298 | operationsList.slice(0, numOperations), 299 | updatedDiff(finalBlocksArray, blocksFromDb) 300 | ); 301 | 302 | // fs.writeFileSync( 303 | // 'from_db_1.json', 304 | // JSON.stringify(finalBlocksArray, null, 2), 305 | // 'utf8', 306 | // ); 307 | // fs.writeFileSync( 308 | // 'from_db_2.json', 309 | // JSON.stringify(blocksFromDb, null, 2), 310 | // 'utf8', 311 | // ); 312 | break; 313 | } 314 | 315 | numOperations = Math.ceil( 316 | (lastSuccessNumOperations + lastFailingOperationNum) / 2 317 | ); 318 | } 319 | } 320 | }); 321 | }); 322 | }); 323 | -------------------------------------------------------------------------------- /src/Editor/__tests__/split_block.tests.js: -------------------------------------------------------------------------------- 1 | import { EditorState } from 'draft-js'; 2 | import { 3 | assertBlockDepth, 4 | assertParentId, 5 | assertBlockCount, 6 | assertBlockPos, 7 | assertBlockText, 8 | getParentId, 9 | getBlock, 10 | sampleStateLarge, 11 | moveFocus, 12 | updateText, 13 | addNewBlock, 14 | } from '../../testHelpers.ts'; 15 | 16 | import { BASE_POS, ROOT_KEY } from '../../constants'; 17 | import { getEmptySlateState } from '../block_creators'; 18 | import { splitBlock } from '../split_block'; 19 | import { getPosNumber } from '../../testHelpers'; 20 | 21 | function getEditorWithSomeStuff() { 22 | let editorState = getEmptySlateState(ROOT_KEY); 23 | 24 | editorState = updateText(editorState, 1, 'hey there'); 25 | editorState = addNewBlock(editorState, 'how you doing?'); 26 | editorState = EditorState.moveFocusToEnd(editorState); 27 | 28 | return editorState; 29 | } 30 | 31 | describe('splitBlock function', () => { 32 | describe('block count, position, text and depth after split', () => { 33 | // write tests about the assignment of parentId and position data 34 | // attributes on the new block 35 | it('should create new empty block when split at end of a block', () => { 36 | let editorState = getEditorWithSomeStuff(); 37 | 38 | assertBlockCount(editorState, 3); 39 | editorState = splitBlock(editorState, ROOT_KEY); 40 | assertBlockCount(editorState, 4); 41 | assertBlockText(editorState, 2, 'how you doing?'); 42 | assertBlockText(editorState, 3, ''); 43 | }); 44 | 45 | it('should split text and put it in the new block when focus is in not at the end of line', () => { 46 | let editorState = getEditorWithSomeStuff(); 47 | 48 | assertBlockCount(editorState, 3); 49 | editorState = moveFocus(editorState, 2, 3); 50 | 51 | editorState = splitBlock(editorState, ROOT_KEY); 52 | // printBlocks(editorState); 53 | assertBlockCount(editorState, 4); 54 | assertBlockText(editorState, 2, 'how'); 55 | assertBlockText(editorState, 3, ' you doing?'); 56 | }); 57 | 58 | it('should create new block at same depth as block being split', () => { 59 | let editorState = getEditorWithSomeStuff(); 60 | 61 | editorState = splitBlock(editorState, ROOT_KEY); 62 | assertBlockDepth(editorState, 3, 1); 63 | }); 64 | }); 65 | 66 | describe('parentId and position data', () => { 67 | it('should give same parentId to new block as the split block', () => { 68 | let editorState = sampleStateLarge(); 69 | assertBlockCount(editorState, 50); 70 | 71 | editorState = moveFocus(editorState, 2, 3); 72 | editorState = splitBlock(editorState, ROOT_KEY); 73 | assertBlockCount(editorState, 51); 74 | assertParentId(editorState, 3, getParentId(editorState, 2)); 75 | 76 | editorState = moveFocus(editorState, 7, 3); 77 | editorState = splitBlock(editorState, ROOT_KEY); 78 | assertBlockCount(editorState, 52); 79 | assertParentId(editorState, 8, getParentId(editorState, 7)); 80 | }); 81 | 82 | it('should set block to split as parentId if that block is zoomed in', () => { 83 | let editorState = sampleStateLarge(); 84 | assertBlockCount(editorState, 50); 85 | 86 | const blockToSplitIndex = 5; 87 | const blockToSplit = getBlock(editorState, blockToSplitIndex); 88 | editorState = moveFocus(editorState, blockToSplitIndex, 3); 89 | editorState = splitBlock(editorState, blockToSplit.getKey()); 90 | assertBlockCount(editorState, 51); 91 | assertParentId(editorState, blockToSplitIndex + 1, blockToSplit.getKey()); 92 | }); 93 | // TODO: It might be better to load some existing editorState, like we do 94 | // for cypress, move selection around, and then splitBlock and test 95 | // resulting editorState. It might also not be a bad idea to have different 96 | // such jsons as fixtures and load and test whichever we want to instead of 97 | // trying to manipulate our editorState by writing these helper functions 98 | // which themselves might have bugs. 99 | it('should set position as that between 0 and first child next when splitting a normal non-collapsed parent block', () => { 100 | let editorState = sampleStateLarge(); 101 | assertBlockCount(editorState, 50); 102 | 103 | const blockToSplitIndex = 5; 104 | editorState = moveFocus(editorState, blockToSplitIndex, 3); 105 | editorState = splitBlock(editorState); 106 | assertBlockCount(editorState, 51); 107 | // verify the position of the new block 108 | assertBlockPos(editorState, blockToSplitIndex + 1, BASE_POS / 2); 109 | }); 110 | 111 | it('should set position as next possible position when splitting a child block', () => { 112 | let editorState = sampleStateLarge(); 113 | assertBlockCount(editorState, 50); 114 | 115 | const blockToSplitIndex = 21; 116 | const blockToSplit = getBlock(editorState, blockToSplitIndex); 117 | editorState = moveFocus(editorState, blockToSplitIndex, 3); 118 | editorState = splitBlock(editorState); 119 | assertBlockCount(editorState, 51); 120 | // verify the position of the new block 121 | assertBlockPos( 122 | editorState, 123 | blockToSplitIndex + 1, 124 | blockToSplit.get('data').get('pos') + BASE_POS 125 | ); 126 | }); 127 | 128 | it('should set position as next possible intermediate position when splitting a intermediate child block', () => { 129 | let editorState = sampleStateLarge(); 130 | assertBlockCount(editorState, 50); 131 | 132 | const blockToSplitIndex = 4; 133 | const blockToSplit = getBlock(editorState, blockToSplitIndex); 134 | editorState = moveFocus(editorState, blockToSplitIndex, 3); 135 | editorState = splitBlock(editorState); 136 | assertBlockCount(editorState, 51); 137 | // verify the position of the new block 138 | assertBlockPos( 139 | editorState, 140 | blockToSplitIndex + 1, 141 | // because there's a block after the split block at the same level 142 | // the new block will get a pos in between the split block and the next block 143 | blockToSplit.get('data').get('pos') + BASE_POS / 2 144 | ); 145 | }); 146 | 147 | it('should set position as the position for first position when splitting a zoomed in item', () => { 148 | let editorState = sampleStateLarge(); 149 | assertBlockCount(editorState, 50); 150 | 151 | const blockToSplitIndex = 5; 152 | const blockToSplit = getBlock(editorState, blockToSplitIndex); 153 | editorState = moveFocus(editorState, blockToSplitIndex, 3); 154 | editorState = splitBlock(editorState, blockToSplit.getKey()); 155 | assertBlockCount(editorState, 51); 156 | // because the zoomed in item has other children, it should generate a pos value in between 0 and the first item pos 157 | assertBlockPos(editorState, blockToSplitIndex + 1, BASE_POS / 2); 158 | }); 159 | 160 | it('should set position as an in between position when splitting an intermediate item in a list of items at same level', () => { 161 | let editorState = sampleStateLarge(); 162 | assertBlockCount(editorState, 50); 163 | 164 | // it's the 4th line with text 165 | // "Every document can contain infinite documents." 166 | const blockToSplitIndex = 4; 167 | const blockToSplit = getBlock(editorState, blockToSplitIndex); 168 | editorState = moveFocus( 169 | editorState, 170 | blockToSplitIndex, 171 | blockToSplit.getText().length 172 | ); 173 | editorState = splitBlock(editorState); 174 | assertBlockCount(editorState, 51); 175 | const newBlock = getBlock(editorState, blockToSplitIndex + 1); 176 | const nextBlock = getBlock(editorState, blockToSplitIndex + 2); 177 | expect(newBlock.get('data').get('pos')).toBeGreaterThan( 178 | blockToSplit.get('data').get('pos') 179 | ); 180 | expect(newBlock.get('data').get('pos')).toBeLessThan( 181 | nextBlock.get('data').get('pos') 182 | ); 183 | }); 184 | 185 | it('should split and create a block above current block if cursor is at beginning of line, for a leaf item', () => { 186 | let editorState = sampleStateLarge(); 187 | assertBlockCount(editorState, 50); 188 | const blockToSplitIndex = 3; 189 | const blockToSplit = getBlock(editorState, blockToSplitIndex); 190 | assertBlockDepth(editorState, blockToSplitIndex, 2); 191 | editorState = moveFocus(editorState, blockToSplitIndex, 0); 192 | editorState = splitBlock(editorState); 193 | assertBlockCount(editorState, 51); 194 | assertBlockText(editorState, blockToSplitIndex, ''); 195 | assertBlockText( 196 | editorState, 197 | blockToSplitIndex + 1, 198 | blockToSplit.getText() 199 | ); 200 | assertBlockDepth(editorState, blockToSplitIndex, 2); 201 | assertBlockDepth(editorState, blockToSplitIndex + 1, 2); 202 | expect(getPosNumber(editorState, blockToSplitIndex)).toBeLessThan( 203 | getPosNumber(editorState, blockToSplitIndex + 1) 204 | ); 205 | expect(getPosNumber(editorState, blockToSplitIndex + 1)).toBeLessThan( 206 | getPosNumber(editorState, blockToSplitIndex + 2) 207 | ); 208 | }); 209 | 210 | it('should split and create a block above current block if cursor is at beginning of line, for an expanded item. It should not create a child item in this case.', () => { 211 | let editorState = sampleStateLarge(); 212 | assertBlockCount(editorState, 50); 213 | const blockToSplitIndex = 5; 214 | const blockToSplit = getBlock(editorState, blockToSplitIndex); 215 | assertBlockDepth(editorState, blockToSplitIndex, 2); 216 | editorState = moveFocus(editorState, blockToSplitIndex, 0); 217 | editorState = splitBlock(editorState); 218 | assertBlockCount(editorState, 51); 219 | assertBlockText(editorState, blockToSplitIndex, ''); 220 | assertBlockText( 221 | editorState, 222 | blockToSplitIndex + 1, 223 | blockToSplit.getText() 224 | ); 225 | assertBlockDepth(editorState, blockToSplitIndex, 2); 226 | assertBlockDepth(editorState, blockToSplitIndex + 1, 2); 227 | expect(getPosNumber(editorState, blockToSplitIndex)).toBeLessThan( 228 | getPosNumber(editorState, blockToSplitIndex + 1) 229 | ); 230 | expect(getPosNumber(editorState, blockToSplitIndex)).toBeGreaterThan( 231 | getPosNumber(editorState, blockToSplitIndex - 1) 232 | ); 233 | expect(getParentId(editorState, blockToSplitIndex + 2)).toBe( 234 | blockToSplit.getKey() 235 | ); 236 | expect(getParentId(editorState, blockToSplitIndex)).toBe( 237 | blockToSplit.getIn(['data', 'parentId']) 238 | ); 239 | }); 240 | }); 241 | }); 242 | -------------------------------------------------------------------------------- /src/Editor/add_empty_block_to_end.ts: -------------------------------------------------------------------------------- 1 | import { OrderedMap } from 'immutable'; 2 | import { 3 | EditorState, 4 | ContentState, 5 | SelectionState, 6 | ContentBlock, 7 | BlockMap, 8 | } from 'draft-js'; 9 | 10 | import pluckGoodies from './pluck_goodies'; 11 | import { getNewContentBlock } from './block_creators'; 12 | import { getBlocksWithItsDescendants } from './tree_utils'; 13 | import { getPosNum, getPosAfter } from './pos_generators'; 14 | 15 | // TODO: Should instead use recreateParentBlockMap 16 | function insertBlocksAtKey( 17 | blockMap: BlockMap, 18 | blocksToInsert: Immutable.Iterable, 19 | insertionBlockKey: string, 20 | insertBefore: boolean 21 | ) { 22 | const insertionBlock = blockMap.get(insertionBlockKey); 23 | const blocksBeforeInsertionPoint = blockMap 24 | .toSeq() 25 | .takeUntil((_, k) => k === insertionBlockKey); 26 | const blocksAfterInsertionPoint = blockMap 27 | .toSeq() 28 | .skipUntil((_, k) => k === insertionBlockKey) 29 | .rest(); 30 | 31 | if (insertBefore) { 32 | return blocksBeforeInsertionPoint 33 | .concat(blocksToInsert) 34 | .concat([[insertionBlockKey, insertionBlock]]) 35 | .concat(blocksAfterInsertionPoint) 36 | .toOrderedMap(); 37 | } else { 38 | return blocksBeforeInsertionPoint 39 | .concat([[insertionBlockKey, insertionBlock]]) 40 | .concat(blocksToInsert) 41 | .concat(blocksAfterInsertionPoint) 42 | .toOrderedMap(); 43 | } 44 | } 45 | 46 | function insertBlocksAfter( 47 | blockMap: BlockMap, 48 | blocks: Immutable.Iterable, 49 | insertionBlockKey: string 50 | ) { 51 | return insertBlocksAtKey(blockMap, blocks, insertionBlockKey, false); 52 | } 53 | 54 | /** 55 | * Will add a child to the given parentBlockKey after all of it's children. 56 | * If the parentBlockKey has no children, it will create a new child and add 57 | * that. 58 | */ 59 | function appendChild( 60 | blockMap: BlockMap, 61 | parentBlockKey: string, 62 | blockToAdd: ContentBlock 63 | ) { 64 | const blockWithItsChildren = getBlocksWithItsDescendants( 65 | blockMap, 66 | parentBlockKey 67 | ); 68 | 69 | let blockToInsertAfterKey = parentBlockKey; 70 | const parentId = parentBlockKey; 71 | let pos = getPosNum(1); 72 | // if the block has some children 73 | if (blockWithItsChildren && blockWithItsChildren.count() > 1) { 74 | blockToInsertAfterKey = blockWithItsChildren.last().getKey(); 75 | pos = getPosAfter(blockWithItsChildren.last().getIn(['data', 'pos'])); 76 | } 77 | 78 | return insertBlocksAfter( 79 | blockMap, 80 | OrderedMap({ 81 | [blockToAdd.getKey()]: blockToAdd 82 | .setIn(['data', 'parentId'], parentId) 83 | .setIn(['data', 'pos'], pos) as ContentBlock, 84 | }), 85 | blockToInsertAfterKey 86 | ); 87 | } 88 | 89 | /** 90 | * Adds an empty block to the end of the list 91 | * If we are in a zoomed in state, we should add the block to the end of the 92 | * children list for the zoomedin item 93 | */ 94 | export function addEmptyBlockToEnd( 95 | editorState: EditorState, 96 | zoomedInItemId: string, 97 | depth: number 98 | ) { 99 | const { contentState, blockMap } = pluckGoodies(editorState); 100 | const newBlock = getNewContentBlock({ depth }); 101 | let newBlockMap; 102 | 103 | // if we are at the root level, we can simply add the block to end of list 104 | if (!zoomedInItemId) { 105 | newBlockMap = blockMap.set(newBlock.getKey(), newBlock); 106 | } else { 107 | // otherwise we need to add the block to end of children list of the zoomed 108 | // in item. There can be 2 cases here 109 | // 1. The zoomed in item already has children 110 | // 2. The zoomed in item does not have any children 111 | // In this case, we can add the block after the zoomedin item and then call 112 | // onTab, which will make that block the zoomed in items child 113 | // OR - let's just write a appendChild method which takes care of both cases 114 | // internally 115 | newBlockMap = appendChild(blockMap, zoomedInItemId, newBlock); 116 | } 117 | 118 | const newSelection = SelectionState.createEmpty(newBlock.getKey()); 119 | 120 | const newContentState = contentState.merge({ 121 | blockMap: newBlockMap, 122 | selectionBefore: newSelection, 123 | selectionAfter: newSelection.merge({ 124 | anchorKey: newBlock.getKey(), 125 | anchorOffset: 0, 126 | focusKey: newBlock.getKey(), 127 | focusOffset: 0, 128 | }), 129 | }) as ContentState; 130 | 131 | // Always, always use this method to modify editorState when in doubt about 132 | // how to edit the editor state. It maintains the undo/redo stack for the 133 | // stack - https://draftjs.org/docs/api-reference-editor-state#push 134 | return EditorState.forceSelection( 135 | EditorState.push(editorState, newContentState, 'add-new-item' as any), 136 | newSelection 137 | ); 138 | } 139 | -------------------------------------------------------------------------------- /src/Editor/block_creators.ts: -------------------------------------------------------------------------------- 1 | import Immutable, { List } from 'immutable'; 2 | import { 3 | CharacterMetadata, 4 | ContentBlock, 5 | EditorState, 6 | ContentState, 7 | genKey, 8 | SelectionState, 9 | } from 'draft-js'; 10 | 11 | import { getPosNum } from './pos_generators'; 12 | import { createDecorators } from './decorators'; 13 | 14 | interface ContentBlockConfig { 15 | key: string; 16 | text: string; 17 | depth: number; 18 | characterList: List; 19 | } 20 | 21 | export function getNewContentBlock(config: Partial) { 22 | return new ContentBlock({ 23 | key: genKey(), 24 | type: 'unordered-list-item', 25 | text: '', 26 | depth: 0, 27 | ...config, 28 | }); 29 | } 30 | 31 | export function getEmptyBlock() { 32 | return getNewContentBlock({ text: '' }); 33 | } 34 | 35 | export function getRootBlock(rootId: string) { 36 | return getNewContentBlock({ text: '', key: rootId }); 37 | } 38 | 39 | export function getEmptySlateState(rootId: string) { 40 | const firstItem = getEmptyBlock() 41 | .set('depth', 1) 42 | .set( 43 | 'data', 44 | Immutable.Map({ parentId: rootId, pos: getPosNum(1) }) 45 | ) as ContentBlock; 46 | const rootBlock = getRootBlock(rootId); 47 | // we add 2 blocks in our empty slate because 48 | // if we add only the root block, draftjs will then allow editing of the 49 | // root block item. 50 | // That problem goes away when i set the zoomedInItemId as 'root'. Hmm. 51 | // But then there's no starting item to work with. Which is why we need 52 | // the second empty block 53 | const firstBlocks = [rootBlock, firstItem]; 54 | 55 | // // How to get started with a list item by default 56 | // // Which is what workflowy does 57 | // // Just call RichUtils.toggleBlockType with the empty state we create at 58 | // // the beginning with 'unordered-list-item' 59 | // RichUtils.toggleBlockType(EditorState.createEmpty(), "unordered-list-item") 60 | // ); 61 | let emptySlate = EditorState.createWithContent( 62 | ContentState.createFromBlockArray(firstBlocks), 63 | createDecorators() 64 | ); 65 | emptySlate = EditorState.forceSelection( 66 | emptySlate, 67 | SelectionState.createEmpty(firstItem.getKey()) 68 | ); 69 | 70 | return emptySlate; 71 | } 72 | -------------------------------------------------------------------------------- /src/Editor/calculate_depth.ts: -------------------------------------------------------------------------------- 1 | import { BlockMap } from 'draft-js'; 2 | 3 | export function calculateDepth( 4 | blockMap: BlockMap, 5 | blockKey: string, 6 | zoomedInItemId?: string 7 | ) { 8 | let depth = -1; 9 | const block = blockMap.get(blockKey); 10 | 11 | if (!block) { 12 | return depth; 13 | } 14 | 15 | let parentBlock = blockMap.get(block.getIn(['data', 'parentId'])); 16 | 17 | while (parentBlock) { 18 | if (zoomedInItemId && parentBlock.getKey() === zoomedInItemId) { 19 | break; 20 | } 21 | 22 | depth += 1; 23 | parentBlock = blockMap.get(parentBlock.getIn(['data', 'parentId'])); 24 | } 25 | 26 | return depth; 27 | } 28 | -------------------------------------------------------------------------------- /src/Editor/collapse_expand_block.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ContentState, 3 | ContentBlock, 4 | EditorState, 5 | SelectionState, 6 | } from 'draft-js'; 7 | 8 | import pluckGoodies from './pluck_goodies'; 9 | import { getBlocksWithItsDescendants } from './tree_utils'; 10 | 11 | function toggleCollapseState( 12 | editorState: EditorState, 13 | collapseState: boolean, 14 | blockKey: string 15 | ) { 16 | const { contentState, selectionState, blockMap } = pluckGoodies(editorState); 17 | const block = blockMap.get(blockKey); 18 | 19 | // if the block does not exists 20 | // or if the collapsed state is already in the desired state, let it be 21 | if (!block || block.getIn(['data', 'collapsed']) === collapseState) { 22 | return editorState; 23 | } 24 | 25 | const blockWithItsChildren = getBlocksWithItsDescendants(blockMap, blockKey); 26 | // if this list contains just one item, it's that block itself. There are 27 | // no children to collapse 28 | if (!blockWithItsChildren || blockWithItsChildren.count() === 1) { 29 | return editorState; 30 | } 31 | 32 | const newContentState = contentState.merge({ 33 | blockMap: blockMap.set( 34 | blockKey, 35 | block.setIn(['data', 'collapsed'], collapseState) as ContentBlock 36 | ), 37 | }) as ContentState; 38 | 39 | const newSelection = new SelectionState({ 40 | anchorKey: blockKey, 41 | anchorOffset: selectionState.getAnchorOffset(), 42 | // There is a bug where when i click expand/collapse arrow of any item, the 43 | // draft-js code throws an exception. It seems to happen consistently when 44 | // i click the first item arrow with mouse. Happens randomly for other 45 | // items on and off. 46 | // And looks like it doesn't happen again if i set the focusKey to 47 | // undefined. Definitely not an ideal bug fix. 48 | // TODO: figure out why this might happen. What is the focusKey exactly 49 | // doing? 50 | focusKey: undefined, 51 | focusOffset: selectionState.getFocusOffset(), 52 | }); 53 | const newState = EditorState.push( 54 | editorState, 55 | newContentState, 56 | collapseState ? 'collapse-list' : ('expand-list' as any) 57 | ); 58 | 59 | return EditorState.forceSelection(newState, newSelection); 60 | } 61 | 62 | export function collapseBlock( 63 | editorState: EditorState, 64 | blockKey: string 65 | ): EditorState { 66 | const { anchorKey } = pluckGoodies(editorState); 67 | 68 | return toggleCollapseState(editorState, true, blockKey || anchorKey); 69 | } 70 | 71 | export function expandBlock( 72 | editorState: EditorState, 73 | blockKey: string 74 | ): EditorState { 75 | const { anchorKey } = pluckGoodies(editorState); 76 | 77 | return toggleCollapseState(editorState, false, blockKey || anchorKey); 78 | } 79 | 80 | /* 81 | * bug - if the cursor is at end of line of the list item, expand does 82 | * not work. If the cursor is anywhere else on the line, it works 83 | * Root cause - selectionState.getAnchorKey, getStartKey, getFocusKey - all 84 | * return the wrong information when the cursor is at the end of line. They 85 | * return the last child of the collapsed item as the anchor block 86 | * Once we collapse the item, the selection state goes wrong. It sets itself 87 | * to the last child of the collapsed item. Probably because draftjs also 88 | * handles command+up or command+down? don't know. 89 | * 90 | * bug - Once expanded, creating new item with 'Enter' key creates the 91 | * new item as a child of the current item. Because the selectionState is 92 | * pointing to the last child of this item. Just hiding the block with 93 | * display: none is not a viable solution. Also because we are not controlling 94 | * the wrapper li for each list item. That's controlled by draft-js. We 95 | * are just hiding the content inside that li. We might need to completely 96 | * remove those blocks from contentState when collapsing items. And maintain 97 | * a master contentState somewhere. But that would create problems of 98 | * syncing that master contentState with changed contentState while editing 99 | * a current one which has one or more collapsed items. Hmm... 100 | */ 101 | -------------------------------------------------------------------------------- /src/Editor/components/Board.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { EditorState, BlockMap } from 'draft-js'; 3 | import pluckGoodies from '../pluck_goodies'; 4 | import { getChildren } from '../tree_utils'; 5 | 6 | interface Props { 7 | editorState: EditorState; 8 | zoomedInItemId: string; 9 | } 10 | 11 | interface ColumnProps { 12 | blockMap: BlockMap; 13 | columnKey: string; 14 | } 15 | 16 | function Column(props: ColumnProps) { 17 | const { blockMap, columnKey } = props; 18 | const column = blockMap.get(columnKey); 19 | const columnItems = getChildren(blockMap, columnKey); 20 | 21 | return ( 22 |
27 |

{column.getText()}

28 |
29 | {columnItems.toArray().map(columnItem => { 30 | return ( 31 |
35 |