├── .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 | 
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 |
71 | );
72 | }
73 |
--------------------------------------------------------------------------------
/src/Editor/components/Disc.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import classNames from 'classnames';
3 |
4 | import { EditorContext } from './EditorDispatchContext';
5 | import DownArrow from '../../icons/DownArrow';
6 |
7 | import styles from './disc_styles.module.css';
8 |
9 | interface Props {
10 | collapsed: boolean;
11 | itemId: string;
12 | isCollapsible: boolean;
13 | }
14 |
15 | /*
16 | * 18. Tip - If i don't add `contentEditable={false}` in the Disc outer div,
17 | * clicking the mouse on the disc puts the cursor there, as if the whole disc
18 | * is content editable. I think draft-js, by default, makes all html elements
19 | * inside it as contenteditable. So if we don't want something as editable
20 | * entities, we have to specifiy it explicitly.
21 | *
22 | * 19. Question? - Is it better to use Entities for this? Define an entity which is
23 | * IMMUTABLE and then tell that Disc is that type of entity?
24 | */
25 | export default function Disc({ collapsed, itemId, isCollapsible }: Props) {
26 | const { onZoom, onExpandClick, onCollapseClick } = React.useContext(
27 | EditorContext
28 | );
29 |
30 | return (
31 |
82 | );
83 | }
84 |
--------------------------------------------------------------------------------
/src/Editor/components/EditorDispatchContext.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | export interface EditorDispatchContextProps {
4 | onZoom: (key: string) => void;
5 | onExpandClick: (key: string) => void;
6 | onCollapseClick: (key: string) => void;
7 | }
8 |
9 | /**
10 | * We will only put the dispatch function in this context
11 | * The dispatch value from useReducer never changes after it's first created
12 | * Which means the components which access the dispatch functions from this context
13 | * will never uncessarily rerender on other state changes not related to them
14 | */
15 | export const EditorContext = React.createContext<
16 | Partial
17 | >({});
18 |
--------------------------------------------------------------------------------
/src/Editor/components/Hashtag.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | interface Props {
4 | children: Array;
5 | handleSearchInputChange?: (searchText: string) => void;
6 | }
7 |
8 | function Hashtag({ children, handleSearchInputChange }: Props) {
9 | return (
10 |
30 | );
31 | }
32 |
33 | export default React.memo(Hashtag);
34 |
--------------------------------------------------------------------------------
/src/Editor/components/Item.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import classNames from 'classnames';
3 | import { ContentBlock, EditorBlock } from 'draft-js';
4 | import Disc from './Disc';
5 | import PlusSign from '../../icons/PlusSign';
6 | import MinusSign from '../../icons/MinusSign';
7 |
8 | import styles from './item_styles.module.css';
9 |
10 | interface Props {
11 | block: ContentBlock;
12 | blockProps: {
13 | zoomedInItemId: string;
14 | baseDepth: number;
15 | searchText?: string;
16 | hidden: boolean;
17 | onExpandClick: (blockKey: string) => void;
18 | onCollapseClick: (blockKey: string) => void;
19 | };
20 | }
21 |
22 | /**
23 | * Hacky but works
24 | * We wrap the item in nested divs each with class depth-manager mainly to get
25 | * the vertical line which connects the various item bullet dot together.
26 | * If we simply adding a margin-left of (depth * someMargin) on the item div
27 | * itself, we would not be able to show that vertical line by simply adding
28 | * a left-border and. The left border trick works only by creating the left
29 | * space in each depth-manager using a padding. It won't work with a margin-left
30 | */
31 | function wrapInNestedDivs(
32 | el: React.ReactElement,
33 | props: any,
34 | depth: number
35 | ): React.ReactElement {
36 | if (depth <= 0) {
37 | return el;
38 | }
39 |
40 | return React.createElement(
41 | 'div',
42 | props,
43 | wrapInNestedDivs(el, props, depth - 1)
44 | );
45 | }
46 |
47 | function areEqual(prevProps: Props, newProps: Props) {
48 | const {
49 | block: prevBlock,
50 | blockProps: {
51 | hidden: prevHidden,
52 | baseDepth: prevBaseDepth,
53 | searchText: prevSearchText,
54 | },
55 | } = prevProps;
56 | const {
57 | block: nextBlock,
58 | blockProps: {
59 | hidden: nextHidden,
60 | baseDepth: nextBaseDepth,
61 | searchText: nextSearchText,
62 | },
63 | } = newProps;
64 |
65 | return (
66 | prevBlock === nextBlock &&
67 | prevHidden === nextHidden &&
68 | prevBaseDepth === nextBaseDepth &&
69 | prevSearchText === nextSearchText
70 | );
71 | }
72 |
73 | export const Item = React.memo((props: Props) => {
74 | const { block, blockProps } = props;
75 | const {
76 | onExpandClick,
77 | onCollapseClick,
78 | hidden,
79 | baseDepth,
80 | zoomedInItemId,
81 | } = blockProps;
82 |
83 | const collapsed = block.getIn(['data', 'collapsed']);
84 | const completed = block.getIn(['data', 'completed']);
85 | const collapsible = block.getIn(['data', 'hasChildren']);
86 |
87 | /*
88 | * 7. When i try rendering the block on my own by wrapping the list item
89 | * EditorBlock inside my own html, it puts the whole div inside the rendered
90 | * li! So i 1. wrote css to hide the bullet for the li(s). 2. Render a small
91 | * circle like bullet of my own before the EditorBlock renders the text.
92 | *
93 | * Now i can control the various looks of the bullet based on
94 | * 1. Whether they are collapsed or not
95 | * 2. Whether user has marked it complete or not
96 | * 3. And i can add the arrow before the bullet to allow users to collapse or
97 | * expand a list item
98 | */
99 |
100 | if (hidden) {
101 | return null;
102 | }
103 |
104 | const depth = block.getDepth();
105 | // most of the conditional classes are for the zoomed in item. It's special.
106 | const itemClasses = classNames(styles['item-base'], {
107 | [styles.completed]: completed,
108 | [styles['regular-item']]: zoomedInItemId !== block.getKey(),
109 | [styles['zoomed-in-item']]: zoomedInItemId === block.getKey(),
110 | [styles['small-text']]:
111 | zoomedInItemId !== block.getKey() && depth > baseDepth + 1,
112 | });
113 |
114 | return wrapInNestedDivs(
115 |
126 | {block.getDepth() > baseDepth && (
127 |
132 | )}
133 | {/* I had to add display: 'block' to get the edit cursor to work on empty list item.
134 | Otherwise, the edit cursor was not visible on empty item. It would come after typing one
135 | charater */}
136 |