├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .nvmrc ├── CHANGELOG.md ├── README.md ├── core ├── .npmignore ├── README.md ├── jest.config.js ├── package.json ├── src │ ├── EditorState │ │ ├── EditorState.ts │ │ ├── Value.ts │ │ ├── id.ts │ │ ├── index.ts │ │ └── readme.md │ ├── ViewState │ │ ├── __tests__ │ │ │ ├── __snapshots__ │ │ │ │ └── reduceViewState.ts.snap │ │ │ └── reduceViewState.ts │ │ ├── createTextFragments.ts │ │ ├── createViewState.ts │ │ ├── getBlockByPath.ts │ │ ├── getChildrenByPath.ts │ │ ├── getFragmentByPath.ts │ │ ├── getFragmentsByPath.ts │ │ ├── getTreeNode.ts │ │ ├── index.ts │ │ ├── readme.md │ │ └── reduceViewState.ts │ ├── change │ │ ├── backspace.ts │ │ ├── backspaceToBlockStart.ts │ │ ├── backspaceToPrevWord.ts │ │ ├── change.ts │ │ ├── deleteForward.ts │ │ ├── index.ts │ │ ├── insertCharacter.ts │ │ ├── insertText.ts │ │ ├── moveFocusBack.ts │ │ ├── moveFocusForward.ts │ │ ├── redo.ts │ │ ├── removeRange.ts │ │ ├── selectAll.ts │ │ ├── splitBlock.ts │ │ ├── undo.ts │ │ ├── updateBlockEntities.ts │ │ └── updateSelection.ts │ ├── constants.ts │ ├── handlers │ │ ├── index.ts │ │ ├── onBeforeInput.ts │ │ ├── onCut.ts │ │ ├── onKeyDown.ts │ │ ├── onPaste.ts │ │ └── onSelectionChange.ts │ ├── index.ts │ ├── query │ │ ├── findAfter.ts │ │ ├── findBefore.ts │ │ ├── getBlockForIndex.ts │ │ ├── getBlockNumber.ts │ │ ├── getBlockOffset.ts │ │ ├── getBlockText.ts │ │ ├── getBlocksForRange.ts │ │ ├── getIndexAfter.ts │ │ ├── getIndexBefore.ts │ │ ├── getNextCharacterIndex.ts │ │ ├── getPreviousCharacterIndex.ts │ │ ├── index.ts │ │ └── textToListIndex.ts │ ├── selection │ │ ├── getDomRange.ts │ │ ├── getDomSelection.ts │ │ ├── getFragmentNode.ts │ │ ├── getFragmentOffset.ts │ │ ├── index.ts │ │ └── setDomSelection.ts │ ├── serialize │ │ ├── fromRaw.ts │ │ ├── fromText.ts │ │ ├── index.ts │ │ ├── readme.md │ │ ├── toText.ts │ │ ├── valueFromText.ts │ │ └── valueToText.ts │ ├── types.ts │ └── utils │ │ ├── __tests__ │ │ └── getUTF16Length.ts │ │ ├── getUCS2Position.ts │ │ ├── getUTF16Length.ts │ │ └── index.ts ├── tsconfig.json └── yarn.lock ├── editable.code-workspace ├── logo_small.png ├── package.json ├── react ├── .npmignore ├── README.md ├── babel.config.js ├── env-setup.js ├── package.json ├── src │ ├── DefaultRenderBlock.tsx │ ├── Editor.tsx │ ├── EditorBlock.tsx │ ├── EditorChildren.tsx │ ├── EditorText.tsx │ ├── __tests__ │ │ └── index.tsx │ ├── index.tsx │ └── types.ts ├── tsconfig.json └── yarn.lock ├── scripts ├── check-npm-release.js └── release.js ├── site ├── package.json ├── public │ ├── CNAME │ ├── favicon.ico │ ├── index.html │ ├── logo_small.png │ └── manifest.json ├── src │ ├── components │ │ ├── Button │ │ │ ├── index.module.css │ │ │ └── index.tsx │ │ └── index.ts │ ├── examples │ │ ├── BlockStyling │ │ │ └── index.tsx │ │ ├── Changes │ │ │ └── index.tsx │ │ ├── Draggable │ │ │ ├── index.css │ │ │ └── index.tsx │ │ ├── Fragments │ │ │ └── index.tsx │ │ ├── InputTester │ │ │ └── index.tsx │ │ ├── ItalicAndBold │ │ │ └── index.tsx │ │ ├── MarkdownTest │ │ │ ├── index.tsx │ │ │ └── prism.css │ │ ├── Mentions │ │ │ └── index.tsx │ │ ├── Nocode │ │ │ ├── Cheat.tsx │ │ │ ├── StyleEditor.tsx │ │ │ ├── index.css │ │ │ └── index.tsx │ │ ├── PlainText │ │ │ └── index.tsx │ │ ├── ReadOnly │ │ │ └── index.tsx │ │ ├── Rtl │ │ │ └── index.tsx │ │ ├── TimeTravel │ │ │ ├── Timeline.tsx │ │ │ ├── index.css │ │ │ └── index.tsx │ │ ├── Tree │ │ │ ├── index.css │ │ │ └── index.tsx │ │ ├── TypeStyler │ │ │ ├── index.css │ │ │ └── index.tsx │ │ └── index.tsx │ ├── index.css │ ├── index.tsx │ ├── logo-small.png │ ├── react-app-env.d.ts │ ├── typography.ts │ └── zettel-logo.png ├── tsconfig.json └── yarn.lock ├── slides ├── average-document-model.svg ├── components │ ├── Demo.jsx │ └── Diagram.jsx ├── deck.mdx ├── img │ ├── ck-editor-5.png │ ├── djsp.png │ ├── document-model.svg │ ├── draft-js.png │ ├── editor-products-comparison.svg │ ├── froala.png │ ├── google-docs.png │ ├── gutenberg-reviews.png │ ├── gutenberg.png │ ├── julian-logo.png │ ├── medium.png │ ├── minimal-api.png │ ├── mxstbr-draft.png │ ├── mxstbr-draft2.png │ ├── mxstbr-regrets.png │ ├── nocode_logos.png │ ├── notion.png │ ├── paper.png │ ├── react-icon.png │ ├── rich-text.png │ ├── slate-2.png │ ├── slate.png │ ├── tettra.png │ ├── tiny.png │ ├── tree.svg │ ├── trix.png │ ├── twitter-editor.png │ ├── update-model-2.svg │ ├── update-model.svg │ ├── vscode-change.png │ ├── webflow-investment.png │ ├── webflow.png │ └── zettel-model.svg ├── index.css ├── package-lock.json ├── package.json ├── theme.js ├── tree.svg └── yarn.lock ├── tsconfig.json └── yarn.lock /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | # Sequence of patterns matched against refs/tags 4 | branches: 5 | - master 6 | 7 | name: Create Release 8 | 9 | jobs: 10 | release: 11 | name: Create Release 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v2 16 | with: 17 | persist-credentials: false 18 | 19 | - name: Get yarn cache directory path 20 | id: yarn-cache-dir-path 21 | run: echo "::set-output name=dir::$(yarn cache dir)" 22 | 23 | - uses: actions/cache@v1 24 | with: 25 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 26 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 27 | restore-keys: | 28 | ${{ runner.os }}-yarn- 29 | 30 | - name: Install Dependencies 31 | run: yarn 32 | - name: Get Changelog Entry 33 | id: changelog_entry 34 | run: node scripts/release.js 35 | env: 36 | PACKAGES: core, react 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | 39 | - name: publish core package 40 | # if: ${{ steps.changelog_entry.outputs.unreleased == 'true' }} 41 | continue-on-error: true 42 | run: | 43 | npm config set //registry.npmjs.org/:_authToken=$NPM_TOKEN 44 | cd core 45 | yarn 46 | npm publish 47 | env: 48 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 49 | 50 | - name: check core package is published 51 | run: node scripts/check-npm-release.js 52 | env: 53 | PACKAGE_VERSION: ${{ steps.changelog_entry.outputs.version }} 54 | PACKAGE_NAME: core 55 | 56 | - name: publish react package 57 | # if: ${{ steps.changelog_entry.outputs.unreleased == 'true' }} 58 | continue-on-error: true 59 | run: | 60 | npm config set //registry.npmjs.org/:_authToken=$NPM_TOKEN 61 | cd react 62 | yarn 63 | npm publish 64 | env: 65 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 66 | 67 | - name: check react package is published 68 | run: node scripts/check-npm-release.js 69 | env: 70 | PACKAGE_VERSION: ${{ steps.changelog_entry.outputs.version }} 71 | PACKAGE_NAME: react 72 | 73 | - name: Create Github Release 74 | id: create_release 75 | if: ${{ steps.changelog_entry.outputs.unreleased == 'true' }} 76 | uses: actions/create-release@v1 77 | continue-on-error: true 78 | env: 79 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 80 | with: 81 | tag_name: "v${{ steps.changelog_entry.outputs.version }}" 82 | release_name: Release v${{ steps.changelog_entry.outputs.version }} 83 | body: | 84 | ${{ steps.changelog_entry.outputs.description }} 85 | 86 | ### Artifacts 87 | - [@zettel/core](https://www.npmjs.com/package/@zettel/core/v/${{ steps.changelog_entry.outputs.version }}) 88 | - [@zettel/react](https://www.npmjs.com/package/@zettel/react/v/${{ steps.changelog_entry.outputs.version }}) 89 | draft: false 90 | prerelease: false 91 | - name: Install site dependencies 92 | run: | 93 | cd site 94 | yarn 95 | yarn build 96 | - name: Deploy to Github Pages 97 | uses: peaceiris/actions-gh-pages@v3 98 | with: 99 | publish_dir: site/build 100 | github_token: ${{ secrets.GITHUB_TOKEN }} 101 | 102 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: push 2 | name: Unit Tests 3 | jobs: 4 | test: 5 | name: Test packages 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Checkout code 9 | uses: actions/checkout@v2 10 | 11 | - name: Get yarn cache directory path 12 | id: yarn-cache-dir-path 13 | run: echo "::set-output name=dir::$(yarn cache dir)" 14 | 15 | - uses: actions/cache@v1 16 | with: 17 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 18 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 19 | restore-keys: | 20 | ${{ runner.os }}-yarn- 21 | 22 | - name: Install dependencies 23 | run: yarn 24 | - name: test @zettel/core 25 | run: | 26 | cd core 27 | yarn 28 | yarn link 29 | yarn test 30 | - name: test @zettel/react 31 | run: | 32 | cd react 33 | yarn link @zettel/core 34 | yarn 35 | yarn test 36 | 37 | 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /node_modules 3 | /build 4 | dist 5 | build 6 | .next 7 | 8 | npm-debug.log* 9 | docs/genTypes.js 10 | yarn-debug.log* 11 | yarn-error.log* 12 | 13 | node_modules 14 | **/node_modules/* 15 | .DS_Store -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 10 -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Zettel 2 | 3 | ## [Unreleased] 4 | - Replace `ListState` with `Value` type for representing content. 5 | 6 | ## [0.0.23] 7 | 8 | ### Fixed 9 | - `readOnly` prop now actually makes the editor read only and removes any editor specific css from the editor and block components. 10 | - Fixed fragments rendering - now stable. 11 | 12 | ### Removed 13 | - Removed concept of entities. Not needed anymore now we have fragments. To attach any kind of data to a fragment or block just add arbitrary data to the `data` property of a `block-start` or `fragment-start` character. 14 | 15 | ## [0.0.22] - 2020-05-20 16 | 17 | ### Added 18 | - Added Fragment Model. Fragments are similar to Blocks, they can either contain text or other fragments. They can also contain metadata which makes them useful for things such as mentions. Fragments can cross block boundaries. If `[ ]` is a block boundary and `< >` a fragment boundary we can do things like: `[First Block]`. 19 | - Soft newlines are now supported and working correctly (tested in markdown example with codeblocks) 20 | 21 | ## [0.0.21] - 2020-05-18 22 | 23 | ### Changed 24 | - Tidied up list of examples, better names and better urls, removed examples which are misleading/incomplete 25 | 26 | ### Fixed 27 | - Remove unused dependencies from @zettel/core 28 | - Remove useMemo in @zettel/react which breaks draggable example 29 | 30 | ## [0.0.20] - 2019-10-29 31 | 32 | ### Added 33 | - Added tables example 34 | - Expose DefaultRenderBlock and EditorChildren from @zettel/react 35 | 36 | ### Fixed 37 | - Regression - Current styles not applied when inserting character #22 38 | 39 | ## [0.0.19] - 2019-10-28 40 | 41 | ### Fixed 42 | - Updated `setDomSelection` to look focus and anchor fragment elements with the `data-text-fragment` html attribute and add `data-text-fragment` to text-fragment render prop so we can differentiate text fragments form blocks. Fixes #18 43 | 44 | 45 | ## [0.0.17] - 2019-10-25 46 | 47 | ### Added 48 | - changelog.md 49 | 50 | ### Changed 51 | - Add block offsets to renderblock components so that all selection changes are captured. 52 | - Use mdx macro to render .md and evtl .mdx files 🎉🎉🎉 53 | - Update index page to render changelog -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Zettel](logo_small.png) 2 | 3 | ## [Zettel](https://zettel.software) 4 | 5 | Disclaimer: Project still in early stages - API subject to frequent change, use at your own risk. 6 | 7 | ### Low level text editing library 8 | 9 | [Join Zettel on Slack](https://join.slack.com/t/zetteljs/shared_invite/zt-eiih8wis-Hca9bcrvX3V728odwEqiBA) 10 | 11 | [![@zettel/core](https://badge.fury.io/js/%40zettel%2Fcore.svg)](https://badge.fury.io/js/%40zettel%2Fcore) 12 | [![@zettel/react](https://badge.fury.io/js/%40zettel%2Freact.svg)](https://badge.fury.io/js/%40zettel%2Freact) 13 | 14 | ## Getting started with @zettel/core and @zettel/react 15 | 16 | Right now we only have the packages `core` and `react`, you'll need those to get started. 17 | 18 | ``` 19 | yarn add @zettel/core @zettel/react 20 | ``` 21 | 22 | Now that you have them installed, here's a basic example of a plaintext editor: 23 | 24 | ```jsx 25 | import * as React from 'react'; 26 | import { useState } from 'react' 27 | import { EditorState } from '@zettel/core' 28 | import Editor from '@zettel/react' 29 | 30 | const text = `[One 😅Line][And another line of text][And another line]` 31 | 32 | const App = () => { 33 | const [editorState, setEditorState] = useState(() => EditorState.fromJSON({ 34 | text, 35 | ranges: [], 36 | entityMap: {} 37 | })) 38 | 39 | return ( 40 | 45 | ); 46 | } 47 | 48 | export default App; 49 | ``` 50 | 51 | For more examples [have a look here](https://github.com/juliankrispel/zettel/tree/master/site/src/examples) 52 | 53 | 54 | ## Current Roadmap 55 | 56 | This changes a lot. To focus as much as possible I'll keep this small. 57 | 58 | - [x] Firefox, Safari, Chrome support 59 | - [x] rendering text 60 | - [x] render blocks 61 | - [x] keeping selection in sync 62 | - [x] All editing operations 63 | - [x] render non-text media 64 | - [x] react view layer 65 | - [x] render text fragments 66 | - [x] redo/undo 67 | - [x] UTF-16 support for editing 68 | - [x] IME Event handling 69 | - [x] Android support (via Input Events Level 2) [thanks Trix ❤️](https://github.com/basecamp/trix/blob/master/src/trix/controllers/level_2_input_controller.coffee) 70 | - [x] [IME support](https://developer.mozilla.org/en-US/docs/Mozilla/IME_handling_guide) 71 | - [ ] [rtl support](https://github.com/juliankrispel/zettel/issues/8) 72 | - [ ] Support for parser integration 73 | - [ ] Prototype collaborative editing 74 | - [ ] Start writing docs and publishing exampples on codesandbox 75 | - [ ] Alternative view layers (Vuejs/svelte/angular) 76 | 77 | 78 | ## Thanks 79 | 80 | This project wouldn't have come this far without the influence of open source projects such as: 81 | 82 | - [draft-js](https://github.com/facebook/draft-js) 83 | - [slatejs](https://github.com/ianstormtaylor/slate) 84 | - [prosemirror](https://github.com/ProseMirror/prosemirror) 85 | - [vscode](https://github.com/Microsoft/vscode/issues) 86 | - [trix](https://github.com/basecamp/trix) 87 | -------------------------------------------------------------------------------- /core/.npmignore: -------------------------------------------------------------------------------- 1 | src -------------------------------------------------------------------------------- /core/README.md: -------------------------------------------------------------------------------- 1 | ## @zettel/core 2 | 3 | The core module for Zettel, the editor framework -------------------------------------------------------------------------------- /core/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "roots": [ 3 | "/src" 4 | ], 5 | "transform": { 6 | "^.+\\.tsx?$": "ts-jest" 7 | }, 8 | } -------------------------------------------------------------------------------- /core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@zettel/core", 3 | "version": "0.0.23", 4 | "description": "Core module of zettel", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "author": "Julian Krispel", 8 | "license": "MIT", 9 | "scripts": { 10 | "test": "../node_modules/.bin/jest .", 11 | "test:watch": "../node_modules/.bin/jest . --watch", 12 | "build": "tsc", 13 | "start": "tsc -w", 14 | "prepare": "tsc" 15 | }, 16 | "files": [ 17 | "dist" 18 | ], 19 | "devDependencies": { 20 | "@types/dom-inputevent": "^1.0.4" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /core/src/EditorState/EditorState.ts: -------------------------------------------------------------------------------- 1 | import { 2 | RawDocument, 3 | Change, 4 | Value, 5 | EditorChange, 6 | Changes, 7 | } from '../types' 8 | 9 | import rawToFlat from '../serialize/fromRaw' 10 | import change, { Update } from '../change/change' 11 | import textToFlat from '../serialize/fromText' 12 | import { undo, redo } from '../change'; 13 | 14 | const emptyValue = [] 15 | 16 | type ConstructorProps = { 17 | value: Value, 18 | currentStyles?: string[], 19 | lastChangeType?: string | null, 20 | redoStack?: Changes[], 21 | undoStack?: Changes[], 22 | start?: number, 23 | end?: number, 24 | anchorOffset?: number, 25 | focusOffset?: number, 26 | } 27 | 28 | export default class EditorState { 29 | value: Value 30 | start: number 31 | end: number 32 | anchorOffset: number 33 | focusOffset: number 34 | lastChangeType: string | null 35 | currentStyles: string[] 36 | redoStack: Changes[] = [] 37 | undoStack: Changes[] = [] 38 | 39 | constructor({ 40 | start = 0, 41 | end = 0, 42 | anchorOffset, 43 | focusOffset, 44 | value = emptyValue, 45 | lastChangeType = null, 46 | currentStyles = [], 47 | undoStack = [], 48 | redoStack = [], 49 | }: ConstructorProps) { 50 | this.value = value 51 | this.undoStack = undoStack 52 | this.currentStyles = currentStyles 53 | 54 | this.anchorOffset = typeof anchorOffset === 'number' ? anchorOffset : start 55 | this.focusOffset = typeof focusOffset === 'number' ? focusOffset : end 56 | 57 | let [_start, _end]: number[] = [this.anchorOffset, this.focusOffset].sort((a, b) => a - b) 58 | 59 | this.start = _start 60 | this.end = _end 61 | 62 | this.lastChangeType = lastChangeType 63 | this.redoStack = redoStack 64 | } 65 | 66 | change(_change: EditorChange) { 67 | const start = (typeof _change.start === 'number' ? _change.start : this.start) + 1 68 | const end = (typeof _change.end === 'number' ? _change.end : this.end) + 1 69 | 70 | const defaultChange: Change = { 71 | start: this.start, 72 | end: this.end, 73 | value: this.value.slice(start, end) 74 | } 75 | 76 | const update: Update = { 77 | value: this.value, 78 | change: { 79 | ...defaultChange, 80 | ..._change, 81 | } 82 | } 83 | 84 | const updated = change(update) 85 | let undoStack = this.undoStack 86 | const [lastUndo, ...undoRest] = this.undoStack 87 | 88 | const lastChangeType = _change.type || null 89 | 90 | if (Boolean(_change.isBoundary) === false || this.undoStack.length === 0) { 91 | undoStack = [[updated.change].concat(lastUndo || [])].concat(undoRest) 92 | } else { 93 | undoStack = [[updated.change]].concat([lastUndo || []].concat(undoRest)) 94 | } 95 | 96 | return new EditorState({ 97 | start: updated.change.start - 1, 98 | end: updated.change.end - 1, 99 | currentStyles: this.currentStyles, 100 | lastChangeType, 101 | value: updated.value, 102 | redoStack: [], 103 | undoStack, 104 | }) 105 | } 106 | 107 | undo(): EditorState { 108 | return undo(this) 109 | } 110 | 111 | redo(): EditorState { 112 | return redo(this) 113 | } 114 | 115 | setCurrentStyles(styles: string[]) { 116 | this.currentStyles = styles 117 | return this 118 | } 119 | 120 | toggleStyle( 121 | style: string, 122 | _start: number = this.start, 123 | _end: number = this.end, 124 | ): EditorState { 125 | const start:number = _start + 1 126 | const end = _end + 1 127 | const selectedValue = this.value.slice(start, end) 128 | const hasStyle = selectedValue.every(char => 129 | 'type' in char || 130 | (char.styles || []).includes(style) 131 | ) 132 | 133 | const updatedValue = selectedValue.map(char => { 134 | const newChar = { ...char } 135 | if ('char' in newChar) { 136 | if (hasStyle) { 137 | newChar.styles = (newChar.styles || []).filter(st => st !== style) 138 | } else if (!(newChar.styles || []).includes(style)) { 139 | newChar.styles = (newChar.styles || []).concat([style]) 140 | } 141 | } 142 | 143 | return newChar 144 | }) 145 | 146 | let newEditorState = this.change({ 147 | isBoundary: true, 148 | value: updatedValue 149 | }) 150 | 151 | newEditorState.start = _start 152 | newEditorState.end = _end 153 | 154 | if (!hasStyle || !this.currentStyles.includes(style)) { 155 | newEditorState.currentStyles = newEditorState.currentStyles.concat([style]) 156 | } else { 157 | newEditorState.currentStyles = newEditorState.currentStyles.filter(st => st !== style) 158 | } 159 | 160 | return newEditorState 161 | } 162 | 163 | /** 164 | * creates a new EditorState from JSON format 165 | * 166 | * @param fromJSON 167 | */ 168 | static fromJSON(json: RawDocument): EditorState { 169 | return new EditorState({ 170 | value: rawToFlat(json) 171 | }) 172 | } 173 | 174 | static fromText(text: string): EditorState { 175 | return new EditorState({ 176 | value: textToFlat(text), 177 | }) 178 | } 179 | } -------------------------------------------------------------------------------- /core/src/EditorState/Value.ts: -------------------------------------------------------------------------------- 1 | import { throwStatement } from "@babel/types"; 2 | 3 | const chunkSize = 100 4 | 5 | const chunk = (input: t[]): t[][] => 6 | input.reduce( 7 | (acc, val, index) => { 8 | if (index > chunkSize) { 9 | acc.push([]) 10 | } 11 | const _chunk = acc[acc.length - 1] 12 | _chunk.push(val) 13 | return acc 14 | }, [[]] 15 | ) 16 | 17 | class Value { 18 | chunks: t[][] 19 | 20 | constructor(array: t[]) { 21 | this.chunks = chunk(array) 22 | } 23 | 24 | map(cb: (val: t, index: number) => t) { 25 | this.chunks = this.chunks.map(chunk => { 26 | return chunk.map(cb) 27 | }) 28 | 29 | return this 30 | } 31 | 32 | replace(start: number, end: number, value: t[]) { 33 | this.chunks = this.chunks.reduce((acc, chunk) => { 34 | return acc 35 | }, this.chunks) 36 | } 37 | } -------------------------------------------------------------------------------- /core/src/EditorState/id.ts: -------------------------------------------------------------------------------- 1 | function id(): string { 2 | let firstPart:any = (Math.random() * 46656) | 0; 3 | let secondPart: any = (Math.random() * 46656) | 0; 4 | firstPart = ("000" + firstPart.toString(36)).slice(-3); 5 | secondPart = ("000" + secondPart.toString(36)).slice(-3); 6 | return `${firstPart}${secondPart}`; 7 | } 8 | 9 | export default id -------------------------------------------------------------------------------- /core/src/EditorState/index.ts: -------------------------------------------------------------------------------- 1 | import EditorState from "./EditorState"; 2 | import id from './id' 3 | export default EditorState 4 | 5 | export { 6 | id 7 | } -------------------------------------------------------------------------------- /core/src/EditorState/readme.md: -------------------------------------------------------------------------------- 1 | # EditorState 2 | 3 | State object, keeps track of content, selection and editing history. 4 | 5 | 6 | ``` 7 | ``` -------------------------------------------------------------------------------- /core/src/ViewState/__tests__/__snapshots__/reduceViewState.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`reduceViewState creates view state for empty block in between 1`] = ` 4 | Object { 5 | "blocks": Array [ 6 | Object { 7 | "blockKey": "block-1", 8 | "blockLevel": 0, 9 | "blocks": Array [], 10 | "fragments": Array [ 11 | Object { 12 | "text": "One", 13 | }, 14 | ], 15 | "styles": Array [], 16 | "value": Array [ 17 | Object { 18 | "char": "O", 19 | }, 20 | Object { 21 | "char": "n", 22 | }, 23 | Object { 24 | "char": "e", 25 | }, 26 | ], 27 | }, 28 | Object { 29 | "blockKey": "block-2", 30 | "blockLevel": 0, 31 | "blocks": Array [], 32 | "fragments": Array [], 33 | "styles": Array [], 34 | "value": Array [], 35 | }, 36 | Object { 37 | "blockKey": "block-3", 38 | "blockLevel": 0, 39 | "blocks": Array [], 40 | "fragments": Array [ 41 | Object { 42 | "text": "Two", 43 | }, 44 | ], 45 | "styles": Array [], 46 | "value": Array [ 47 | Object { 48 | "char": "T", 49 | }, 50 | Object { 51 | "char": "w", 52 | }, 53 | Object { 54 | "char": "o", 55 | }, 56 | ], 57 | }, 58 | ], 59 | } 60 | `; 61 | 62 | exports[`reduceViewState creates view state for nested blocks 1`] = ` 63 | Object { 64 | "blocks": Array [ 65 | Object { 66 | "blockKey": "block-1", 67 | "blockLevel": 0, 68 | "blocks": Array [ 69 | Object { 70 | "blockKey": "block-2", 71 | "blockLevel": 1, 72 | "blocks": Array [], 73 | "fragments": Array [ 74 | Object { 75 | "text": "Two", 76 | }, 77 | ], 78 | "styles": Array [], 79 | "value": Array [ 80 | Object { 81 | "char": "T", 82 | }, 83 | Object { 84 | "char": "w", 85 | }, 86 | Object { 87 | "char": "o", 88 | }, 89 | ], 90 | }, 91 | Object { 92 | "blockKey": "block-3", 93 | "blockLevel": 1, 94 | "blocks": Array [], 95 | "fragments": Array [ 96 | Object { 97 | "text": "Three", 98 | }, 99 | ], 100 | "styles": Array [], 101 | "value": Array [ 102 | Object { 103 | "char": "T", 104 | }, 105 | Object { 106 | "char": "h", 107 | }, 108 | Object { 109 | "char": "r", 110 | }, 111 | Object { 112 | "char": "e", 113 | }, 114 | Object { 115 | "char": "e", 116 | }, 117 | ], 118 | }, 119 | ], 120 | "fragments": Array [ 121 | Object { 122 | "text": "One", 123 | }, 124 | ], 125 | "styles": Array [], 126 | "value": Array [ 127 | Object { 128 | "char": "O", 129 | }, 130 | Object { 131 | "char": "n", 132 | }, 133 | Object { 134 | "char": "e", 135 | }, 136 | ], 137 | }, 138 | Object { 139 | "blockKey": "block-4", 140 | "blockLevel": 0, 141 | "blocks": Array [], 142 | "fragments": Array [ 143 | Object { 144 | "text": "Four", 145 | }, 146 | ], 147 | "styles": Array [], 148 | "value": Array [ 149 | Object { 150 | "char": "F", 151 | }, 152 | Object { 153 | "char": "o", 154 | }, 155 | Object { 156 | "char": "u", 157 | }, 158 | Object { 159 | "char": "r", 160 | }, 161 | ], 162 | }, 163 | ], 164 | } 165 | `; 166 | 167 | exports[`reduceViewState creates view state with fragments in between block boundaries 1`] = ` 168 | Object { 169 | "blocks": Array [ 170 | Object { 171 | "blockKey": "block-1", 172 | "blockLevel": 0, 173 | "blocks": Array [], 174 | "fragments": Array [ 175 | Object { 176 | "text": "Hi", 177 | }, 178 | Object { 179 | "data": undefined, 180 | "fragments": Array [ 181 | Object { 182 | "text": "Yo", 183 | }, 184 | ], 185 | }, 186 | ], 187 | "styles": Array [], 188 | "value": Array [ 189 | Object { 190 | "char": "H", 191 | }, 192 | Object { 193 | "char": "i", 194 | }, 195 | Object { 196 | "char": "Y", 197 | }, 198 | Object { 199 | "char": "o", 200 | }, 201 | ], 202 | }, 203 | Object { 204 | "blockKey": "block-2", 205 | "blockLevel": 0, 206 | "blocks": Array [], 207 | "fragments": Array [ 208 | Object { 209 | "text": "Wh", 210 | }, 211 | Object { 212 | "text": "o", 213 | }, 214 | ], 215 | "styles": Array [], 216 | "value": Array [ 217 | Object { 218 | "char": "W", 219 | }, 220 | Object { 221 | "char": "h", 222 | }, 223 | Object { 224 | "char": "o", 225 | }, 226 | ], 227 | }, 228 | ], 229 | } 230 | `; 231 | 232 | exports[`reduceViewState creates view state with nested fragments 1`] = ` 233 | Object { 234 | "blocks": Array [ 235 | Object { 236 | "blockKey": "block-1", 237 | "blockLevel": 0, 238 | "blocks": Array [], 239 | "fragments": Array [ 240 | Object { 241 | "text": "O", 242 | }, 243 | Object { 244 | "data": undefined, 245 | "fragments": Array [ 246 | Object { 247 | "text": "n", 248 | }, 249 | Object { 250 | "data": Object { 251 | "some": "a", 252 | }, 253 | "fragments": Array [ 254 | Object { 255 | "text": "e ", 256 | }, 257 | ], 258 | }, 259 | Object { 260 | "text": "TWo", 261 | }, 262 | ], 263 | }, 264 | ], 265 | "styles": Array [], 266 | "value": Array [ 267 | Object { 268 | "char": "O", 269 | }, 270 | Object { 271 | "char": "n", 272 | }, 273 | Object { 274 | "char": "e", 275 | }, 276 | Object { 277 | "char": " ", 278 | }, 279 | Object { 280 | "char": "T", 281 | }, 282 | Object { 283 | "char": "W", 284 | }, 285 | Object { 286 | "char": "o", 287 | }, 288 | ], 289 | }, 290 | ], 291 | } 292 | `; 293 | 294 | exports[`reduceViewState maintains block entities 1`] = ` 295 | Object { 296 | "blocks": Array [ 297 | Object { 298 | "blockKey": "block-1", 299 | "blockLevel": 0, 300 | "blocks": Array [], 301 | "fragments": Array [ 302 | Object { 303 | "text": "1", 304 | }, 305 | ], 306 | "styles": Array [], 307 | "value": Array [ 308 | Object { 309 | "char": "1", 310 | }, 311 | ], 312 | }, 313 | Object { 314 | "blockKey": "block-2", 315 | "blockLevel": 0, 316 | "blocks": Array [], 317 | "fragments": Array [ 318 | Object { 319 | "text": "2", 320 | }, 321 | ], 322 | "styles": Array [], 323 | "value": Array [ 324 | Object { 325 | "char": "2", 326 | }, 327 | ], 328 | }, 329 | ], 330 | } 331 | `; 332 | -------------------------------------------------------------------------------- /core/src/ViewState/__tests__/reduceViewState.ts: -------------------------------------------------------------------------------- 1 | import { Value } from '../../types' 2 | import id from '../../EditorState/id' 3 | import reduceViewState from '../reduceViewState' 4 | 5 | describe('reduceViewState', () => { 6 | it('maintains block entities', () => { 7 | const testState: Value = [{ 8 | blockKey: 'block-1', 9 | type: 'block-start', 10 | styles: [], 11 | }, { 12 | char: '1' 13 | }, { 14 | type: 'block-end', 15 | }, { 16 | blockKey: 'block-2', 17 | type: 'block-start', 18 | styles: [], 19 | }, { 20 | char: '2' 21 | }, { 22 | type: 'block-end', 23 | }] 24 | 25 | const res = reduceViewState({ 26 | value: testState 27 | }) 28 | 29 | expect(res).toMatchSnapshot() 30 | }) 31 | 32 | it('creates view state with fragments in between block boundaries', () => { 33 | const testState: Value = [{ 34 | blockKey: 'block-1', 35 | type: 'block-start', 36 | styles: [] 37 | }, { 38 | char: 'H' 39 | }, { 40 | char: 'i' 41 | }, { 42 | type: 'fragment-start' 43 | }, { 44 | char: 'Y' 45 | }, { 46 | char: 'o' 47 | }, { 48 | type: 'block-end', 49 | }, { 50 | blockKey: 'block-2', 51 | type: 'block-start', 52 | styles: [] 53 | }, { 54 | char: 'W' 55 | }, { 56 | char: 'h' 57 | }, { 58 | type: 'fragment-end' 59 | }, { 60 | char: 'o' 61 | }, { 62 | type: 'block-end', 63 | }] 64 | 65 | const res = reduceViewState({ 66 | value: testState, 67 | }) 68 | 69 | expect(res).toMatchSnapshot() 70 | }) 71 | 72 | it('creates view state for empty block in between', () => { 73 | const testState: Value = [{ 74 | blockKey: 'block-1', 75 | type: 'block-start', 76 | }, { 77 | char: 'O' 78 | }, { 79 | char: 'n' 80 | }, { 81 | char: 'e' 82 | }, { 83 | type: 'block-end', 84 | }, { 85 | type: 'block-start', 86 | blockKey: 'block-2', 87 | }, 88 | { 89 | type: 'block-end', 90 | }, 91 | { 92 | type: 'block-start', 93 | blockKey: 'block-3', 94 | styles: [] 95 | }, { 96 | char: 'T' 97 | }, { 98 | char: 'w' 99 | }, { 100 | char: 'o' 101 | }, { 102 | type: 'block-end', 103 | }] 104 | 105 | const res = reduceViewState({ 106 | value: testState, 107 | }) 108 | 109 | expect(res).toMatchSnapshot() 110 | }) 111 | 112 | it('creates view state for nested blocks', () => { 113 | const testState: Value = [{ 114 | blockKey: 'block-1', 115 | type: 'block-start', 116 | }, { 117 | char: 'O' 118 | }, { 119 | char: 'n' 120 | }, { 121 | char: 'e' 122 | }, { 123 | type: 'block-start', 124 | blockKey: 'block-2', 125 | }, { 126 | char: 'T' 127 | }, { 128 | char: 'w' 129 | }, { 130 | char: 'o' 131 | }, { 132 | type: 'block-end', 133 | }, { 134 | type: 'block-start', 135 | blockKey: 'block-3', 136 | styles: [] 137 | }, { 138 | char: 'T' 139 | }, { 140 | char: 'h' 141 | }, { 142 | char: 'r' 143 | }, { 144 | char: 'e' 145 | }, { 146 | char: 'e' 147 | }, { 148 | type: 'block-end', 149 | }, { 150 | type: 'block-end', 151 | }, { 152 | type: 'block-start', 153 | blockKey: 'block-4', 154 | styles: [] 155 | }, { 156 | char: 'F' 157 | }, { 158 | char: 'o' 159 | }, { 160 | char: 'u' 161 | }, { 162 | char: 'r' 163 | }, { 164 | type: 'block-end', 165 | }, 166 | ] 167 | 168 | const res = reduceViewState({ 169 | value: testState, 170 | }) 171 | 172 | expect(res).toMatchSnapshot() 173 | }) 174 | 175 | it('creates view state with nested fragments', () => { 176 | const testState: Value = [{ 177 | blockKey: 'block-1', 178 | type: 'block-start', 179 | }, { 180 | char: 'O' 181 | }, { 182 | type: 'fragment-start' 183 | }, { 184 | char: 'n' 185 | }, { 186 | type: 'fragment-start', 187 | data: { some: 'a' } 188 | }, { 189 | char: 'e' 190 | }, { 191 | char: ' ' 192 | }, { 193 | type: 'fragment-end' 194 | }, { 195 | char: 'T' 196 | }, { 197 | char: 'W' 198 | }, { 199 | char: 'o' 200 | }, { 201 | type: 'fragment-end' 202 | }, { 203 | type: 'block-end', 204 | } ] 205 | 206 | const res = reduceViewState({ 207 | value: testState, 208 | }) 209 | 210 | // console.log(JSON.stringify(res, null, 2)) 211 | expect(res).toMatchSnapshot() 212 | }) 213 | }) -------------------------------------------------------------------------------- /core/src/ViewState/createTextFragments.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TextCharacter, 3 | TextFragment, 4 | CharacterData, 5 | CharacterRange 6 | } from '../types' 7 | 8 | type CharacterMeta = CharacterData | CharacterRange 9 | 10 | function hasEqualCharacterData ( 11 | left: CharacterMeta = { styles: [] }, 12 | right: CharacterMeta = { styles: [] } 13 | ): boolean { 14 | return left != null && right != null && 15 | Array.from(left.styles || []).sort().join('') === Array.from(right.styles || []).sort().join('') 16 | } 17 | 18 | export default function createTextFragments(value: TextCharacter[]): TextFragment[] { 19 | const start: TextFragment[] = [] 20 | return value.reduce( 21 | (acc: any, data, index) => { 22 | if (acc.length < 1) { 23 | const el: TextFragment= { 24 | text: value[index].char 25 | } 26 | if (data.styles) el.styles = data.styles 27 | 28 | return [el] 29 | } else { 30 | const lastFragment = acc[acc.length - 1] 31 | if (hasEqualCharacterData(lastFragment, data)) { 32 | return acc.slice(0, -1).concat([{ 33 | ...lastFragment, 34 | text: lastFragment.text + value[index].char 35 | }]) 36 | } else { 37 | const el: TextFragment = { 38 | text: value[index].char 39 | } 40 | if (data.styles) el.styles = data.styles 41 | 42 | return [ 43 | ...acc, 44 | el 45 | ] 46 | } 47 | } 48 | }, 49 | start 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /core/src/ViewState/createViewState.ts: -------------------------------------------------------------------------------- 1 | import { TextCharacter, ListState, ViewState } from '../types' 2 | import createTextFragments from './createTextFragments' 3 | import { getNode, getNodes } from './getTreeNode' 4 | 5 | export default function createViewState ( 6 | flat: ListState, 7 | ): ViewState { 8 | const state: ViewState = { 9 | blocks: [], 10 | entityMap: flat.entityMap, 11 | } 12 | 13 | let text: TextCharacter[] = [] 14 | let path: number[] = [] 15 | 16 | flat.value.forEach((char, index) => { 17 | if ( 18 | (char.type === 'block-end' || char.type === 'block-start') 19 | && text.length > 0 20 | ) { 21 | const node = getNode(state, path) 22 | node.fragments = createTextFragments(text, flat.entityMap) 23 | node.value = text 24 | text = [] 25 | } 26 | 27 | if (char.type === 'block-start') { 28 | const blocks = getNodes(state, path) 29 | 30 | blocks.push({ 31 | fragments: [], 32 | value: [], 33 | blocks: [], 34 | blockLevel: path.length, 35 | blockKey: char.blockKey, 36 | styles: char.styles != null ? char.styles : [], 37 | entity: char.entity != null ? flat.entityMap[char.entity] : null 38 | }) 39 | 40 | path.push(blocks.length - 1) 41 | } else if (char.type === 'block-end') { 42 | path.pop() 43 | } else if (char.type === 'fragment-start') { 44 | } else if (char.type === 'fragment-end') { 45 | } else { 46 | if (path.length === 0) { 47 | throw new Error(`Invalid List State`) 48 | } 49 | text.push(char) 50 | } 51 | }) 52 | 53 | return state 54 | } -------------------------------------------------------------------------------- /core/src/ViewState/getBlockByPath.ts: -------------------------------------------------------------------------------- 1 | import { ViewState, Block } from '../types' 2 | 3 | export default function getBlockByPath (state: ViewState, path: number[]): Block { 4 | return path.reduce((acc: any, val) => { 5 | return acc.blocks[val] 6 | }, state) 7 | } 8 | -------------------------------------------------------------------------------- /core/src/ViewState/getChildrenByPath.ts: -------------------------------------------------------------------------------- 1 | import { ViewState, Block } from '../types' 2 | 3 | export default function getNodes (state: ViewState, path: number[]): Block[] { 4 | if (path.length === 0) { 5 | return state.blocks 6 | } 7 | 8 | return path.reduce((acc, val) => { 9 | return acc[val].blocks || acc 10 | }, state.blocks) 11 | } 12 | -------------------------------------------------------------------------------- /core/src/ViewState/getFragmentByPath.ts: -------------------------------------------------------------------------------- 1 | import { Block, Fragment } from '../types' 2 | 3 | export default function getFragmentByPath (block: Block, path: number[]): Fragment { 4 | return path.reduce((acc: any, val) => { 5 | return (acc.fragments || [])[val] 6 | }, block) 7 | } 8 | -------------------------------------------------------------------------------- /core/src/ViewState/getFragmentsByPath.ts: -------------------------------------------------------------------------------- 1 | import { ViewState, Block, Fragment, ContainerFragment } from '../types' 2 | 3 | export default function getFragmentsByPath (block: Block, path: number[]): Fragment[] { 4 | if (path.length === 0) { 5 | return block.fragments 6 | } 7 | 8 | return path.reduce((acc, val) => { 9 | const fragment = (acc || [])[val] as ContainerFragment 10 | return (fragment || { fragments: [] }).fragments || acc 11 | }, block.fragments) 12 | } 13 | -------------------------------------------------------------------------------- /core/src/ViewState/getTreeNode.ts: -------------------------------------------------------------------------------- 1 | import { ViewState, Block } from '../types' 2 | 3 | export function getNodes (state: ViewState, path: number[]): Block[] { 4 | if (path.length === 0) { 5 | return state.blocks 6 | } 7 | 8 | return path.reduce((acc, val) => { 9 | return acc[val].blocks || acc 10 | }, state.blocks) 11 | } 12 | 13 | export function getNode (state: ViewState, path: number[]): Block { 14 | return path.reduce((acc: any, val) => { 15 | return acc.blocks[val] 16 | }, state) 17 | } 18 | -------------------------------------------------------------------------------- /core/src/ViewState/index.ts: -------------------------------------------------------------------------------- 1 | import createBlockTree from './reduceViewState' 2 | import createTextFragments from './createTextFragments' 3 | import createViewState from './reduceViewState' 4 | import { getNode, getNodes } from './getTreeNode' 5 | 6 | export { 7 | createBlockTree, 8 | createViewState, 9 | createTextFragments, 10 | getNode, 11 | getNodes 12 | } -------------------------------------------------------------------------------- /core/src/ViewState/readme.md: -------------------------------------------------------------------------------- 1 | # ViewState 2 | 3 | - result of `createTree(ListState)` 4 | - readonly structure for rendering editor 5 | 6 | ### Example in Text 7 | 8 | ```txt 9 | [Hi up] 11 | ``` 12 | 13 | - `<`, `>` - fragment delimiters 14 | - `[`, `]` - block delimiters 15 | 16 | ### Example as Editor State 17 | 18 | ```ts 19 | [ 20 | { type: 'block-start' }, 21 | { char: 'H' }, 22 | { char: 'i' }, 23 | { char: ' ' }, 24 | { type: 'fragment-start', data: 'boing' }, 25 | { char: 'Y' }, 26 | { char: 'o' }, 27 | { char: 'u' }, 28 | { type: 'block-end' }, 29 | { type: 'block-start' }, 30 | { char: 'W' }, 31 | { char: 'h' }, 32 | { char: 'a' }, 33 | { char: 't' }, 34 | { type: 'fragment-end' }, 35 | { char: ' ' }, 36 | { char: 'U' }, 37 | { char: 'p' }, 38 | { type: 'block-end' }, 39 | ] 40 | ``` 41 | 42 | ### Example as View State 43 | 44 | ```ts 45 | [{ 46 | fragments: [{ 47 | text: 'Hi ' 48 | }, { 49 | fragments: [{ 50 | text: 'You' 51 | }] 52 | }] 53 | }, { 54 | { 55 | fragments: [{ 56 | text: 'What' 57 | }] 58 | }, 59 | fragments: [{ 60 | text: ' up' 61 | }] 62 | }] 63 | ``` 64 | 65 | ### Implementation 66 | 67 | ```ts 68 | { 69 | viewState, 70 | i, 71 | blockPath, 72 | fragPath 73 | } 74 | 75 | Reducer(Char, State) 76 | 77 | Value.reduce(Reducer, State) 78 | ``` 79 | 80 | ### Logic 81 | 82 | Given a document contains the following tokens: 83 | 84 | - `Char` 85 | - `BlockStart` 86 | - `BlockEnd` 87 | - `FragmentStart` 88 | - `FragmentEnd` -------------------------------------------------------------------------------- /core/src/ViewState/reduceViewState.ts: -------------------------------------------------------------------------------- 1 | import { Block, Fragment, Character, ListState, ViewState, TextCharacter, BlockStart, FragmentStart, ContainerFragment } from '../types' 2 | import getBlockByPath from './getBlockByPath' 3 | import getChildrenByPath from './getChildrenByPath' 4 | import getFragmentByPath from './getFragmentByPath' 5 | import createTextFragments from './createTextFragments' 6 | 7 | type ReducerState = { 8 | viewState: ViewState, 9 | i: number, 10 | currentText: TextCharacter[], 11 | blockPath: number[], 12 | fragPath: number[], 13 | } 14 | 15 | const reducer = (state: ReducerState, char: Character, i: number) => { 16 | const blocks = getChildrenByPath(state.viewState, state.blockPath) 17 | const block = getBlockByPath(state.viewState, state.blockPath) 18 | const fragment = getFragmentByPath(block, state.fragPath) 19 | const type = 'type' in char ? char.type : null 20 | 21 | if (type == null) { 22 | state.currentText.push(char as TextCharacter) 23 | return state 24 | } 25 | 26 | if (state.currentText.length > 0) { 27 | if (fragment != null && 'fragments' in fragment) { 28 | fragment.fragments = fragment.fragments.concat(createTextFragments(state.currentText)) 29 | } else { 30 | block.fragments = block.fragments.concat(createTextFragments(state.currentText)) 31 | } 32 | 33 | block.value = block.value.concat(state.currentText) 34 | state.currentText = [] 35 | } 36 | 37 | if (type === 'fragment-start') { 38 | let frag = char as FragmentStart 39 | if ('fragments' in fragment) { 40 | state.fragPath.push(fragment.fragments.length) 41 | fragment.fragments.push({ 42 | fragments: [], 43 | data: frag.data 44 | }) 45 | } else { 46 | state.fragPath.push(block.fragments.length) 47 | block.fragments.push({ 48 | fragments: [], 49 | data: frag.data 50 | }) 51 | } 52 | } else if (type === 'fragment-end') { 53 | state.fragPath.pop() 54 | } else if (type === 'block-start') { 55 | let _char = char as BlockStart 56 | const _block: Block = { 57 | fragments: [], 58 | value: [], 59 | blocks: [], 60 | blockLevel: state.blockPath.length, 61 | blockKey: _char.blockKey, 62 | styles: _char.styles != null ? _char.styles : [], 63 | } 64 | 65 | blocks.push(_block) 66 | 67 | state.blockPath.push(blocks.length - 1) 68 | } else if (type == 'block-end') { 69 | state.blockPath.pop() 70 | } 71 | 72 | return state 73 | } 74 | 75 | 76 | export default function reduceViewState ( 77 | flat: ListState, 78 | ): ViewState { 79 | /** 80 | * Given a document contains the following tokens: 81 | * `TextCharacter | BlockStart | BlockEnd | FragmentStart | FragmentEnd` 82 | */ 83 | const initialState: ReducerState = { 84 | viewState: { 85 | blocks: [], 86 | }, 87 | i: 0, 88 | blockPath: [], 89 | fragPath: [], 90 | currentText: [] 91 | } 92 | 93 | return flat.value.reduce(reducer, initialState).viewState 94 | } -------------------------------------------------------------------------------- /core/src/change/backspace.ts: -------------------------------------------------------------------------------- 1 | import EditorState from '../EditorState' 2 | import getIndexBefore from '../query/getIndexBefore' 3 | import { COMMAND } from '../constants' 4 | 5 | export default function backspace( 6 | editorState: EditorState, 7 | start: number, 8 | end: number 9 | ): EditorState { 10 | let newEditorState = editorState 11 | 12 | const previousCharIndex = getIndexBefore(editorState.value, start + 1, (ch) => { 13 | if (ch == null) { 14 | return false 15 | } 16 | 17 | if ('char' in ch) { 18 | return true 19 | } 20 | 21 | return ch.type === 'block-end'; 22 | }); 23 | 24 | if (previousCharIndex != null) { 25 | let _start = previousCharIndex - 1 26 | 27 | newEditorState = editorState.change({ 28 | isBoundary: editorState.lastChangeType !== COMMAND.BACKSPACE, 29 | type: COMMAND.BACKSPACE, 30 | start: _start, 31 | end, 32 | value: [] 33 | }).change({ 34 | type: COMMAND.BACKSPACE, 35 | start: _start, 36 | end: _start, 37 | value: [], 38 | }) 39 | } 40 | 41 | return newEditorState 42 | } -------------------------------------------------------------------------------- /core/src/change/backspaceToBlockStart.ts: -------------------------------------------------------------------------------- 1 | import EditorState from '../EditorState' 2 | import getIndexBefore from '../query/getIndexBefore'; 3 | import { COMMAND } from '../constants' 4 | 5 | export default function backspaceToBlockStart( 6 | editorState: EditorState, 7 | start: number, 8 | end: number 9 | ): EditorState { 10 | const prevChar = editorState.value[start - 1] 11 | let newEditorState = editorState 12 | 13 | if ('char' in prevChar) { 14 | const blockBeginning = getIndexBefore( 15 | editorState.value, 16 | start, 17 | (ch) => { 18 | if ('type' in ch && ch.type === 'block-start'){ 19 | return true 20 | } 21 | return false 22 | } 23 | ) 24 | 25 | if (blockBeginning != null) { 26 | newEditorState = editorState.change({ 27 | type: COMMAND.BACKSPACE_BLOCK_START, 28 | start: blockBeginning + 1, 29 | end, 30 | value: [], 31 | isBoundary: true 32 | }) 33 | } 34 | } 35 | 36 | return newEditorState 37 | } -------------------------------------------------------------------------------- /core/src/change/backspaceToPrevWord.ts: -------------------------------------------------------------------------------- 1 | import EditorState from '../EditorState' 2 | import getIndexBefore from '../query/getIndexBefore'; 3 | import { COMMAND } from '../constants' 4 | import { TextCharacter, Character } from '../types'; 5 | 6 | export default function backspaceToPrevWord( 7 | editorState: EditorState, 8 | start: number, 9 | end: number 10 | ): EditorState { 11 | let newEditorState = editorState 12 | const prevChar = editorState.value[start] 13 | 14 | if ('char' in prevChar) { 15 | let hasSpaceBefore = false 16 | let isBlockStart = false 17 | const prevWordEnd = getIndexBefore( 18 | editorState.value, 19 | start, 20 | (ch) => { 21 | if ('char' in ch) { 22 | hasSpaceBefore = [' ', '\n'].includes(ch.char) 23 | } 24 | if ('type' in ch && ch.type === 'block-start'){ 25 | isBlockStart = true 26 | return true 27 | } 28 | if ('char' in ch && hasSpaceBefore) { 29 | return true 30 | } 31 | return false 32 | } 33 | ) 34 | 35 | if (prevWordEnd != null) { 36 | newEditorState = editorState.change({ 37 | type: COMMAND.BACKSPACE_PREV_WORD, 38 | start: isBlockStart ? prevWordEnd : prevWordEnd, 39 | end, 40 | value: [], 41 | isBoundary: true 42 | }) 43 | } 44 | } 45 | 46 | return newEditorState 47 | } -------------------------------------------------------------------------------- /core/src/change/change.ts: -------------------------------------------------------------------------------- 1 | import { Value, Change } from '../types' 2 | 3 | /** 4 | * Represents the input and output for updates 5 | */ 6 | export type Update = { 7 | value: Value, 8 | change: Change 9 | } 10 | 11 | /** 12 | * 13 | * takes State and Change object, return a state 14 | * object that has been updated with the Change, and 15 | * a Change object to undo the change 16 | */ 17 | export default function change(update: Update): Update { 18 | const { value: currentValue } = update 19 | 20 | const [start, end] = [ 21 | update.change.start, 22 | update.change.end, 23 | ].sort((a, b) => a - b) 24 | 25 | const selectedValue = update.value.slice(start + 1, end + 1) 26 | let valueUpdate = update.change.value 27 | let newValue = update.value 28 | 29 | newValue = currentValue.slice(0, start + 1) 30 | .concat(valueUpdate) 31 | .concat(currentValue.slice(end + 1)) 32 | 33 | 34 | const firstChar = newValue[0] 35 | const lastChar = newValue[newValue.length - 1] 36 | 37 | if ('type' in firstChar && firstChar.type !== 'block-start') { 38 | throw new Error('First character always needs to be block-start') 39 | } else if ('type' in lastChar && lastChar.type !== 'block-end') { 40 | throw new Error('Last character always needs to be block-end') 41 | } 42 | 43 | const reverse: Change = { 44 | start: start + 1, 45 | end: end - selectedValue.length + valueUpdate.length + 1, 46 | value: selectedValue 47 | } 48 | 49 | return { 50 | value: newValue, 51 | change: reverse, 52 | } 53 | } -------------------------------------------------------------------------------- /core/src/change/deleteForward.ts: -------------------------------------------------------------------------------- 1 | import EditorState from '../EditorState' 2 | import { COMMAND } from '../constants' 3 | 4 | export default function deleteForward( 5 | editorState: EditorState, 6 | start: number, 7 | end: number 8 | ) { 9 | return editorState.change({ 10 | isBoundary: editorState.lastChangeType !== COMMAND.DELETE_FORWARD, 11 | type: COMMAND.DELETE_FORWARD, 12 | start, 13 | end, 14 | value: [] 15 | }).change({ 16 | type: COMMAND.DELETE_FORWARD, 17 | start: end + 1, 18 | end: end + 1, 19 | value: [], 20 | }) 21 | } -------------------------------------------------------------------------------- /core/src/change/index.ts: -------------------------------------------------------------------------------- 1 | import backspaceToPrevWord from './backspaceToPrevWord' 2 | import backspaceToBlockStart from './backspaceToBlockStart' 3 | import redo from './redo' 4 | import undo from './undo' 5 | import backspace from './backspace' 6 | import insertCharacter from './insertCharacter' 7 | import removeRange from './removeRange' 8 | import splitBlock from './splitBlock' 9 | import updateSelection from './updateSelection' 10 | import moveFocusBack from './moveFocusBack' 11 | import moveFocusForward from './moveFocusForward' 12 | import deleteForward from './deleteForward' 13 | import insertText from './insertText' 14 | 15 | export { 16 | undo, 17 | redo, 18 | insertText, 19 | updateSelection, 20 | deleteForward, 21 | insertCharacter, 22 | backspaceToBlockStart, 23 | removeRange, 24 | backspaceToPrevWord, 25 | backspace, 26 | splitBlock, 27 | moveFocusBack, 28 | moveFocusForward 29 | } -------------------------------------------------------------------------------- /core/src/change/insertCharacter.ts: -------------------------------------------------------------------------------- 1 | import EditorState from '../EditorState' 2 | import { COMMAND } from '../constants' 3 | 4 | export default function insertCharacter( 5 | editorState: EditorState, 6 | start: number, 7 | end: number, 8 | char: string 9 | ) { 10 | 11 | const prevValue = editorState.value[start] 12 | const nextValue = editorState.value[end + 1] 13 | 14 | const value = [{ 15 | char, 16 | styles: editorState.currentStyles 17 | }] 18 | 19 | if (char === '\n') { 20 | value.push({ 21 | char: ' ', 22 | styles: [], 23 | }) 24 | } 25 | 26 | let newEditorState = editorState.change({ 27 | isBoundary: editorState.lastChangeType !== COMMAND.INSERT_CHARACTER, 28 | type: COMMAND.INSERT_CHARACTER, 29 | start, 30 | end, 31 | value 32 | }).change({ 33 | type: COMMAND.INSERT_CHARACTER, 34 | start: start + 1, 35 | end: start + 1 36 | }) 37 | 38 | return newEditorState 39 | } -------------------------------------------------------------------------------- /core/src/change/insertText.ts: -------------------------------------------------------------------------------- 1 | import EditorState from '../EditorState' 2 | import fromText from '../serialize/valueFromText' 3 | import { COMMAND } from '../constants' 4 | 5 | export default function insertText( 6 | editorState: EditorState, 7 | start: number, 8 | end: number, 9 | text: string 10 | ) { 11 | const value = fromText(text) 12 | return editorState.change({ 13 | isBoundary: editorState.lastChangeType !== COMMAND.INSERT_CHARACTER, 14 | type: COMMAND.INSERT_TEXT, 15 | start, 16 | end, 17 | value: value, 18 | }).change({ 19 | type: COMMAND.INSERT_TEXT, 20 | start: start + value.length, 21 | end: start + value.length, 22 | value: [] 23 | }) 24 | 25 | } -------------------------------------------------------------------------------- /core/src/change/moveFocusBack.ts: -------------------------------------------------------------------------------- 1 | import EditorState from '../EditorState' 2 | import updateSelection from './updateSelection' 3 | import getPreviousCharacterIndex from '../query/getPreviousCharacterIndex' 4 | 5 | export default function moveFocusBack(editorState: EditorState): EditorState { 6 | const focusOffset = getPreviousCharacterIndex(editorState, editorState.focusOffset) 7 | return updateSelection( 8 | editorState, 9 | { 10 | anchorOffset: editorState.anchorOffset, 11 | focusOffset: focusOffset 12 | } 13 | ) 14 | } -------------------------------------------------------------------------------- /core/src/change/moveFocusForward.ts: -------------------------------------------------------------------------------- 1 | import EditorState from '../EditorState' 2 | import updateSelection from './updateSelection' 3 | import getNextCharacterIndex from '../query/getNextCharacterIndex' 4 | 5 | export default function moveFocusForward(editorState: EditorState): EditorState { 6 | return updateSelection( 7 | editorState, 8 | { 9 | anchorOffset: editorState.anchorOffset, 10 | focusOffset: getNextCharacterIndex(editorState, editorState.focusOffset) 11 | } 12 | ) 13 | } -------------------------------------------------------------------------------- /core/src/change/redo.ts: -------------------------------------------------------------------------------- 1 | import EditorState from '../EditorState' 2 | import { Changes, Change } from '../types'; 3 | import { COMMAND } from '../constants' 4 | import change from './change' 5 | 6 | export default function redo(editorState: EditorState): EditorState { 7 | if (editorState.redoStack.length === 0) { 8 | return editorState 9 | } 10 | 11 | const emptyChange: Changes = [] 12 | 13 | let newEditorState = new EditorState({ 14 | start: editorState.start, 15 | end: editorState.end, 16 | value: editorState.value, 17 | redoStack: editorState.redoStack, 18 | undoStack: [emptyChange].concat([...editorState.undoStack]) 19 | }) 20 | 21 | const [lastChanges, ...rest] = editorState.redoStack 22 | // @ts-ignore 23 | newEditorState = lastChanges.reduce((editorState: EditorState, lastChange: Change) => { 24 | const updated = change({ 25 | value: editorState.value, 26 | change: { 27 | ...lastChange, 28 | start: lastChange.start - 1, 29 | end: lastChange.end - 1, 30 | } 31 | }) 32 | 33 | const [lastUndo, ...undoStack] = editorState.undoStack 34 | 35 | return new EditorState({ 36 | start: updated.change.start, 37 | end: updated.change.end, 38 | value: updated.value, 39 | redoStack: rest, 40 | undoStack: [[updated.change].concat(lastUndo || [])].concat(undoStack), 41 | }) 42 | }, newEditorState) 43 | 44 | return new EditorState({ 45 | start: newEditorState.start - 1, 46 | lastChangeType: COMMAND.REDO, 47 | end: newEditorState.end - 1, 48 | value: newEditorState.value, 49 | redoStack: newEditorState.redoStack, 50 | undoStack: newEditorState.undoStack 51 | }) 52 | } -------------------------------------------------------------------------------- /core/src/change/removeRange.ts: -------------------------------------------------------------------------------- 1 | import EditorState from '../EditorState' 2 | import { COMMAND } from '../constants' 3 | 4 | export default function removeRange( 5 | editorState: EditorState, 6 | start: number, 7 | end: number 8 | ) { 9 | return editorState.change({ 10 | isBoundary: true, 11 | type: COMMAND.REMOVE_RANGE, 12 | start, 13 | end, 14 | value: [] 15 | }).change({ 16 | type: COMMAND.REMOVE_RANGE, 17 | start, 18 | end: start, 19 | value: [], 20 | }) 21 | } -------------------------------------------------------------------------------- /core/src/change/selectAll.ts: -------------------------------------------------------------------------------- 1 | import EditorState from '../state' 2 | import updateSelection from './updateSelection' 3 | 4 | export default function selectAll(editorState: EditorState): EditorState { 5 | return updateSelection( 6 | editorState, 7 | { 8 | start: 0, 9 | end: editorState.value.length - 2, 10 | anchorOffset: 0, 11 | focusOffset: editorState.value.length - 2 12 | } 13 | ) 14 | } -------------------------------------------------------------------------------- /core/src/change/splitBlock.ts: -------------------------------------------------------------------------------- 1 | import getBlockForIndex from '../query/getBlockForIndex' 2 | import EditorState from '../EditorState' 3 | import id from '../EditorState/id' 4 | import { COMMAND } from '../constants' 5 | import { BlockEnd } from '../types' 6 | import { BlockStart } from '../types' 7 | 8 | export default function splitBlock( 9 | editorState: EditorState, 10 | start: number, 11 | end: number 12 | ) { 13 | const { block: currentBlock } = getBlockForIndex(editorState.value, start) 14 | const blockEnd: BlockEnd = { type: 'block-end' } 15 | const blockStart: BlockStart = { styles: [], ...currentBlock, type: 'block-start', blockKey: id() } 16 | 17 | const newEditorState = editorState.change({ 18 | isBoundary: true, 19 | type: COMMAND.SPLIT_BLOCK, 20 | start, 21 | end, 22 | value: [ 23 | blockEnd, 24 | blockStart 25 | ] 26 | }).change({ 27 | type: COMMAND.SPLIT_BLOCK, 28 | start: start + 2, 29 | end: start + 2, 30 | value: [], 31 | }) 32 | 33 | return newEditorState 34 | } -------------------------------------------------------------------------------- /core/src/change/undo.ts: -------------------------------------------------------------------------------- 1 | import EditorState from '../EditorState' 2 | import change from './change' 3 | import { COMMAND } from '../constants' 4 | import { Changes, Change } from '../types'; 5 | 6 | export default function undo(editorState: EditorState): EditorState { 7 | if (editorState.undoStack.length === 0) { 8 | return editorState 9 | } 10 | 11 | const [lastChanges, ...rest] = editorState.undoStack 12 | const emptyChange: Changes = [] 13 | 14 | let newEditorState: EditorState = new EditorState({ 15 | start: editorState.start, 16 | end: editorState.end, 17 | value: editorState.value, 18 | redoStack: [emptyChange].concat([...editorState.redoStack]), 19 | undoStack: editorState.undoStack 20 | }) 21 | 22 | // @ts-ignore 23 | newEditorState = lastChanges.reduce((editorState: EditorState, lastChange: Change) => { 24 | const updated = change({ 25 | value: editorState.value, 26 | change: { 27 | ...lastChange, 28 | start: lastChange.start - 1, 29 | end: lastChange.end - 1, 30 | } 31 | }) 32 | 33 | const [lastRedo, ...redoStack] = editorState.redoStack 34 | 35 | return new EditorState({ 36 | start: updated.change.start, 37 | end: updated.change.end, 38 | value: updated.value, 39 | redoStack: [[updated.change].concat(lastRedo || [])].concat(redoStack), 40 | undoStack: rest 41 | }) 42 | }, newEditorState) 43 | 44 | return new EditorState({ 45 | start: newEditorState.start - 1, 46 | lastChangeType: COMMAND.UNDO, 47 | end: newEditorState.end - 1, 48 | value: newEditorState.value, 49 | redoStack: newEditorState.redoStack, 50 | undoStack: newEditorState.undoStack 51 | }) 52 | } -------------------------------------------------------------------------------- /core/src/change/updateBlockEntities.ts: -------------------------------------------------------------------------------- 1 | import EditorState from '../state' 2 | import getBlocksForRange from '../query/getBlocksForRange' 3 | import { COMMAND } from '../constants' 4 | 5 | export default function updateBlockEntiies ( 6 | editorState: EditorState, 7 | start: number, 8 | end: number, 9 | entity: string | null 10 | ): EditorState { 11 | return getBlocksForRange( 12 | editorState.value, 13 | start, 14 | end 15 | ).reduce((newEditorState, { block, blockOffset }) => { 16 | return newEditorState.change({ 17 | type: COMMAND.UPDATE_BLOCK_ENTITIES, 18 | start: blockOffset, 19 | end: blockOffset + 1, 20 | value: [{ 21 | ...block, 22 | entity 23 | }] 24 | }) 25 | }, editorState) 26 | .change({ 27 | type: COMMAND.UPDATE_BLOCK_ENTITIES, 28 | start, 29 | end, 30 | value: [] 31 | }) 32 | } -------------------------------------------------------------------------------- /core/src/change/updateSelection.ts: -------------------------------------------------------------------------------- 1 | import EditorState from '../EditorState' 2 | import { COMMAND } from '../constants' 3 | 4 | type SelectionStateUpdate = { 5 | start?: number, 6 | end?: number 7 | anchorOffset: number, 8 | focusOffset: number 9 | } 10 | 11 | export default function updateSelection( 12 | editorState: EditorState, 13 | selection: SelectionStateUpdate 14 | ): EditorState { 15 | return new EditorState({ 16 | lastChangeType: COMMAND.CHANGE_SELECTION, 17 | ...editorState, 18 | ...selection 19 | }) 20 | } -------------------------------------------------------------------------------- /core/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const COMMAND = { 2 | BLOCK_START: 'block-start', 3 | BLOCK_END: 'block-end', 4 | FRAGMENT_START: 'fragment-start', 5 | FRAGMENT_END: 'fragment-end', 6 | CHANGE_SELECTION: 'CHANGE_SELECTION', 7 | BACKSPACE: 'BACKSPACE', 8 | BACKSPACE_BLOCK_START: 'BACKSPACE_BLOCK_START', 9 | BACKSPACE_PREV_WORD: 'BACKSPACE_PREV_WORD', 10 | REMOVE_RANGE: 'REMOVE_RANGE', 11 | INSERT_TEXT: 'INSERT_TEXT', 12 | DELETE_FORWARD: 'DELETE_FORWARD', 13 | INSERT_CHARACTER: 'INSERT_CHARACTER', 14 | SPLIT_BLOCK: 'SPLIT_BLOCK', 15 | UNDO: 'UNDO', 16 | REDO: 'REDO', 17 | UPDATE_BLOCK_ENTITIES: 'UPDATE_BLOCK_ENTITIES' 18 | } 19 | 20 | 21 | -------------------------------------------------------------------------------- /core/src/handlers/index.ts: -------------------------------------------------------------------------------- 1 | import onCut from './onCut' 2 | import onBeforeInput from './onBeforeInput' 3 | import onKeyDown from './onKeyDown' 4 | import onPaste from './onPaste' 5 | import onSelectionChange from './onSelectionChange' 6 | 7 | export { 8 | onCut, 9 | onBeforeInput, 10 | onKeyDown, 11 | onPaste, 12 | onSelectionChange 13 | } 14 | -------------------------------------------------------------------------------- /core/src/handlers/onBeforeInput.ts: -------------------------------------------------------------------------------- 1 | import EditorState from "../EditorState"; 2 | import { 3 | backspaceToBlockStart, 4 | backspaceToPrevWord, 5 | backspace, 6 | removeRange, 7 | deleteForward, 8 | insertCharacter, 9 | insertText, 10 | splitBlock, 11 | } from '../change' 12 | import getDomRange from "../selection/getDomRange"; 13 | 14 | type InputEvent = Event & { 15 | readonly data?: string; 16 | readonly inputType?: string 17 | readonly isComposing: boolean 18 | } 19 | 20 | export default function onBeforeInput(editorState: EditorState, _event: any) { 21 | const event: InputEvent = _event 22 | let newEditorState = editorState 23 | const domRange = getDomRange(editorState.value) 24 | 25 | if (domRange == null) return newEditorState 26 | 27 | const { collapsed, start, end } = domRange 28 | 29 | event.preventDefault() 30 | event.stopPropagation() 31 | 32 | // console.log(event) 33 | 34 | if (event.inputType === 'insertText' && event.data != null && event.data.length === 1) { 35 | newEditorState = insertCharacter(newEditorState, start, end, event.data) 36 | } else if (event.inputType === 'insertText') { 37 | newEditorState = insertText(newEditorState, start, end, event.data || '') 38 | } else if (event.inputType === 'insertFromPaste') { 39 | newEditorState = insertText(newEditorState, start, end, event.data || '') 40 | } else if (event.inputType === 'insertCompositionText') { 41 | newEditorState = insertText(newEditorState, start, end, event.data || '') 42 | // } else if (event.inputType === 'insertLineBreak' && event.shiftKey) { 43 | // // soft line break 44 | // newEditorState = insertText(editorState, start, end, '\n') 45 | } else if (event.inputType === 'insertLineBreak') { 46 | newEditorState = splitBlock(newEditorState, start, end) 47 | } else if (!collapsed && event.inputType === 'deleteContentBackward') { 48 | newEditorState = removeRange(editorState, start, end) 49 | } else if (event.inputType === 'deleteContentBackward') { 50 | newEditorState = backspace(editorState, start, end) 51 | } else if (event.inputType === 'deleteWordBackward') { 52 | newEditorState = backspaceToPrevWord(editorState, start, end) 53 | } else if (event.inputType === 'deleteSoftLineBackward') { 54 | newEditorState = backspaceToBlockStart(editorState, start, end) 55 | } else if (event.inputType === 'deleteContentForward') { 56 | newEditorState = deleteForward(editorState, start, end) 57 | } 58 | 59 | return newEditorState 60 | } -------------------------------------------------------------------------------- /core/src/handlers/onCut.ts: -------------------------------------------------------------------------------- 1 | import EditorState from "../EditorState"; 2 | import { removeRange } from '../change' 3 | 4 | export default function onCut(editorState: EditorState, event: ClipboardEvent) { 5 | event.preventDefault() 6 | document.execCommand('copy') 7 | return removeRange(editorState, editorState.start, editorState.end) 8 | } -------------------------------------------------------------------------------- /core/src/handlers/onKeyDown.ts: -------------------------------------------------------------------------------- 1 | import getDomSelection from '../selection/getDomSelection' 2 | import EditorState from '../EditorState'; 3 | import { 4 | moveFocusBack, 5 | moveFocusForward, 6 | undo, 7 | redo, 8 | updateSelection, 9 | backspaceToBlockStart, 10 | backspaceToPrevWord, 11 | backspace, 12 | removeRange, 13 | splitBlock, 14 | deleteForward, 15 | insertText, 16 | insertCharacter, 17 | } from '../change' 18 | import change from '../change/change'; 19 | 20 | // @ts-ignore 21 | const inputEventSupported = (new InputEvent('insertText')).getTargetRanges != null 22 | const actionKeys = ['Backspace', 'Delete', 'Meta', 'Alt', 'Enter', 'Control', 'Shift', 'Tab', 'Escape', 'CapsLock'] 23 | 24 | const isCharacterInsert = (e: KeyboardEvent) => 25 | e.key !== 'Unidentified' && 26 | !e.altKey && 27 | !e.metaKey && 28 | !e.ctrlKey && 29 | !e.key.includes('Arrow') && 30 | !actionKeys.includes(e.key) 31 | 32 | const isMoveFocus = (e: KeyboardEvent) => e.shiftKey && ['ArrowLeft', 'ArrowRight'].includes(e.key) 33 | const isSelectAll = (e: KeyboardEvent) => e.metaKey && e.key.toLowerCase() === 'a' 34 | const isUndo = (e: KeyboardEvent) => !e.shiftKey && e.metaKey && e.key === 'z' 35 | const isRedo = (e: KeyboardEvent) => e.shiftKey && e.metaKey && e.key === 'z' 36 | 37 | export default function handleKeyDown (editorState: EditorState, event: KeyboardEvent): EditorState | void | null { 38 | // newEditorState is the value that gets returned by this function 39 | // if it is still undefined when being 'returned' no editor change should occur 40 | // and the event shouldn't be cancelled (i.e. no event.preventDefault()) 41 | let newEditorState 42 | 43 | let position = getDomSelection(editorState.value) 44 | if (position === null) { 45 | console.warn('cant get start and end selection, resume with current state') 46 | position = { 47 | start: editorState.start, 48 | end: editorState.end, 49 | anchorOffset: editorState.anchorOffset, 50 | focusOffset: editorState.focusOffset 51 | } 52 | } 53 | 54 | const { start, end } = position 55 | const isCollapsed = start === end 56 | 57 | if (isUndo(event)) { 58 | // undo 59 | newEditorState = undo(editorState) 60 | } else if (isMoveFocus(event)) { 61 | if (event.key === 'ArrowLeft' && event.metaKey) { 62 | // move focus back to block start 63 | } else if (event.key === 'ArrowLeft' && event.altKey) { 64 | // move focus back to prev word 65 | } else if (event.key === 'ArrowLeft') { 66 | // move focus back to prev char 67 | newEditorState = moveFocusBack(editorState) 68 | } else if (event.key === 'ArrowRight' && event.metaKey) { 69 | // move focus forward to block end 70 | } else if (event.key === 'ArrowRight' && event.altKey) { 71 | // move focus forward to word end 72 | } else if (event.key === 'ArrowRight') { 73 | newEditorState = moveFocusForward(editorState) 74 | } 75 | 76 | } else if (isSelectAll(event)) { 77 | newEditorState = updateSelection( 78 | editorState, 79 | { 80 | start: 0, 81 | end: editorState.value.length - 2, 82 | anchorOffset: 0, 83 | focusOffset: editorState.value.length - 2 84 | } 85 | ) 86 | } else if (isRedo(event)) { 87 | // redo 88 | newEditorState = redo(editorState) 89 | } 90 | 91 | // soft linebreaks 92 | if (event.key === 'Enter' && event.shiftKey) { 93 | newEditorState = insertCharacter(editorState, start, end, '\n') 94 | } 95 | 96 | if (newEditorState == null && !inputEventSupported) { 97 | if (isCollapsed && event.key === 'Backspace' && event.metaKey === true) { 98 | // backspaceToBlockStart 99 | newEditorState = backspaceToBlockStart(editorState, start, end) 100 | } else if (isCollapsed && event.key === 'Backspace' && event.altKey === true) { 101 | // backspaceToPrevWord 102 | newEditorState = backspaceToPrevWord(editorState, start, end) 103 | } else if (event.key === 'Backspace' && isCollapsed) { 104 | // backspace 105 | newEditorState = backspace(editorState, start, end) 106 | } else if (event.key === 'Backspace' && !isCollapsed) { 107 | // removeRange 108 | newEditorState = removeRange(editorState, start, end) 109 | } else if (event.key === 'Enter') { 110 | // splitBlock 111 | newEditorState = splitBlock(editorState, start, end) 112 | } else if (event.key === 'Delete' && isCollapsed) { 113 | // deleteForward 114 | newEditorState = deleteForward(editorState, start, end) 115 | } else if (event.key === 'Delete' && !isCollapsed) { 116 | // removeRange 117 | newEditorState = removeRange(editorState, start, end) 118 | } else if (isCharacterInsert(event)) { 119 | // insertCharacter 120 | 121 | newEditorState = insertCharacter( 122 | editorState, 123 | start, 124 | end, 125 | event.key 126 | ) 127 | } 128 | } 129 | 130 | return newEditorState 131 | } 132 | -------------------------------------------------------------------------------- /core/src/handlers/onPaste.ts: -------------------------------------------------------------------------------- 1 | import EditorState from "../EditorState"; 2 | import valueFromText from '../serialize/valueFromText' 3 | import getDomSelection from "../selection/getDomSelection"; 4 | 5 | export default function onPaste(editorState: EditorState, event: ClipboardEvent) { 6 | const position = getDomSelection(editorState.value) 7 | 8 | if (position === null) { 9 | console.warn('cant get start and end selection') 10 | return editorState 11 | } 12 | 13 | event.preventDefault() 14 | 15 | const { start, end } = position 16 | 17 | if (event.clipboardData == null) { 18 | return editorState 19 | } 20 | 21 | const text = event.clipboardData.getData('text') 22 | const value = valueFromText(text) 23 | 24 | const changed = editorState.change({ 25 | start, 26 | end, 27 | value, 28 | }).change({ 29 | start: start + value.length, 30 | end: start + value.length, 31 | value: [] 32 | }) 33 | 34 | return changed 35 | } -------------------------------------------------------------------------------- /core/src/handlers/onSelectionChange.ts: -------------------------------------------------------------------------------- 1 | import EditorState from '../EditorState' 2 | import { updateSelection } from '../change' 3 | import { getDomRange } from '../selection' 4 | 5 | export default function onSelectionChange(editorState: EditorState) { 6 | const result = getDomRange(editorState.value) 7 | 8 | if (result != null) { 9 | const { start, end } = result 10 | return updateSelection( 11 | editorState, 12 | { 13 | anchorOffset: start, 14 | focusOffset: end 15 | } 16 | ) 17 | } 18 | } -------------------------------------------------------------------------------- /core/src/index.ts: -------------------------------------------------------------------------------- 1 | import EditorState from './EditorState' 2 | 3 | export { EditorState } 4 | export * from './utils' 5 | export * from './types' 6 | export * from './handlers' 7 | export * from './change' 8 | export * from './query' 9 | export * from './ViewState' 10 | export * from './serialize' 11 | export * from './selection' -------------------------------------------------------------------------------- /core/src/query/findAfter.ts: -------------------------------------------------------------------------------- 1 | import { Value, Character } from "../types"; 2 | 3 | export default function findAfter( 4 | value: Value, 5 | startIndex: number, 6 | find: (ch: Character) => boolean 7 | ): Character | void { 8 | for (let i = startIndex; i < value.length; i++) { 9 | if (find(value[i])) { 10 | return value[i] 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /core/src/query/findBefore.ts: -------------------------------------------------------------------------------- 1 | import { Value, Character } from "../types"; 2 | 3 | export default function findBefore( 4 | value: Value, 5 | startIndex: number, 6 | find: (ch: Character) => boolean 7 | ): Character | void { 8 | for (let i = startIndex; i >= 0; i--) { 9 | if(find(value[i])) { 10 | return value[i] 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /core/src/query/getBlockForIndex.ts: -------------------------------------------------------------------------------- 1 | import { Value, BlockStart } from "../types"; 2 | 3 | type BlockAndIndex = { 4 | block: BlockStart | null, 5 | blockOffset: number 6 | } 7 | 8 | export default function getBlockForIndex(value: Value, index: number): BlockAndIndex { 9 | let block: BlockStart | null = null 10 | let blockOffset: number = 0 11 | 12 | const curVal = value[index] 13 | 14 | if (curVal != null && 'type' in curVal && curVal.type === 'block-start') { 15 | return { 16 | blockOffset: index, 17 | block: curVal 18 | } 19 | } 20 | 21 | for (let i = 0; i <= index; i++) { 22 | const val = value[i] 23 | if (val != null && 'type' in val && val.type === 'block-start') { 24 | block = val 25 | blockOffset = i 26 | } 27 | } 28 | 29 | return { 30 | blockOffset, 31 | block 32 | } 33 | } -------------------------------------------------------------------------------- /core/src/query/getBlockNumber.ts: -------------------------------------------------------------------------------- 1 | import { Value, BlockStart } from "../types"; 2 | 3 | type BlockAndIndex = { 4 | block: BlockStart | null, 5 | blockOffset: number 6 | } 7 | 8 | export default function getBlockNumber(value: Value, blockKey: string): number | void { 9 | let block: BlockStart | null = null 10 | 11 | for (let i = 0; i <= value.length; i++) { 12 | const val = value[i] 13 | 14 | if ('type' in val && val.type === 'block-start' && val.blockKey === blockKey) { 15 | return i 16 | } 17 | i++ 18 | } 19 | } -------------------------------------------------------------------------------- /core/src/query/getBlockOffset.ts: -------------------------------------------------------------------------------- 1 | import { Value } from "../types"; 2 | 3 | export default function getBlockOffset(value: Value, key: string): number | null { 4 | for (let i = 0; i < value.length; i++) { 5 | const val = value[i] 6 | if ('type' in val && val.type === 'block-start' && val.blockKey === key) { 7 | return i 8 | } 9 | } 10 | 11 | return null 12 | } -------------------------------------------------------------------------------- /core/src/query/getBlockText.ts: -------------------------------------------------------------------------------- 1 | import { Value, TextCharacter } from '../types' 2 | import getBlockOffset from './getBlockOffset' 3 | import getIndexAfter from './getIndexAfter' 4 | 5 | /** 6 | * Get text characters for given block 7 | */ 8 | export default function getBlockValue(value: Value, blockKey: string): TextCharacter[] | null { 9 | const blockOffset = getBlockOffset(value, blockKey) 10 | if (blockOffset == null) { 11 | return null 12 | } 13 | 14 | const blockEnd = getIndexAfter( 15 | value, 16 | blockOffset, 17 | ch => 'type' in ch && (ch.type === 'block-end' || ch.type === 'block-start') 18 | ) 19 | // @ts-ignore 20 | return value.slice(blockOffset + 1, blockEnd) 21 | } -------------------------------------------------------------------------------- /core/src/query/getBlocksForRange.ts: -------------------------------------------------------------------------------- 1 | import { Value, BlockStart } from "../types"; 2 | import getBlockForIndex from './getBlockForIndex' 3 | 4 | type BlockAndIndex = { 5 | block: BlockStart | null, 6 | blockOffset: number 7 | } 8 | 9 | /** 10 | * Gets block indeces for range (start, end) 11 | */ 12 | export default function getBlocksForRange(value: Value, start: number, end: number): BlockAndIndex[] { 13 | const firstBlock = getBlockForIndex(value, start) 14 | const blocks: BlockAndIndex[] = [firstBlock] 15 | const firstBlockKey = firstBlock != null && firstBlock.block != null && firstBlock.block.blockKey 16 | 17 | return value.slice(start, end).reduce((acc, ch, index) => { 18 | if ( 19 | 'type' in ch && 20 | ch.type === 'block-start' && 21 | ch.blockKey !== firstBlockKey 22 | ) { 23 | return acc.concat({ 24 | blockOffset: start + index, 25 | block: ch 26 | }) 27 | } 28 | return acc 29 | }, blocks) 30 | } -------------------------------------------------------------------------------- /core/src/query/getIndexAfter.ts: -------------------------------------------------------------------------------- 1 | import { Value, Character } from "../types"; 2 | 3 | export default function getIndexAfter( 4 | value: Value, 5 | startIndex: number, 6 | find: (ch: Character) => boolean 7 | ): number | null { 8 | for (let i = startIndex + 1; i < value.length; i++) { 9 | if (find(value[i])) { 10 | return i 11 | } 12 | } 13 | 14 | return null 15 | } -------------------------------------------------------------------------------- /core/src/query/getIndexBefore.ts: -------------------------------------------------------------------------------- 1 | import { Value, Character } from "../types"; 2 | 3 | export default function getIndexBefore( 4 | value: Value, 5 | startIndex: number, 6 | find: (ch: Character) => boolean 7 | ): number | null { 8 | for (let i = startIndex - 1; i >= -1; i--) { 9 | if (find(value[i])) { 10 | return i 11 | } 12 | } 13 | 14 | return null 15 | } -------------------------------------------------------------------------------- /core/src/query/getNextCharacterIndex.ts: -------------------------------------------------------------------------------- 1 | import EditorState from '../EditorState' 2 | import getIndexAfter from './getIndexAfter' 3 | 4 | export default function getNextCharacterIndex(editorState: EditorState, currIndex: number): number { 5 | const index = getIndexAfter(editorState.value, currIndex, ch => { 6 | return 'char' in ch 7 | }) 8 | 9 | if (index != null) { 10 | return index 11 | } else { 12 | return currIndex 13 | } 14 | } -------------------------------------------------------------------------------- /core/src/query/getPreviousCharacterIndex.ts: -------------------------------------------------------------------------------- 1 | import EditorState from '../EditorState' 2 | import getIndexBefore from './getIndexBefore' 3 | 4 | export default function getNextCharacterIndex(editorState: EditorState, currIndex: number): number { 5 | const index = getIndexBefore(editorState.value, currIndex, ch => { 6 | return 'char' in ch 7 | }) 8 | 9 | if (index != null) { 10 | return index 11 | } else { 12 | return currIndex 13 | } 14 | } -------------------------------------------------------------------------------- /core/src/query/index.ts: -------------------------------------------------------------------------------- 1 | import findAfter from './findAfter' 2 | import findBefore from './findBefore' 3 | import getBlockForIndex from './getBlockForIndex' 4 | import getBlockNumber from './getBlockNumber' 5 | import getBlockOffset from './getBlockOffset' 6 | import getBlocksForRange from './getBlocksForRange' 7 | import getBlockText from './getBlockText' 8 | import getIndexAfter from './getIndexAfter' 9 | import getIndexBefore from './getIndexBefore' 10 | import getNextCharacterIndex from './getNextCharacterIndex' 11 | import getPreviousCharacterIndex from './getPreviousCharacterIndex' 12 | import textToListIndex from './textToListIndex' 13 | 14 | export { 15 | findAfter, 16 | findBefore, 17 | getBlockForIndex, 18 | getBlockNumber, 19 | getBlockOffset, 20 | getBlocksForRange, 21 | getBlockText, 22 | getIndexAfter, 23 | getIndexBefore, 24 | getNextCharacterIndex, 25 | getPreviousCharacterIndex, 26 | textToListIndex, 27 | } -------------------------------------------------------------------------------- /core/src/query/textToListIndex.ts: -------------------------------------------------------------------------------- 1 | import { Value } from '../types' 2 | 3 | /** 4 | * Maps text index to list index 5 | */ 6 | export default function textToListIndex(value: Value, textIndex: number): number { 7 | let offset = 0 8 | 9 | for (let i = 0; i < value.length; i++) { 10 | const ch = value[i] 11 | if (textIndex === offset && 'char' in ch) { 12 | return i 13 | } else if ('char' in ch) { 14 | offset++ 15 | } 16 | } 17 | 18 | return offset 19 | } -------------------------------------------------------------------------------- /core/src/selection/getDomRange.ts: -------------------------------------------------------------------------------- 1 | import { Value, SelectionRange } from '../types' 2 | import getFragmentOffset from './getFragmentOffset' 3 | import { getUTF16Length } from '../utils' 4 | import getFragmentNode from './getFragmentNode' 5 | 6 | export default function getDomRange(value: Value): SelectionRange | null { 7 | const domSelection = window.getSelection() 8 | 9 | if (domSelection == null || domSelection.anchorNode == null) { 10 | return null 11 | } 12 | 13 | const range = domSelection.getRangeAt(0) 14 | const endContainer: any = range.endContainer 15 | const startContainer: any = range.startContainer 16 | const fragmentOffsetEnd = getFragmentOffset(value, endContainer) || 0 17 | const fragmentOffsetStart = getFragmentOffset(value, startContainer) || 0 18 | const anchorFragmentNode = getFragmentNode(startContainer) 19 | const focusFragmentNode = getFragmentNode(endContainer) 20 | 21 | let { startOffset, endOffset } = range 22 | 23 | if (anchorFragmentNode != null && focusFragmentNode != null) { 24 | startOffset = getUTF16Length(anchorFragmentNode.innerText.slice(0, startOffset)) 25 | endOffset = getUTF16Length(focusFragmentNode.innerText.slice(0, endOffset)) 26 | } 27 | 28 | const direction = anchorFragmentNode != null && getComputedStyle(anchorFragmentNode).direction === 'rtl' ? 'rtl' : 'ltr' 29 | 30 | const start = startOffset + fragmentOffsetStart 31 | const end = endOffset + fragmentOffsetEnd 32 | 33 | return { 34 | start, 35 | end, 36 | collapsed: range.collapsed, 37 | direction 38 | } 39 | } -------------------------------------------------------------------------------- /core/src/selection/getDomSelection.ts: -------------------------------------------------------------------------------- 1 | import { Value, SelectionState } from "../types"; 2 | import getBlockOffset from '../query/getBlockOffset' 3 | import { getUTF16Length } from '../utils' 4 | import getFragmentNode from './getFragmentNode' 5 | 6 | export default (value: Value): SelectionState | null => { 7 | const domSelection = window.getSelection() 8 | 9 | if (domSelection == null || domSelection.anchorNode == null) { 10 | return null 11 | } 12 | 13 | let { 14 | anchorOffset, 15 | focusOffset, 16 | } = domSelection 17 | 18 | 19 | const _anchorNode = domSelection.anchorNode as HTMLElement 20 | const _focusNode = domSelection.focusNode as HTMLElement 21 | 22 | const anchorNode = getFragmentNode(_anchorNode) 23 | const focusNode = getFragmentNode(_focusNode) 24 | 25 | if (anchorNode != null && focusNode != null) { 26 | anchorOffset = getUTF16Length(anchorNode.innerText.slice(0, anchorOffset)) 27 | focusOffset = getUTF16Length(focusNode.innerText.slice(0, focusOffset)) 28 | } 29 | 30 | if (anchorNode == null || focusNode == null) { 31 | return null 32 | } 33 | 34 | const anchorKey = anchorNode.dataset.blockKey 35 | const focusKey = focusNode.dataset.blockKey 36 | 37 | const anchorFragmentOffset = parseInt(anchorNode.dataset.fragmentStart) 38 | const focusFragmentOffset = parseInt(focusNode.dataset.fragmentStart) 39 | 40 | anchorOffset+= (getBlockOffset(value, anchorKey) || 0) + anchorFragmentOffset 41 | focusOffset+= (getBlockOffset(value, focusKey) || 0) + focusFragmentOffset 42 | 43 | const [start, end] = [anchorOffset, focusOffset].sort((a, b) => a - b) 44 | 45 | return { 46 | start, 47 | end, 48 | anchorOffset, 49 | focusOffset 50 | } 51 | } -------------------------------------------------------------------------------- /core/src/selection/getFragmentNode.ts: -------------------------------------------------------------------------------- 1 | interface ElementWithDataSet extends HTMLElement { 2 | readonly dataset: { 3 | fragmentStart: string, 4 | blockKey: string 5 | } 6 | } 7 | 8 | export default function getFragmentNode (el: HTMLElement | null): ElementWithDataSet | null { 9 | if (el == null) { 10 | return null 11 | } 12 | 13 | if (el.dataset && el.dataset.blockKey != null && el.dataset.fragmentStart != null) { 14 | const _el: any = el 15 | return _el 16 | } else if (el.parentElement) { 17 | return getFragmentNode(el.parentElement) 18 | } 19 | 20 | return null 21 | } 22 | -------------------------------------------------------------------------------- /core/src/selection/getFragmentOffset.ts: -------------------------------------------------------------------------------- 1 | import { Value } from '../types' 2 | import getBlockOffset from '../query/getBlockOffset' 3 | 4 | interface ElementWithDataSet extends HTMLElement { 5 | readonly dataset: { 6 | fragmentStart: string, 7 | blockKey: string 8 | } 9 | } 10 | 11 | const getFragmentNode = (el: HTMLElement | null): ElementWithDataSet | null => { 12 | if (el == null) { 13 | return null 14 | } 15 | 16 | if (el.dataset && el.dataset.blockKey != null && el.dataset.fragmentStart != null) { 17 | const _el: any = el 18 | return _el 19 | } else if (el.parentElement) { 20 | return getFragmentNode(el.parentElement) 21 | } 22 | 23 | return null 24 | } 25 | 26 | export default function getFragmentOffset (value: Value, node: HTMLElement) { 27 | const fragment = getFragmentNode(node) 28 | if (fragment == null) return null 29 | 30 | const { 31 | fragmentStart, 32 | blockKey 33 | } = fragment.dataset 34 | const blockOffset = getBlockOffset(value, blockKey) 35 | 36 | if (blockOffset == null) return null 37 | return blockOffset + parseInt(fragmentStart) 38 | } 39 | -------------------------------------------------------------------------------- /core/src/selection/index.ts: -------------------------------------------------------------------------------- 1 | import getDomRange from './getDomRange' 2 | import getDomSelection from './getDomSelection' 3 | import getFragmentOffset from './getFragmentOffset' 4 | import setDomSelection from './setDomSelection' 5 | 6 | export { 7 | getDomRange, 8 | getDomSelection, 9 | getFragmentOffset, 10 | setDomSelection, 11 | } -------------------------------------------------------------------------------- /core/src/selection/setDomSelection.ts: -------------------------------------------------------------------------------- 1 | import getBlockForIndex from '../query/getBlockForIndex' 2 | import EditorState from '../EditorState' 3 | import { getUCS2Position } from '../utils' 4 | import getFragmentNode from './getFragmentNode' 5 | 6 | const findRangeTarget = (el: Node | null): Node | null => { 7 | if (el == null) { 8 | return null 9 | } else if (['#text', 'BR'].includes(el.nodeName)) { 10 | return el 11 | } else if (el.childNodes) { 12 | const childNodes = Array.from(el.childNodes) 13 | 14 | for (let i = 0; i <= childNodes.length; i++) { 15 | let child = findRangeTarget(childNodes[i]) 16 | if (child != null) { 17 | return child 18 | } 19 | } 20 | } 21 | 22 | return null 23 | } 24 | 25 | export default function setDomSelection( 26 | editorState: EditorState, 27 | containerNode: HTMLElement, 28 | ): void { 29 | const { value } = editorState 30 | 31 | const { 32 | block: focusBlock, 33 | blockOffset: focusBlockOffset, 34 | } = getBlockForIndex(value, editorState.focusOffset) 35 | 36 | const { 37 | block: anchorBlock, 38 | blockOffset: anchorBlockOffset, 39 | } = getBlockForIndex(value, editorState.anchorOffset) 40 | 41 | if (focusBlock == null || anchorBlock == null) { 42 | console.warn('cannot select current start and end position') 43 | return 44 | } 45 | 46 | const anchorNodes = containerNode.querySelectorAll(`[data-text-fragment="true"][data-block-key="${anchorBlock.blockKey}"]`) 47 | const focusNodes = containerNode.querySelectorAll(`[data-text-fragment="true"][data-block-key="${focusBlock.blockKey}"]`) 48 | let anchorOffset = editorState.anchorOffset - anchorBlockOffset 49 | let focusOffset = editorState.focusOffset - focusBlockOffset 50 | 51 | const anchorFragment: any = Array.from(anchorNodes).find((node: any) => { 52 | return parseInt(node.dataset.fragmentStart) <= anchorOffset && 53 | parseInt(node.dataset.fragmentEnd) >= anchorOffset 54 | }) 55 | 56 | if (anchorFragment == null) { 57 | return 58 | } 59 | 60 | const anchorFragmentOffset = parseInt(anchorFragment.dataset.fragmentStart) 61 | 62 | const focusFragment: any = Array.from(focusNodes).find((node: any) => { 63 | return parseInt(node.dataset.fragmentStart) <= focusOffset && 64 | parseInt(node.dataset.fragmentEnd) >= focusOffset 65 | }) 66 | 67 | if (focusFragment == null) { 68 | return 69 | } 70 | 71 | const focusFragmentOffset = parseInt(focusFragment.dataset.fragmentStart) 72 | 73 | const anchorNode = findRangeTarget(anchorFragment) as HTMLElement 74 | const focusNode = findRangeTarget(focusFragment) as HTMLElement 75 | 76 | const newSelection = window.getSelection() 77 | 78 | anchorOffset = anchorOffset - anchorFragmentOffset 79 | focusOffset = focusOffset - focusFragmentOffset 80 | 81 | const fragmentAnchorNode = getFragmentNode(anchorNode) 82 | const fragmentFocusNode = getFragmentNode(focusNode) 83 | 84 | if (fragmentAnchorNode != null && anchorOffset !== 0) { 85 | anchorOffset = getUCS2Position(fragmentAnchorNode.innerText, anchorOffset) 86 | } 87 | 88 | if (fragmentFocusNode != null && focusOffset !== 0) { 89 | focusOffset = getUCS2Position(fragmentFocusNode.innerText, focusOffset) 90 | } 91 | 92 | if (newSelection != null && anchorNode != null && focusNode != null) { 93 | newSelection.setBaseAndExtent( 94 | anchorNode, 95 | anchorOffset, 96 | focusNode, 97 | focusOffset 98 | ) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /core/src/serialize/fromRaw.ts: -------------------------------------------------------------------------------- 1 | import { BlockStart, Value, BlockEnd, Character, RawDocument, ListState, TextCharacter } from '../types' 2 | import id from '../EditorState/id' 3 | 4 | const fromRaw = (raw: RawDocument): Value => { 5 | const value: Value = [] 6 | 7 | let ignore = true 8 | 9 | const text = raw.text 10 | 11 | for (let char of text) { 12 | if (char === '[') { 13 | ignore = false 14 | 15 | const val: BlockStart = { 16 | type: 'block-start', 17 | styles: [], 18 | blockKey: id() 19 | } 20 | value.push(val) 21 | } else if (char === ']') { 22 | ignore = true 23 | 24 | const val: BlockEnd = { 25 | type: 'block-end' 26 | } 27 | value.push(val) 28 | } else if (ignore === false) { 29 | const val: Character = { 30 | char, 31 | styles: [], 32 | } 33 | value.push(val) 34 | } 35 | } 36 | 37 | raw.ranges.forEach(({ offset, length, ...charData }) => { 38 | for (var i = offset; i < offset + length; i++) { 39 | const item = value[i] 40 | if ('char' in item || item.type === 'block-start') { 41 | const newValue = { 42 | ...item, 43 | ...charData, 44 | } 45 | 46 | value[i] = newValue 47 | } 48 | } 49 | }) 50 | 51 | return value 52 | } 53 | 54 | export default fromRaw -------------------------------------------------------------------------------- /core/src/serialize/fromText.ts: -------------------------------------------------------------------------------- 1 | import { Value } from '../types' 2 | import raw from './fromRaw' 3 | 4 | const textToFlat = (text: string): Value => { 5 | return raw({ 6 | text: `[${text.replace(/\n/gi, '][')}]`, 7 | ranges: [], 8 | }) 9 | } 10 | 11 | export default textToFlat -------------------------------------------------------------------------------- /core/src/serialize/index.ts: -------------------------------------------------------------------------------- 1 | import fromRaw from './fromRaw' 2 | import fromText from './fromText' 3 | import toText from './toText' 4 | import valueToText from './valueToText' 5 | import valueFromText from './valueFromText' 6 | 7 | export { 8 | fromRaw, 9 | fromText, 10 | valueToText, 11 | toText, 12 | valueFromText 13 | } -------------------------------------------------------------------------------- /core/src/serialize/readme.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juliankrispel/zettel/e45e0ae95b96a08f80184860c66cc8a22d9682f7/core/src/serialize/readme.md -------------------------------------------------------------------------------- /core/src/serialize/toText.ts: -------------------------------------------------------------------------------- 1 | import EditorState from '../EditorState' 2 | import { TextCharacter } from '../types' 3 | 4 | export default function toText(editorState: EditorState) { 5 | return editorState 6 | 7 | .value 8 | .filter(ch => 'char' in ch || ch.type !== 'block-end') 9 | .map(ch => { 10 | if ('char' in ch) { 11 | return ch.char 12 | } else { 13 | return '\n' 14 | } 15 | }).join('') 16 | } -------------------------------------------------------------------------------- /core/src/serialize/valueFromText.ts: -------------------------------------------------------------------------------- 1 | import { Value } from '../types' 2 | import raw from './fromRaw' 3 | 4 | const valueFromText = (text: string): Value => { 5 | return raw({ 6 | text: `[${text.replace(/\n/gi, '][')}]`, 7 | ranges: [] 8 | }).slice(1, -1) 9 | } 10 | 11 | export default valueFromText -------------------------------------------------------------------------------- /core/src/serialize/valueToText.ts: -------------------------------------------------------------------------------- 1 | import { Value } from '../types' 2 | import raw from './fromRaw' 3 | 4 | const valueToText = (val: Value): string => { 5 | return val 6 | .map(ch => 'char' in ch ? ch.char : null) 7 | .filter(ch => ch != null).join('') 8 | } 9 | 10 | export default valueToText -------------------------------------------------------------------------------- /core/src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Range 3 | */ 4 | export type SelectionRange = { 5 | start: number, 6 | end: number, 7 | collapsed: boolean, 8 | direction: 'ltr' | 'rtl' 9 | } 10 | 11 | /** 12 | * State of a document. 13 | * Used to create derived states such as BlockTree, 14 | * RawDocument or any other 15 | */ 16 | export type ListState = { 17 | value: Value, 18 | } 19 | 20 | /** 21 | * Array of Characters 22 | */ 23 | export type Value = Character[] 24 | 25 | export type Character = (TextCharacter | BlockStart | BlockEnd | FragmentStart | FragmentEnd) 26 | 27 | /** 28 | * [ 29 | * { char: 'A', styles: ['bold', 'italic'] }, 30 | * { type: 'block-start' }, 31 | * { char: 'B', styles: ['bold', 'italic'] }, 32 | * { type: 'block-start' }, 33 | * { char: 'C', styles: ['bold', 'italic'] }, 34 | * { type: 'block-end' }, 35 | * { type: 'block-end' }, 36 | * ] 37 | */ 38 | 39 | export type CharacterData = { 40 | styles?: string[] 41 | } 42 | 43 | /* 44 | * Represents one text character. 45 | * Contains ucs2 text symbol (1-2 code points) 46 | * as well as array of 47 | * styles and entity 48 | */ 49 | export type TextCharacter = CharacterData & { 50 | char: string 51 | } 52 | 53 | export type SelectionState = { 54 | start: number, 55 | end: number, 56 | anchorOffset: number, 57 | focusOffset: number 58 | } 59 | 60 | /** 61 | * Represents the beginning of a block 62 | */ 63 | export type BlockStart = CharacterData & { 64 | blockKey: string, 65 | type: 'block-start' 66 | } 67 | 68 | /** 69 | * Represents the end of a block 70 | */ 71 | export type BlockEnd = { 72 | type: 'block-end', 73 | } 74 | 75 | /** 76 | * Represents the beginning of a fragment 77 | */ 78 | export type FragmentStart = { 79 | type: 'fragment-start', 80 | data?: any 81 | } 82 | 83 | /** 84 | * Represents the end of a fragment 85 | */ 86 | export type FragmentEnd = { 87 | type: 'fragment-end', 88 | } 89 | 90 | export type TextFragment = { 91 | text: string, 92 | styles?: string[], 93 | } 94 | 95 | /** 96 | * Embodies all possible changes made to doc 97 | */ 98 | export type Change = { 99 | start: number, 100 | end: number, 101 | value: Value 102 | } 103 | 104 | export type EditorChange = { 105 | start?: number, 106 | end?: number, 107 | value?: Value, 108 | isBoundary?: boolean, 109 | type?: string 110 | } 111 | 112 | /** 113 | * History 114 | */ 115 | export type Changes = Change[] 116 | 117 | /** 118 | * Represents raw form of a Zettel Document, 119 | * serializable and easier on the human eye 120 | */ 121 | 122 | export type RawDocument = { 123 | text: string, 124 | ranges: RawRange[] 125 | } 126 | 127 | /** 128 | * Ranges in RawDocument 129 | * Used to style text and relate to entities 130 | */ 131 | export type RawRange = { 132 | offset: number, 133 | length: number, 134 | styles: string[] 135 | } 136 | 137 | export type CharacterRange = RawRange 138 | 139 | export type Path = number[] 140 | 141 | /** 142 | * Tree representation of content. Used for 143 | * rendering only 144 | */ 145 | export type ViewState = { 146 | blocks: Block[] 147 | } 148 | 149 | export type ContainerFragment = { 150 | fragments: Fragment[], 151 | data: any 152 | } 153 | 154 | export type Fragment = ContainerFragment | TextFragment 155 | 156 | /** 157 | * Represents one node in a tree 158 | * Used for rendering 159 | */ 160 | export type Block = { 161 | fragments: Fragment[], 162 | value: TextCharacter[], 163 | blockKey: string, 164 | blockLevel: number, 165 | blocks: Block[], 166 | styles: string[] 167 | } -------------------------------------------------------------------------------- /core/src/utils/__tests__/getUTF16Length.ts: -------------------------------------------------------------------------------- 1 | import getUTF16Length from '../getUTF16Length' 2 | 3 | describe('getUTF16Length', () => { 4 | it('gets the 16 bit length of a string', () => { 5 | const text = '😅' 6 | expect(getUTF16Length(text)).toBe(1) 7 | }); 8 | }) -------------------------------------------------------------------------------- /core/src/utils/getUCS2Position.ts: -------------------------------------------------------------------------------- 1 | export default function getUCS2Position(text: string, utf16index: number) { 2 | let utf16length = 0 3 | let length = 0 4 | for (let ch of text) { 5 | utf16length++ 6 | length+= ch.length 7 | if (utf16index === utf16length) { 8 | break 9 | } 10 | } 11 | return length 12 | } -------------------------------------------------------------------------------- /core/src/utils/getUTF16Length.ts: -------------------------------------------------------------------------------- 1 | export default function getUTF16Length(string: string) { 2 | let length = 0 3 | for (let _ of string) { 4 | length++ 5 | } 6 | return length 7 | } -------------------------------------------------------------------------------- /core/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import getUTF16Length from './getUTF16Length' 2 | import getUCS2Position from './getUCS2Position' 3 | 4 | export { 5 | getUTF16Length, 6 | getUCS2Position 7 | } -------------------------------------------------------------------------------- /core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": ["jest"], 4 | "lib": ["es2017", "es7", "es6", "dom"], 5 | "module": "commonjs", 6 | "target": "es2015", 7 | "declaration": true, 8 | "sourceMap": true, 9 | "outDir": "dist", 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "removeComments": true 13 | }, 14 | "include": [ 15 | "src/index.ts" 16 | ], 17 | "exclude": [ 18 | "**/*.spec.ts", 19 | "node_modules", 20 | "dist", 21 | "**/__tests__" 22 | ] 23 | } -------------------------------------------------------------------------------- /core/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@types/dom-inputevent@^1.0.4": 6 | version "1.0.4" 7 | resolved "https://registry.yarnpkg.com/@types/dom-inputevent/-/dom-inputevent-1.0.4.tgz#05bc0bbaed7006f74af6afa9fc897f402783ab42" 8 | integrity sha512-sdIvtcS03YVuXLID1KBFwsATwgTsuClHstO2EJ8DpBDJM2zdact+fANVUyG088JxLRhYdWuf9Ly6H26VnF3ULA== 9 | -------------------------------------------------------------------------------- /editable.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": {} 8 | } -------------------------------------------------------------------------------- /logo_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juliankrispel/zettel/e45e0ae95b96a08f80184860c66cc8a22d9682f7/logo_small.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zettel", 3 | "version": "0.0.0", 4 | "homepage": "https://zettel.software", 5 | "description": "Mono repo for zettel", 6 | "private": true, 7 | "dependencies": { 8 | "@actions/core": "^1.2.4", 9 | "@actions/github": "^2.2.0", 10 | "@types/jest": "^24.0.17", 11 | "@types/node": "12.0.4", 12 | "@types/react": "^16.8.23", 13 | "@types/react-dom": "^16.8.4", 14 | "axios": "^0.19.2", 15 | "changelog-parser": "^2.8.0", 16 | "gh-pages": "^2.1.1", 17 | "global": "^4.4.0", 18 | "lorem-ipsum": "^2.0.3", 19 | "react-scripts": "^3.4.1", 20 | "typescript": "^3.9.2" 21 | }, 22 | "eslintConfig": { 23 | "extends": "react-app" 24 | }, 25 | "browserslist": { 26 | "production": [ 27 | ">0.2%", 28 | "not dead", 29 | "not op_mini all" 30 | ], 31 | "development": [ 32 | "last 1 chrome version", 33 | "last 1 firefox version", 34 | "last 1 safari version" 35 | ] 36 | }, 37 | "devDependencies": { 38 | "@babel/parser": "^7.4.5", 39 | "@babel/preset-env": "^7.9.6", 40 | "@babel/preset-react": "^7.9.4", 41 | "@babel/types": "^7.4.4", 42 | "@testing-library/react": "^10.0.4", 43 | "babel-jest": "^26.0.1", 44 | "concurrently": "^4.1.0", 45 | "eslint-plugin-vue": "^5.2.2", 46 | "jest": "^26.0.1", 47 | "jest-cli": "^24.9.0", 48 | "react-test-renderer": "^16.13.1", 49 | "ts-jest": "^24.0.2" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /react/.npmignore: -------------------------------------------------------------------------------- 1 | src -------------------------------------------------------------------------------- /react/README.md: -------------------------------------------------------------------------------- 1 | ## @zettel/react 2 | 3 | The react module for Zettel, the editor framework -------------------------------------------------------------------------------- /react/babel.config.js: -------------------------------------------------------------------------------- 1 | // babel.config.js 2 | module.exports = { 3 | presets: ['@babel/preset-env', '@babel/preset-react'], 4 | }; -------------------------------------------------------------------------------- /react/env-setup.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = async () => { 3 | console.log('yoyoyo') 4 | 5 | // @ts-ignore 6 | global.window.InputEvent = class InputEvent extends window.Event {} 7 | window.InputEvent = class InputEvent extends window.Event {} 8 | }; -------------------------------------------------------------------------------- /react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@zettel/react", 3 | "version": "0.0.23", 4 | "description": "React view library for zettel", 5 | "main": "dist/index.js", 6 | "author": "Julian Krispel", 7 | "license": "MIT", 8 | "scripts": { 9 | "build": "tsc", 10 | "start": "tsc -w", 11 | "prepare": "tsc", 12 | "test": "../node_modules/.bin/jest .", 13 | "test:watch": "../node_modules/.bin/jest . --watch" 14 | }, 15 | "dependencies": { 16 | "@zettel/core": "0.0.23" 17 | }, 18 | "peerDependencies": { 19 | "react": "16.8.6", 20 | "react-dom": "16.8.6" 21 | }, 22 | "files": [ 23 | "dist" 24 | ], 25 | "devDependencies": { 26 | "@types/dom-inputevent": "^1.0.4", 27 | "@types/react": "^16.8.19", 28 | "@types/react-dom": "^16.8.4" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /react/src/DefaultRenderBlock.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { RenderBlock } from './types' 3 | import EditorChildren from './EditorChildren' 4 | 5 | const DefaultRenderBlock: RenderBlock = ({ 6 | htmlAttrs, 7 | children, 8 | block, 9 | ...renderProps 10 | }) => { 11 | return
12 | {children} 13 | {(block.blocks.length > 0) && ( 14 | 19 | )} 20 |
21 | } 22 | 23 | export default DefaultRenderBlock -------------------------------------------------------------------------------- /react/src/Editor.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useLayoutEffect, useRef, useState, useMemo } from 'react' 3 | import { 4 | EditorState, 5 | setDomSelection, 6 | createViewState, 7 | onKeyDown, 8 | ViewState, 9 | onBeforeInput, 10 | onSelectionChange 11 | } from '@zettel/core' 12 | import DefaultRenderBlock from './DefaultRenderBlock' 13 | import { RenderProps } from './types' 14 | import EditorChildren from './EditorChildren' 15 | 16 | type Props = RenderProps & { 17 | onChange: (editorState: EditorState) => void, 18 | editorState: EditorState, 19 | viewState?: ViewState, 20 | htmlAttrs?: Object, 21 | onKeyDown?: (event: React.KeyboardEvent) => EditorState | void, 22 | readOnly?: boolean, 23 | } 24 | 25 | const editorStyles: React.CSSProperties = { 26 | whiteSpace: 'pre-wrap', 27 | WebkitUserModify: 'read-write-plaintext-only', 28 | // @ts-ignore 29 | WebkitLineBreak: 'after-white-space', 30 | overflowWrap: 'break-word', 31 | userSelect: 'text', 32 | outline: 'none' 33 | } 34 | 35 | /** 36 | * Editor Component 37 | */ 38 | const Editor = (props: Props): React.ReactElement => { 39 | const { 40 | editorState: _editorState, 41 | onChange: _onChange, 42 | readOnly, 43 | htmlAttrs, 44 | renderTextFragment, 45 | renderStyle, 46 | renderBlock = DefaultRenderBlock, 47 | renderChildren, 48 | } = props 49 | 50 | const onChange = (args: any) => _onChange(args) 51 | const ref = useRef(null) 52 | const [isComposing, setComposing] = useState(false) 53 | const editorState = _editorState 54 | 55 | let handlerProps = { } 56 | 57 | if (!readOnly) { 58 | handlerProps = { 59 | onCompositionStart: () => setComposing(true), 60 | onCompositionEnd: () => { 61 | onChange(onSelectionChange(editorState)); 62 | setComposing(false); 63 | }, 64 | onSelect: () => { 65 | const newEditorState = onSelectionChange(editorState) 66 | const { start, end } = editorState 67 | 68 | if (newEditorState != null && 69 | (start !== newEditorState.start|| end !== newEditorState.end) 70 | && !isComposing 71 | ) { 72 | onChange(newEditorState) 73 | } 74 | }, 75 | onKeyDown: (event: React.KeyboardEvent) => { 76 | let handled = null 77 | 78 | if (props.onKeyDown != null) { 79 | handled = props.onKeyDown(event) 80 | } 81 | 82 | if (handled == null) { 83 | handled = onKeyDown(editorState, event.nativeEvent) 84 | } 85 | 86 | if (handled != null) { 87 | event.preventDefault() 88 | onChange(onSelectionChange(editorState)) 89 | onChange(handled) 90 | } 91 | } 92 | } 93 | } 94 | 95 | /* 96 | * we need to enforce our selection 97 | */ 98 | useLayoutEffect(() => { 99 | const container = ref.current 100 | if (container != null && !isComposing && !readOnly) { 101 | setDomSelection(editorState, container) 102 | } 103 | }) 104 | 105 | useLayoutEffect(() => { 106 | const el: any = ref != null ? ref.current : null 107 | if (el != null && !readOnly) { 108 | // @ts-ignore 109 | const beforeinput = (event: InputEvent) => { 110 | const newEditorState = onBeforeInput(editorState, event) 111 | if (newEditorState != null) { 112 | onChange(newEditorState) 113 | } 114 | } 115 | 116 | el.addEventListener('beforeinput', beforeinput) 117 | return () => { 118 | el.removeEventListener('beforeinput', beforeinput) 119 | } 120 | } 121 | }, [ref.current, editorState, readOnly]) 122 | 123 | const divProps = { 124 | ...htmlAttrs, 125 | style: readOnly ? {} : { ...editorStyles }, 126 | contentEditable: readOnly ? false : true, 127 | } 128 | 129 | console.log({ editorState }) 130 | const viewState = props.viewState || useMemo(() => createViewState(editorState), [isComposing || editorState]) 131 | const children = 139 | 140 | 141 | return ( 142 |
150 | {children} 151 |
152 | ); 153 | } 154 | 155 | export default Editor; -------------------------------------------------------------------------------- /react/src/EditorBlock.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Block } from '@zettel/core' 3 | import EditorText from './EditorText' 4 | import { RenderProps } from './types' 5 | 6 | type Props = RenderProps & { 7 | block: Block 8 | } 9 | 10 | const style = { 11 | WebkitUserModify: 'read-write-plaintext-only', 12 | position: 'relative', 13 | whiteSpace: 'pre-wrap', 14 | overflowWrap: 'break-word', 15 | } 16 | 17 | export default function EditorBlock(props: Props) { 18 | const { 19 | block: _block, 20 | ...renderProps 21 | } = props 22 | 23 | const RenderBlock = renderProps.renderBlock 24 | let block = _block 25 | 26 | let htmlAttrs: any = {} 27 | 28 | if (!props.readOnly) { 29 | htmlAttrs = { 30 | 'data-block-key': block.blockKey, 31 | 'data-fragment-start': 0, 32 | 'data-fragment-end': block.value.length, 33 | style, 34 | } 35 | } 36 | 37 | const content = <> 38 | 43 | 44 | 45 | if (RenderBlock != null) { 46 | return {content} 51 | } 52 | 53 | return content 54 | } -------------------------------------------------------------------------------- /react/src/EditorChildren.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Block } from '@zettel/core' 3 | import EditorBlock from './EditorBlock' 4 | import { RenderProps } from './types' 5 | import { Value } from '@zettel/core' 6 | 7 | type Props = RenderProps & { 8 | mapBlockValue?: (val: Value) => Value, 9 | blocks: Block[], 10 | block?: Block 11 | } 12 | 13 | export default function EditorBlockChildren(props: Props) { 14 | const { 15 | blocks, 16 | block, 17 | readOnly, 18 | ...renderProps 19 | } = props 20 | 21 | const { 22 | renderChildren: RenderChildren 23 | } = renderProps 24 | 25 | const content = <>{props.blocks.map(block => 26 | , 32 | )} 33 | 34 | if (RenderChildren != null) { 35 | return 39 | {content} 40 | 41 | } 42 | 43 | return content 44 | } -------------------------------------------------------------------------------- /react/src/EditorText.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Block, Fragment, TextFragment } from '@zettel/core' 3 | import { RenderProps } from './types' 4 | 5 | type TextProps = RenderProps & { 6 | block: Block, 7 | } 8 | 9 | const mapTextFramgent = (props: TextProps, offset: number, fragment: TextFragment) => { 10 | const { 11 | block, 12 | renderStyle: RenderStyle, 13 | renderTextFragment: RenderTextFragment, 14 | } = props 15 | 16 | const key = `${block.blockKey}-${offset}` 17 | 18 | const fragmentProps = props.readOnly ? {} : { 19 | key, 20 | 'data-block-key': block.blockKey, 21 | 'data-text-fragment': true, 22 | 'data-fragment-start': offset, 23 | 'data-fragment-end': offset + fragment.text.length 24 | } 25 | 26 | const fragmentText: any = fragment.text 27 | 28 | let textFragment: React.ReactElement = (fragment.styles || []).reduce((children, val) => { 29 | if (RenderStyle != null) { 30 | return {children} 31 | } else { 32 | return children 33 | } 34 | }, {fragmentText} 38 | ) 39 | 40 | 41 | if (RenderTextFragment) { 42 | textFragment = {textFragment} 46 | } 47 | 48 | return textFragment 49 | } 50 | 51 | const reduceFragments = (props: TextProps, _offset: number = 0, fragments: Fragment[]): { rendered: any[], offset: number } => { 52 | return fragments.reduce( 53 | ({ offset, rendered }, fragment) => { 54 | if ('fragments' in fragment) { 55 | const reducedFragments = reduceFragments(props, offset + 1, fragment.fragments) 56 | 57 | return { 58 | rendered: rendered.concat([reducedFragments.rendered]), 59 | offset: reducedFragments.offset + 1 60 | } 61 | } else { 62 | const renderedFragment = mapTextFramgent(props, offset, fragment) 63 | return { 64 | offset: offset + fragment.text.length, 65 | rendered: rendered.concat([renderedFragment]) 66 | } 67 | } 68 | }, 69 | { offset: _offset, rendered: ([] as any[]) } 70 | ) 71 | } 72 | 73 | export default function EditorText(props: TextProps) { 74 | const { 75 | block, 76 | renderStyle: RenderStyle, 77 | renderTextFragment: RenderTextFragment, 78 | } = props 79 | 80 | let textFragments: React.ReactNode = null 81 | 82 | if (block.value.length > 0) { 83 | /* 84 | * If the block has content, split it up into fragments and render the fragments 85 | */ 86 | textFragments = reduceFragments(props, 0, block.fragments).rendered 87 | /* 88 | * Render an empty block 89 | */ 90 | } else { 91 | textFragments =
98 | } 99 | 100 | return <>{textFragments} 101 | } -------------------------------------------------------------------------------- /react/src/__tests__/index.tsx: -------------------------------------------------------------------------------- 1 | // import * as React from 'react' 2 | // import { EditorState } from '@zettel/core' 3 | // import Editor from '../Editor' 4 | // import { render } from '@testing-library/react' 5 | 6 | 7 | describe('hello', () => { 8 | it.skip('can render plain text', () => { 9 | // const text = `[One 😅Line][And another line of text][And another line]` 10 | // const App = () => { 11 | // const [editorState, setEditorState] = React.useState(() => EditorState.fromJSON({ 12 | // text, 13 | // ranges: [], 14 | // entityMap: {} 15 | // })) 16 | 17 | // return ( 18 | // 23 | // ); 24 | // } 25 | 26 | // console.log(render()) 27 | 28 | }) 29 | }) -------------------------------------------------------------------------------- /react/src/index.tsx: -------------------------------------------------------------------------------- 1 | import Editor from './Editor' 2 | import EditorChildren from './EditorChildren' 3 | import EditorText from './EditorText' 4 | import DefaultRenderBlock from './DefaultRenderBlock' 5 | export * from './types' 6 | 7 | export { 8 | EditorText, 9 | EditorChildren, 10 | DefaultRenderBlock, 11 | } 12 | 13 | export default Editor -------------------------------------------------------------------------------- /react/src/types.ts: -------------------------------------------------------------------------------- 1 | import { TextFragment, Block } from '@zettel/core' 2 | 3 | type FragmentRenderProps = { 4 | fragment: TextFragment 5 | } 6 | 7 | /** 8 | * RenderStyle 9 | * 10 | * 11 | */ 12 | export type RenderStyle = React.FunctionComponent<{ 13 | style: string, 14 | children: React.ReactElement 15 | }> 16 | 17 | export type RenderTextFragment = React.FunctionComponent<{ 18 | children: React.ReactElement 19 | }> 20 | 21 | export type RenderBlock = React.FunctionComponent<{ 22 | block: Block, 23 | htmlAttrs: Object, 24 | readOnly?: boolean, 25 | children: React.ReactElement 26 | }> 27 | 28 | export type RenderChildren = React.FunctionComponent<{ 29 | block?: Block, 30 | children: React.ReactElement 31 | }> 32 | 33 | export type RenderAtom = React.FunctionComponent<{ 34 | block?: Block 35 | }> 36 | 37 | export type RenderProps = { 38 | readOnly?: boolean, 39 | mapBlock?: (block: Block) => Block, 40 | renderBlock?: RenderBlock, 41 | renderChildren?: RenderChildren, 42 | renderStyle?: RenderStyle, 43 | renderTextFragment?: RenderTextFragment, 44 | } -------------------------------------------------------------------------------- /react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": ["@zettel/core", "jest"], 4 | "lib": ["es2017", "es7", "es6", "dom"], 5 | "module": "commonjs", 6 | "target": "es2015", 7 | "declaration": true, 8 | "outDir": "dist", 9 | "sourceMap": true, 10 | "strict": true, 11 | "removeComments": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "strictNullChecks": true, 15 | "jsx": "react" 16 | }, 17 | "include": [ 18 | "src/index.tsx" 19 | ], 20 | "exclude": [ 21 | "dist", 22 | "node_modules", 23 | "**/__tests__" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /react/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@types/dom-inputevent@^1.0.4": 6 | version "1.0.5" 7 | resolved "https://registry.yarnpkg.com/@types/dom-inputevent/-/dom-inputevent-1.0.5.tgz#c880fa9b4482b49accc107e4a950117a1af7a61b" 8 | integrity sha512-oL8NzIAn1J8vsIigjEM2qip6PUBRkb1kE+3gbM+NvSCzrScgz+Ixymuv9Z9jmktVjeHWMJc9zhP49YBUBeCTaQ== 9 | 10 | "@types/prop-types@*": 11 | version "15.7.3" 12 | resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7" 13 | integrity sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw== 14 | 15 | "@types/react-dom@^16.8.4": 16 | version "16.9.1" 17 | resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.1.tgz#79206237cba9532a9f870b1cd5428bef6b66378c" 18 | integrity sha512-1S/akvkKr63qIUWVu5IKYou2P9fHLb/P2VAwyxVV85JGaGZTcUniMiTuIqM3lXFB25ej6h+CYEQ27ERVwi6eGA== 19 | dependencies: 20 | "@types/react" "*" 21 | 22 | "@types/react@*", "@types/react@^16.8.19": 23 | version "16.9.3" 24 | resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.3.tgz#6d13251e441a3e67fb60d719d1fc8785b984a2ec" 25 | integrity sha512-Ogb2nSn+2qQv5opoCv7Ls5yFxtyrdUYxp5G+SWTrlGk7dmFKw331GiezCgEZj9U7QeXJi1CDtws9pdXU1zUL4g== 26 | dependencies: 27 | "@types/prop-types" "*" 28 | csstype "^2.2.0" 29 | 30 | "@zettel/core@0.0.21": 31 | version "0.0.21" 32 | resolved "https://registry.yarnpkg.com/@zettel/core/-/core-0.0.21.tgz#1b078114b3fcb8007ea1c73f22b1989541ff8727" 33 | integrity sha512-r1WjWLPZYQI2XJgfH3Bl9iETV2GOkKgM4v+AEXRtXTLfckqpaZKmZfDJhakMiEG3FERIGtQM5lIdVfOYSdj8RA== 34 | 35 | csstype@^2.2.0: 36 | version "2.6.6" 37 | resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.6.tgz#c34f8226a94bbb10c32cc0d714afdf942291fc41" 38 | integrity sha512-RpFbQGUE74iyPgvr46U9t1xoQBM8T4BL8SxrN66Le2xYAPSaDJJKeztV3awugusb3g3G9iL8StmkBBXhcbbXhg== 39 | -------------------------------------------------------------------------------- /scripts/check-npm-release.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios').default 2 | const core = require('@actions/core') 3 | 4 | const version = process.env.PACKAGE_VERSION 5 | const package = process.env.PACKAGE_NAME 6 | 7 | const wait = (time) => { 8 | return new Promise((resolve) => { 9 | setTimeout(() => resolve(), time) 10 | }) 11 | } 12 | 13 | let retries = 3 14 | 15 | const run = async () => { 16 | let success = false 17 | while (retries > 0) { 18 | try { 19 | const result = await axios.get(`https://www.npmjs.com/package/@zettel/${package}/v/${version}`) 20 | if (result.status !== 200) { 21 | throw new Error(`package: ${package}:${version} not released yet or not available`) 22 | } else { 23 | retries = 0 24 | success = true 25 | } 26 | } catch (err) { 27 | retries-- 28 | console.log(`Failed with error: ${err.message}, retrying in 2 seconds`) 29 | await wait(2000) 30 | } 31 | } 32 | 33 | if (!success) { 34 | core.setFailed(`Package ${package}:${version} not on npm`) 35 | } else { 36 | console.log(`Package ${package}:${version} is on npm 🚀`) 37 | } 38 | } 39 | 40 | run() -------------------------------------------------------------------------------- /scripts/release.js: -------------------------------------------------------------------------------- 1 | const parse = require('changelog-parser') 2 | const core = require('@actions/core') 3 | const github = require('@actions/github'); 4 | const fs = require('fs') 5 | const path = require('path') 6 | const packages = process.env.PACKAGES.split(',').map(package => package.trim()) 7 | const token = process.env.GITHUB_TOKEN 8 | 9 | const octokit = new github.GitHub(token); 10 | 11 | async function run() { 12 | const log = await parse('./CHANGELOG.md') 13 | 14 | 15 | const { data: releases } = await octokit.reposReleases({ 16 | owner: 'juliankrispel', 17 | repo: 'zettel' 18 | }) 19 | console.log({ releases }) 20 | 21 | const version = log.versions.find(version => version.version != null) 22 | core.setOutput('title', version.title) 23 | core.setOutput('version', version.version) 24 | core.setOutput('description', version.body) 25 | core.setOutput('unreleased', true) 26 | console.log('setting unreleased to true') 27 | 28 | packages.forEach(package => { 29 | const packageJson = JSON.parse(fs.readFileSync(path.join('./', package, 'package.json')).toString()) 30 | if (packageJson.version !== version.version) { 31 | core.setFailed(`Release failed because version in changelog: ${version.version} isn't the schangelog parserame as version in ${packageJson.name} which is ${packageJson.version}. Update this package first.`) 32 | } 33 | 34 | if (packageJson.dependencies["@zettel/core"] != null && packageJson.dependencies["@zettel/core"] !== version.version) { 35 | core.setFailed(`Release failed because the internal dependency on @zettel/core is using the wrong version`) 36 | } 37 | }) 38 | 39 | // check if already released on github, if it does we set the output of unreleased to false 40 | releases.forEach(release => { 41 | if(release.tag_name.includes(version.version)) { 42 | console.log('setting unreleased to false') 43 | core.setOutput('unreleased', false) 44 | } 45 | }) 46 | } 47 | 48 | run() -------------------------------------------------------------------------------- /site/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@zettel/site", 3 | "description": "Examples of using Zettel, works on codesandbox", 4 | "version": "0.0.0", 5 | "private": true, 6 | "scripts": { 7 | "start": "SKIP_PREFLIGHT_CHECK=true ../node_modules/.bin/react-scripts start", 8 | "build": "SKIP_PREFLIGHT_CHECK=true ../node_modules/.bin/react-scripts build", 9 | "postbuild": "react-snap" 10 | }, 11 | "reactSnap": { 12 | "puppeteerArgs": [ 13 | "--no-sandbox", 14 | "--disable-setuid-sandbox" 15 | ], 16 | "concurrency": 1, 17 | "inlineCss": false, 18 | "skipThirdPartyRequests": false 19 | }, 20 | "dependencies": { 21 | "@microsoft/fast-tooling-react": "^1.23.0", 22 | "@types/prismjs": "^1.16.1", 23 | "@types/react": "^16.9.9", 24 | "@types/react-dom": "^16.9.2", 25 | "@types/react-router-dom": "^4.3.4", 26 | "@types/styled-components": "^4.1.22", 27 | "@types/typography": "^0.16.3", 28 | "@types/use-persisted-state": "^0.3.0", 29 | "@zettel/core": "^0.0.23", 30 | "@zettel/react": "^0.0.23", 31 | "changelog-parser": "^2.8.0", 32 | "lodash-es": "^4.17.15", 33 | "prismjs": "^1.20.0", 34 | "react": "^16.8.6", 35 | "react-dom": "^16.8.6", 36 | "react-draggable": "^4.0.3", 37 | "react-jsonschema-form": "^1.8.0", 38 | "react-router-dom": "^5.0.1", 39 | "react-text-selection-popover": "^1.3.2", 40 | "styled-components": "^4.4.1", 41 | "typography": "^0.16.19", 42 | "use-persisted-state": "^0.3.0" 43 | }, 44 | "eslintConfig": { 45 | "extends": "react-app" 46 | }, 47 | "browserslist": { 48 | "production": [ 49 | ">0.2%", 50 | "not dead", 51 | "not op_mini all" 52 | ], 53 | "development": [ 54 | "last 1 chrome version", 55 | "last 1 firefox version", 56 | "last 1 safari version" 57 | ] 58 | }, 59 | "devDependencies": { 60 | "add": "^2.0.6", 61 | "mdx.macro": "^0.2.9", 62 | "puppeteer": "^3.1.0", 63 | "react-dnd-html5-backend": "^9.4.0", 64 | "react-movable": "^2.2.0", 65 | "react-snap": "1.10.0", 66 | "yarn": "^1.17.3" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /site/public/CNAME: -------------------------------------------------------------------------------- 1 | https://zettel.software -------------------------------------------------------------------------------- /site/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juliankrispel/zettel/e45e0ae95b96a08f80184860c66cc8a22d9682f7/site/public/favicon.ico -------------------------------------------------------------------------------- /site/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 13 | 14 | 23 | Zettel 24 | 25 | 26 | 27 |
28 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /site/public/logo_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juliankrispel/zettel/e45e0ae95b96a08f80184860c66cc8a22d9682f7/site/public/logo_small.png -------------------------------------------------------------------------------- /site/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Zettel", 3 | "name": "Zettel for React", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /site/src/components/Button/index.module.css: -------------------------------------------------------------------------------- 1 | .button { 2 | background: #000; 3 | color: white; 4 | cursor: pointer; 5 | border: none; 6 | border-radius: 3px; 7 | padding: .5em; 8 | margin: .5em; 9 | font-size: inherit; 10 | } -------------------------------------------------------------------------------- /site/src/components/Button/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styles from './index.module.css' 3 | 4 | type Props = { 5 | children: any, 6 | onClick?: React.MouseEventHandler 7 | } 8 | 9 | export default function ({ children, ...props }: Props) { 10 | return 13 | } -------------------------------------------------------------------------------- /site/src/components/index.ts: -------------------------------------------------------------------------------- 1 | import Button from './Button' 2 | 3 | export { 4 | Button 5 | } -------------------------------------------------------------------------------- /site/src/examples/BlockStyling/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useState } from 'react' 3 | import { EditorState, getBlocksForRange } from '@zettel/core' 4 | import Editor from '@zettel/react' 5 | import { Button } from '../../components' 6 | 7 | const text = `[Headline 1][Headline 2][A paragraph]` 8 | 9 | const App = () => { 10 | const [editorState, setEditorState] = useState(() => EditorState.fromJSON({ 11 | text, 12 | ranges: [{ 13 | offset: 0, 14 | length: 1, 15 | styles: ['H1'] 16 | }, { 17 | offset: 12, 18 | length: 1, 19 | styles: ['H2'] 20 | }] 21 | })) 22 | 23 | return ( 24 | <> 25 |
26 | 47 |
48 | { 50 | const { htmlAttrs, children, block } = props 51 | if (block.styles != null) { 52 | if (block.styles.includes('H1')) { 53 | return

{children}

54 | } else if (block.styles.includes('H2')) { 55 | return

{children}

56 | } 57 | } 58 | return
{children}
59 | }} 60 | htmlAttrs={{ className: 'editor'}} 61 | onChange={setEditorState} 62 | editorState={editorState} 63 | /> 64 | 65 | ); 66 | } 67 | 68 | export default App; 69 | -------------------------------------------------------------------------------- /site/src/examples/Changes/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useState } from 'react' 3 | import { EditorState } from '@zettel/core' 4 | import Editor from '@zettel/react' 5 | import styled from 'styled-components' 6 | 7 | const Container = styled.div` 8 | display: flex; 9 | padding: 0; 10 | height: 100%; 11 | ` 12 | 13 | const Error = styled.span` 14 | color: #f00; 15 | font-weight: bold; 16 | font-size: .8em; 17 | ` 18 | 19 | const Aside = styled.aside` 20 | position: relative; 21 | border-top: 1px solid #ccc; 22 | padding: .8em; 23 | height: 100%; 24 | width: 400px; 25 | background: #f0f0f0; 26 | border-left: 1px solid #ccc; 27 | 28 | input, select, textarea, button { 29 | width: 100%; 30 | margin-bottom: 1em; 31 | font-size: 0.8em; 32 | border-radius: 3px; 33 | outline: none; 34 | } 35 | 36 | input { 37 | padding: .3em; 38 | border: 1px solid #ccc; 39 | } 40 | 41 | textarea { 42 | min-height: 300px; 43 | white-space: pre; 44 | border: none; 45 | padding: .5em; 46 | border: 1px solid #ccc; 47 | font-family: monospace; 48 | } 49 | 50 | label { 51 | display: flex; 52 | width: 100%; 53 | } 54 | 55 | select { 56 | margin-right: 1em; 57 | } 58 | 59 | button { 60 | width: auto; 61 | background: #000; 62 | padding: .3em .5em; 63 | border: none; 64 | color: #fff; 65 | } 66 | ` 67 | 68 | const text = `[One 😅Line][And another line of text][And another line]` 69 | 70 | const App = () => { 71 | const [currentChanges, setCurrentChanges] = useState(`[{ 72 | "start": 3, 73 | "end": 3, 74 | "value": [{ 75 | "char": "🔥" 76 | }] 77 | }]`) 78 | const [hasError, setHasError] = useState(false) 79 | const [editorState, setEditorState] = useState(() => EditorState.fromJSON({ 80 | text, 81 | ranges: [], 82 | })) 83 | 84 | const onChangeChange = (event: any) => { 85 | setHasError(false) 86 | setCurrentChanges(event.target.value) 87 | } 88 | 89 | const submitChange = () => { 90 | try { 91 | const changes = JSON.parse(currentChanges) 92 | if (!Array.isArray(changes)) { 93 | // @ts-ignore 94 | throw new Error("SO DUMB") 95 | } 96 | let newEditorState = editorState 97 | changes.forEach(change => { 98 | newEditorState = editorState.change(change) 99 | }) 100 | setEditorState(newEditorState) 101 | } catch (err) { 102 | setHasError(true) 103 | } 104 | } 105 | 106 | return ( 107 | 108 | 113 |