├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── other.md └── workflows │ ├── firebase-hosting-merge.yml │ └── firebase-hosting-pull-request.yml ├── .gitignore ├── .vscode └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── demo ├── .firebaserc ├── .gitignore ├── firebase.json ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── src │ ├── App.tsx │ ├── firebase.ts │ ├── helpers │ │ ├── getImageUrl.ts │ │ ├── onSubmitImageFile.ts │ │ ├── onSubmitImageUrl.ts │ │ └── uploadImage.ts │ ├── index.css │ ├── main.tsx │ └── vite-env.d.ts ├── storage.rules ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── package-lock.json ├── package.json └── react-block-text ├── .gitignore ├── .npmignore ├── README.md ├── package-lock.json ├── package.json ├── postcss.config.js ├── src ├── components │ ├── Block.tsx │ ├── BlockContentText.tsx │ ├── BlockMenu.tsx │ ├── DragLayer.tsx │ ├── QueryMenu.tsx │ ├── ReactBlockText.tsx │ └── SelectionRect.tsx ├── constants.ts ├── contexts │ └── ColorsContext.ts ├── hooks │ └── usePrevious.ts ├── icons │ ├── Add.tsx │ ├── Drag.tsx │ ├── Duplicate.tsx │ └── Trash.tsx ├── index.css ├── index.ts ├── plugins │ ├── header │ │ ├── components │ │ │ ├── BlockContent.tsx │ │ │ └── Icon.tsx │ │ ├── index.ts │ │ ├── plugin.tsx │ │ └── types.ts │ ├── image │ │ ├── assets │ │ │ ├── icon-image-low-res.png │ │ │ └── icon-image.png │ │ ├── components │ │ │ ├── BlockContent.tsx │ │ │ ├── Icon.tsx │ │ │ ├── ImageIcon.tsx │ │ │ ├── ImageSelector.tsx │ │ │ ├── ImageUploader.tsx │ │ │ └── ResizableImage.tsx │ │ ├── index.ts │ │ ├── plugin.tsx │ │ └── types.ts │ ├── list │ │ ├── components │ │ │ ├── BlockContent.tsx │ │ │ ├── BulletedListIcon.tsx │ │ │ └── NumberedListIcon.tsx │ │ ├── index.ts │ │ ├── plugin.tsx │ │ ├── types.ts │ │ └── utils │ │ │ └── applyMetadata.ts │ ├── quote │ │ ├── components │ │ │ ├── BlockContent.tsx │ │ │ └── Icon.tsx │ │ ├── index.ts │ │ └── plugin.tsx │ ├── text │ │ ├── components │ │ │ ├── BlockContent.tsx │ │ │ └── Icon.tsx │ │ ├── index.ts │ │ └── plugin.tsx │ └── todo │ │ ├── components │ │ ├── BlockContent.tsx │ │ ├── CheckIcon.tsx │ │ ├── Checkbox.tsx │ │ └── Icon.tsx │ │ ├── constants.ts │ │ ├── index.ts │ │ ├── plugin.tsx │ │ ├── types.ts │ │ └── utils │ │ └── applyTodoStyle.ts ├── types.ts ├── utils │ ├── appendItemData.ts │ ├── countCharactersOnLastBlockLines.ts │ ├── findAttributeInParents.ts │ ├── findChildWithProperty.ts │ ├── findParentBlock.ts │ ├── findParentWithId.ts │ ├── findScrollParent.ts │ ├── findSelectionRectIds.ts │ ├── forceContentFocus.ts │ ├── getFirstLineFocusOffset.ts │ ├── getLastLineFocusOffset.ts │ ├── getQueryMenuData.ts │ └── getRelativeMousePosition.ts └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "dherault-typescript", 3 | "rules": { 4 | "no-labels": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[Bug]" 5 | labels: bug 6 | assignees: dherault 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[Feature request]" 5 | labels: enhancement 6 | assignees: dherault 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/other.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Other 3 | about: Anything you want to share 4 | title: "[Other]" 5 | labels: '' 6 | assignees: dherault 7 | 8 | --- 9 | -------------------------------------------------------------------------------- /.github/workflows/firebase-hosting-merge.yml: -------------------------------------------------------------------------------- 1 | # This file was auto-generated by the Firebase CLI 2 | # https://github.com/firebase/firebase-tools 3 | 4 | name: Deploy to Firebase Hosting on merge 5 | 'on': 6 | push: 7 | branches: 8 | - main 9 | jobs: 10 | build_and_deploy: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - run: cd demo && npm ci && npm run build 15 | - uses: FirebaseExtended/action-hosting-deploy@v0 16 | with: 17 | repoToken: '${{ secrets.GITHUB_TOKEN }}' 18 | firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_REACT_BLOCK_TEXT }}' 19 | channelId: live 20 | projectId: react-block-text 21 | entryPoint: 'demo' 22 | -------------------------------------------------------------------------------- /.github/workflows/firebase-hosting-pull-request.yml: -------------------------------------------------------------------------------- 1 | # This file was auto-generated by the Firebase CLI 2 | # https://github.com/firebase/firebase-tools 3 | 4 | name: Deploy to Firebase Hosting on PR 5 | 'on': pull_request 6 | jobs: 7 | build_and_preview: 8 | if: '${{ github.event.pull_request.head.repo.full_name == github.repository }}' 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - run: cd demo && npm ci && npm run build 13 | - uses: FirebaseExtended/action-hosting-deploy@v0 14 | with: 15 | repoToken: '${{ secrets.GITHUB_TOKEN }}' 16 | firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_REACT_BLOCK_TEXT }}' 17 | projectId: react-block-text 18 | entryPoint: 'demo' 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": true 4 | }, 5 | "eslint.validate": ["javascript", "typescript"], 6 | "eslint.workingDirectories": [ 7 | { 8 | "mode": "auto" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to React Block Text 2 | 3 | We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | - Becoming a maintainer 10 | 11 | ## We develop with Github 12 | 13 | We use github to host code, to track issues and feature requests, as well as accept pull requests. 14 | 15 | ## We use git, all code changes happen through Pull Requests 16 | 17 | Pull requests are the best way to propose changes to the codebase. We actively welcome your pull requests: 18 | 19 | 1. Fork the repo and create your branch from `main`. 20 | 2. If you've added code that should be tested, add tests (if a test suit exists). 21 | 3. If you've changed APIs, update the documentation. 22 | 4. Ensure the test suite passes (if any). 23 | 5. Make sure your code lints. 24 | 6. Issue that pull request! 25 | 26 | ## Any contributions you make will be under the MIT Software License 27 | 28 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 29 | 30 | ## Report bugs using Github's [issues](https://github.com/dherault/react-block-text/issues) 31 | 32 | We use GitHub issues to track public bugs. Report a bug by opening a new issue; it's that easy! 33 | 34 | ## Write bug reports with detail, background, and sample code 35 | 36 | Here's [an example from Craig Hockenberry](http://www.openradar.me/11905408). 37 | 38 | **Great Bug Reports** tend to have: 39 | 40 | - A quick summary and/or background 41 | - Steps to reproduce 42 | - Be specific! 43 | - Give sample code if you can. 44 | - What you expected would happen 45 | - What actually happens 46 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 47 | 48 | People *love* thorough bug reports. I'm not even kidding. 49 | 50 | ## Use a consistent coding style 51 | 52 | `npm run lint` for the win! 53 | 54 | ## License 55 | 56 | By contributing, you agree that your contributions will be licensed under its MIT License. 57 | 58 | ## Have fun 59 | 60 | That's the most important part! 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-2025 David Hérault 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Block Text 2 | 3 | [![npm version](https://badge.fury.io/js/react-block-text.svg)](https://badge.fury.io/js/react-block-text) 4 | [![PRs](https://img.shields.io/badge/PRs-Welcome!-darkGreen)](https://github.com/dherault/react-block-text/pulls) 5 | 6 | A block text editor for React. 7 | 8 | This is an open-source clone of the famous [Notion.so](https://notion.so) editor. Although not entirely feature complete, it comes with some basic blocks and offers a similar UI. 9 | 10 | Project status: In-development beta. The API will be stable starting with v1. 11 | 12 | ## Disclamer 13 | 14 | This project is uncompleted. Do not use in production as it holds some bugs. 15 | 16 | ## Demo 17 | 18 | [See it live in your browser!](https://react-block-text.web.app/) 19 | 20 | ## Installation 21 | 22 | ```bash 23 | npm install --save react-block-text 24 | ``` 25 | ```bash 26 | yarn add react-block-text 27 | ``` 28 | 29 | ### Note for ViteJs users 30 | 31 | You might need to [add globalThis](https://github.com/vitejs/vite/discussions/7915) to your app. 32 | 33 | ## Usage 34 | 35 | ```jsx 36 | import { useState } from 'react' 37 | import ReactBlockText, { headerPlugin, imagePlugin, listPlugin, quotePlugin, todoPlugin } from 'react-block-text' 38 | 39 | const plugins = [ 40 | ...headerPlugin(), 41 | ...todoPlugin(), 42 | ...listPlugin(), 43 | ...quotePlugin(), 44 | ...imagePlugin({ 45 | onSubmitFile: /* See image plugin section */, 46 | onSubmitUrl: /* ... */, 47 | getUrl: /* ... */, 48 | maxFileSize: '5 MB', /* Optional, displayed in the file upload dialog */ 49 | }), 50 | ] 51 | 52 | function Editor() { 53 | const [value, setValue] = useState('') 54 | 55 | return ( 56 | 61 | ) 62 | } 63 | ``` 64 | 65 | ### Note for multiple instances in SPA 66 | 67 | When implementing multiple instances of the editor on separate pages in a SPA, you might need to set the key prop in order to make in work when transitioning pages: 68 | 69 | ```tsx 70 | 74 | ``` 75 | 76 | ## Options 77 | 78 | ```ts 79 | type ReactBlockTextProps = { 80 | // The data for the editor 81 | value?: string 82 | // An array of plugin 83 | plugins?: ReactBlockTextPlugins 84 | // Enable read only mode 85 | readOnly?: boolean 86 | // Padding top of the editor 87 | paddingTop?: number 88 | // Padding bottom of the editor 89 | paddingBottom?: number 90 | // Padding left of the editor 91 | paddingLeft?: number 92 | // Padding right of the editor 93 | paddingRight?: number 94 | // The primary color for selection, drag and drop, and buttons 95 | primaryColor?: string | null | undefined 96 | // The default text color, to align with your design-system 97 | textColor?: string | null | undefined 98 | // Called when the value changes 99 | onChange?: (value: string) => void 100 | // Called when the user saves the editor with cmd/ctrl+s 101 | onSave?: () => void 102 | } 103 | ``` 104 | 105 | ## Plugins 106 | 107 | ### Header 108 | 109 | Adds support for 3 types of headers. 110 | 111 | ### Todo 112 | 113 | Adds support for todo lists with checkboxes. 114 | 115 | ### List 116 | 117 | Adds support for ordered and unordered lists. 118 | 119 | ### Quote 120 | 121 | Adds support for block quotes. 122 | 123 | ### Image 124 | 125 | Adds support for images. 126 | 127 | Three functions are required for the plugin to work: 128 | 129 | ```ts 130 | type ReactBlockTextImagePluginSubmitter = () => { 131 | progress: number // Between 0 and 1 132 | imageKey?: string // The reference to the image once it's uploaded 133 | isError?: boolean // If true, the upload failed and an error will be displayed in the editor 134 | } 135 | 136 | function onSubmitFile(file: File): Promise 137 | function onSubmitUrl(file: File): Promise 138 | function getUrl(imageKey: string): Promise 139 | ``` 140 | 141 | The returned promises should resolve to a function that returns the progress of the upload as a number between 0 and 1 and eventually a `imageKey` corresponding to the image on your server. Using S3 or Firebase storage this is typically the storage path of the image. This `ReactBlockTextImagePluginSubmitter` function will be called periodically to update the progress of the upload and save the imageKey into the value prop. 142 | 143 | `getUrl` should return the url of the image on your server based on the `imageKey`. Of course you can set `imageKey` directly to the URL and make `getUrl` an identity function. 144 | 145 | For a Firebase example, see [the demo files](https://github.com/dherault/react-block-text/blob/main/demo/src/App.tsx). 146 | 147 | ### Community plugins 148 | 149 | Create your own plugin based on the template provided by this repository and I'll add it here! 150 | 151 | ## License 152 | 153 | MIT 154 | 155 | This project is not affiliated with Notion Labs, Inc. 156 | 157 | 158 | -------------------------------------------------------------------------------- /demo/.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "react-block-text" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /demo/firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "dist", 4 | "ignore": [ 5 | "firebase.json", 6 | "**/.*", 7 | "**/node_modules/**" 8 | ], 9 | "rewrites": [ 10 | { 11 | "source": "**", 12 | "destination": "/index.html" 13 | } 14 | ] 15 | }, 16 | "storage": { 17 | "rules": "storage.rules" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | React Block Text 8 | 9 | 10 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "firebase": "^10.1.0", 14 | "nanoid": "^4.0.2", 15 | "react": "^18.2.0", 16 | "react-block-text": "0.0.17", 17 | "react-dom": "^18.2.0", 18 | "react-github-btn": "^1.4.0" 19 | }, 20 | "devDependencies": { 21 | "@types/react": "^18.2.20", 22 | "@types/react-dom": "^18.2.7", 23 | "@vitejs/plugin-react": "^4.0.4", 24 | "autoprefixer": "^10.4.14", 25 | "postcss": "^8.4.27", 26 | "tailwindcss": "^3.3.3", 27 | "typescript": "^5.1.6", 28 | "vite": "^4.4.9" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /demo/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /demo/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useCallback, useState } from 'react' 2 | import GitHubButton from 'react-github-btn' 3 | 4 | import ReactBlockText, { headerPlugin, imagePlugin, listPlugin, quotePlugin, todoPlugin } from 'react-block-text' 5 | 6 | import onSubmitImageFile from './helpers/onSubmitImageFile' 7 | import onSubmitImageUrl from './helpers/onSubmitImageUrl' 8 | import getImageUrl from './helpers/getImageUrl' 9 | 10 | const LOCAL_STORAGE_KEY = 'react-block-text-data' 11 | 12 | const plugins = [ 13 | ...headerPlugin(), 14 | ...todoPlugin(), 15 | ...listPlugin(), 16 | ...quotePlugin(), 17 | ...imagePlugin({ 18 | onSubmitFile: onSubmitImageFile, 19 | onSubmitUrl: onSubmitImageUrl, 20 | getUrl: getImageUrl, 21 | maxFileSize: '5 MB', 22 | }), 23 | ] 24 | 25 | function App() { 26 | const savedData = localStorage.getItem(LOCAL_STORAGE_KEY) ?? '' 27 | 28 | const [data, setData] = useState(savedData) 29 | const [primaryColor, setPrimaryColor] = useState(null) 30 | const [isContained, setIsContained] = useState(false) 31 | 32 | const handleSave = useCallback(() => { 33 | localStorage.setItem(LOCAL_STORAGE_KEY, data) 34 | }, [data]) 35 | 36 | const renderContainer = useCallback((children: ReactNode) => { 37 | if (!isContained) return children 38 | 39 | return ( 40 |
47 | {/* for testing purposes */} 48 |
49 |
50 | {children} 51 |
52 |
53 | ) 54 | }, [isContained]) 55 | 56 | return ( 57 |
61 | {/* Menu */} 62 |
63 |

64 | React Block Text 65 |

66 |
67 | 74 | Star 75 | 76 |
77 |
setData('')} 79 | className="py-1 px-4 text-gray-600 hover:bg-gray-100 cursor-pointer flex items-center gap-2 select-none" 80 | > 81 | 89 | 94 | 95 | Clear 96 |
97 |
101 | 109 | 114 | 115 | Save to local storage 116 |
117 |
setPrimaryColor(x => x ? null : 'red')} 119 | className="py-1 px-4 text-gray-600 hover:bg-gray-100 cursor-pointer flex items-center gap-2 select-none" 120 | > 121 | 129 | 134 | 135 | Set primary color 136 | {' '} 137 | {primaryColor ? 'blue' : 'red'} 138 |
139 |
setIsContained(x => !x)} 141 | className="py-1 px-4 text-gray-600 hover:bg-gray-100 cursor-pointer flex items-center gap-2 select-none" 142 | > 143 | 151 | 156 | 157 | Toggle container 158 |
159 |
160 |
161 | 167 | Documentation 168 | 169 | {' '} 170 | - MIT License 171 |
172 |
173 | {/* Editor */} 174 |
175 | {renderContainer( 176 | 188 | )} 189 |
190 |
191 | ) 192 | } 193 | 194 | export default App 195 | -------------------------------------------------------------------------------- /demo/src/firebase.ts: -------------------------------------------------------------------------------- 1 | import { initializeApp } from 'firebase/app' 2 | import { getAnalytics } from 'firebase/analytics' 3 | import { getStorage } from 'firebase/storage' 4 | import { ReCaptchaV3Provider, initializeAppCheck } from 'firebase/app-check' 5 | 6 | const firebaseConfig = { 7 | apiKey: 'AIzaSyAlyvTjtWNg_wl5Og6s8b8NUh1DIMtWLcY', 8 | authDomain: 'react-block-text.firebaseapp.com', 9 | projectId: 'react-block-text', 10 | storageBucket: 'react-block-text.appspot.com', 11 | messagingSenderId: '936568140490', 12 | appId: '1:936568140490:web:7caf1f854035573644fa33', 13 | measurementId: 'G-89QSZHTBZV', 14 | } 15 | 16 | const app = initializeApp(firebaseConfig) 17 | 18 | export const analytics = getAnalytics(app) 19 | 20 | export const storage = getStorage(app) 21 | 22 | initializeAppCheck(app, { 23 | provider: new ReCaptchaV3Provider('6Les-XcnAAAAAGxTW0Nh15IwnNSqpVwNUsNskmRW'), 24 | isTokenAutoRefreshEnabled: true, 25 | }) 26 | -------------------------------------------------------------------------------- /demo/src/helpers/getImageUrl.ts: -------------------------------------------------------------------------------- 1 | import { getDownloadURL, ref } from 'firebase/storage' 2 | 3 | import { storage } from '../firebase' 4 | 5 | function getImageUrl(imageKey: string) { 6 | return getDownloadURL(ref(storage, imageKey)) 7 | } 8 | 9 | export default getImageUrl 10 | -------------------------------------------------------------------------------- /demo/src/helpers/onSubmitImageFile.ts: -------------------------------------------------------------------------------- 1 | import uploadImage from './uploadImage' 2 | 3 | function onSubmitImageFile(file: File) { 4 | return uploadImage(file) 5 | } 6 | 7 | export default onSubmitImageFile 8 | -------------------------------------------------------------------------------- /demo/src/helpers/onSubmitImageUrl.ts: -------------------------------------------------------------------------------- 1 | import uploadImage from './uploadImage' 2 | 3 | async function onSubmitImageUrl(url: string) { 4 | const response = await fetch(url) 5 | 6 | const type = response.headers.get('content-type') ?? undefined 7 | 8 | const blob = await response.blob() 9 | 10 | const file = new File([blob], 'image', { type }) 11 | 12 | return uploadImage(file) 13 | } 14 | 15 | export default onSubmitImageUrl 16 | -------------------------------------------------------------------------------- /demo/src/helpers/uploadImage.ts: -------------------------------------------------------------------------------- 1 | import type { ReactBlockTextImagePluginSubmitter } from 'react-block-text' 2 | import { ref, uploadBytesResumable } from 'firebase/storage' 3 | import { nanoid } from 'nanoid' 4 | 5 | import { storage } from '../firebase' 6 | 7 | // Example on how to upload an image file using Firebase storage 8 | async function uploadImage(imageFile: File): Promise { 9 | const storageRef = ref(storage, `images/${nanoid()}`) 10 | const uploadTask = uploadBytesResumable(storageRef, imageFile) 11 | 12 | let imageKey = '' 13 | let progress = 0 14 | let isError = false 15 | 16 | uploadTask.on('state_changed', 17 | snapshot => { 18 | progress = snapshot.bytesTransferred / snapshot.totalBytes 19 | 20 | console.log('Image upload progress:', `${Math.round(progress * 100)}%`) 21 | }, 22 | error => { 23 | isError = true 24 | 25 | console.error(error) 26 | }, 27 | () => { 28 | progress = 1 // This is important to make sure the progress indicator disappears 29 | imageKey = uploadTask.snapshot.metadata.fullPath 30 | 31 | console.log('Image upload complete!') 32 | } 33 | ) 34 | 35 | return () => ({ 36 | imageKey, 37 | progress, 38 | isError, 39 | }) 40 | } 41 | 42 | export default uploadImage 43 | -------------------------------------------------------------------------------- /demo/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /demo/src/main.tsx: -------------------------------------------------------------------------------- 1 | import './index.css' 2 | import React from 'react' 3 | import { createRoot } from 'react-dom/client' 4 | 5 | import App from './App' 6 | 7 | import './firebase' 8 | 9 | createRoot(document.getElementById('root') as HTMLElement).render( 10 | 11 | 12 | , 13 | ) 14 | -------------------------------------------------------------------------------- /demo/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /demo/storage.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | 3 | service firebase.storage { 4 | match /b/{bucket}/o { 5 | match /images/{imageId} { 6 | allow read, write: if true; 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /demo/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | './index.html', 5 | './src/**/*.{js,ts,jsx,tsx}', 6 | ], 7 | theme: { 8 | extend: {}, 9 | }, 10 | plugins: [], 11 | } 12 | -------------------------------------------------------------------------------- /demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /demo/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /demo/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | optimizeDeps: { 8 | exclude: ['react-block-text'], 9 | }, 10 | }) 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "react-block-text-monorepo", 4 | "version": "0.0.0", 5 | "description": "The monorepo for react-block-text", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/dherault/react-block-text.git" 12 | }, 13 | "author": "David Hérault (https://github.com/dherault)", 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/dherault/react-block-text/issues" 17 | }, 18 | "homepage": "https://github.com/dherault/react-block-text#readme", 19 | "devDependencies": { 20 | "eslint": "^8.46.0", 21 | "eslint-config-dherault-typescript": "^1.3.2" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /react-block-text/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | vite.config.ts.* 27 | 28 | bundle-analysis.txt 29 | -------------------------------------------------------------------------------- /react-block-text/.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | postcss.config.js 3 | tailwind.config.js 4 | tsconfig.json 5 | tsconfig.node.json 6 | vite.config.ts* 7 | bundle-analysis.txt 8 | -------------------------------------------------------------------------------- /react-block-text/README.md: -------------------------------------------------------------------------------- 1 | # React Block Text 2 | 3 | [![npm version](https://badge.fury.io/js/react-block-text.svg)](https://badge.fury.io/js/react-block-text) 4 | [![PRs](https://img.shields.io/badge/PRs-Welcome!-darkGreen)](https://github.com/dherault/react-block-text/pulls) 5 | 6 | A block text editor for React. 7 | 8 | This is an open-source clone of the famous [Notion.so](https://notion.so) editor. Although not entirely feature complete, it comes with some basic blocks and offers a similar UI. 9 | 10 | Project status: In-development beta. The API will be stable starting with v1. 11 | 12 | ## Demo 13 | 14 | [See it live in your browser!](https://react-block-text.web.app/) 15 | 16 | ## Installation 17 | 18 | ```bash 19 | npm install --save react-block-text 20 | ``` 21 | ```bash 22 | yarn add react-block-text 23 | ``` 24 | 25 | ### Note for ViteJs users 26 | 27 | You might need to [add globalThis](https://github.com/vitejs/vite/discussions/7915) to your app. 28 | 29 | ## Usage 30 | 31 | ```jsx 32 | import { useState } from 'react' 33 | import ReactBlockText, { headerPlugin, imagePlugin, listPlugin, quotePlugin, todoPlugin } from 'react-block-text' 34 | 35 | const plugins = [ 36 | ...headerPlugin(), 37 | ...todoPlugin(), 38 | ...listPlugin(), 39 | ...quotePlugin(), 40 | ...imagePlugin({ 41 | onSubmitFile: /* See image plugin section */, 42 | onSubmitUrl: /* ... */, 43 | getUrl: /* ... */, 44 | maxFileSize: '5 MB', /* Optional, displayed in the file upload dialog */ 45 | }), 46 | ] 47 | 48 | function Editor() { 49 | const [value, setValue] = useState('') 50 | 51 | return ( 52 | 57 | ) 58 | } 59 | ``` 60 | 61 | ### Note for multiple instances in SPA 62 | 63 | When implementing multiple instances of the editor on separate pages in a SPA, you might need to set the key prop in order to make in work when transitioning pages: 64 | 65 | ```tsx 66 | 70 | ``` 71 | 72 | ## Options 73 | 74 | ```ts 75 | type ReactBlockTextProps = { 76 | // The data for the editor 77 | value?: string 78 | // An array of plugin 79 | plugins?: ReactBlockTextPlugins 80 | // Enable read only mode 81 | readOnly?: boolean 82 | // Padding top of the editor 83 | paddingTop?: number 84 | // Padding bottom of the editor 85 | paddingBottom?: number 86 | // Padding left of the editor 87 | paddingLeft?: number 88 | // Padding right of the editor 89 | paddingRight?: number 90 | // The primary color for selection, drag and drop, and buttons 91 | primaryColor?: string | null | undefined 92 | // The default text color, to align with your design-system 93 | textColor?: string | null | undefined 94 | // Called when the value changes 95 | onChange?: (value: string) => void 96 | // Called when the user saves the editor with cmd/ctrl+s 97 | onSave?: () => void 98 | } 99 | ``` 100 | 101 | ## Plugins 102 | 103 | ### Header 104 | 105 | Adds support for 3 types of headers. 106 | 107 | ### Todo 108 | 109 | Adds support for todo lists with checkboxes. 110 | 111 | ### List 112 | 113 | Adds support for ordered and unordered lists. 114 | 115 | ### Quote 116 | 117 | Adds support for block quotes. 118 | 119 | ### Image 120 | 121 | Adds support for images. 122 | 123 | Three functions are required for the plugin to work: 124 | 125 | ```ts 126 | type ReactBlockTextImagePluginSubmitter = () => { 127 | progress: number // Between 0 and 1 128 | imageKey?: string // The reference to the image once it's uploaded 129 | isError?: boolean // If true, the upload failed and an error will be displayed in the editor 130 | } 131 | 132 | function onSubmitFile(file: File): Promise 133 | function onSubmitUrl(file: File): Promise 134 | function getUrl(imageKey: string): Promise 135 | ``` 136 | 137 | The returned promises should resolve to a function that returns the progress of the upload as a number between 0 and 1 and eventually a `imageKey` corresponding to the image on your server. Using S3 or Firebase storage this is typically the storage path of the image. This `ReactBlockTextImagePluginSubmitter` function will be called periodically to update the progress of the upload and save the imageKey into the value prop. 138 | 139 | `getUrl` should return the url of the image on your server based on the `imageKey`. Of course you can set `imageKey` directly to the URL and make `getUrl` an identity function. 140 | 141 | For a Firebase example, see [the demo files](https://github.com/dherault/react-block-text/blob/main/demo/src/App.tsx). 142 | 143 | ### Community plugins 144 | 145 | Create your own plugin based on the template provided by this repository and I'll add it here! 146 | 147 | ## License 148 | 149 | MIT 150 | 151 | This project is not affiliated with Notion Labs, Inc. 152 | 153 | 154 | -------------------------------------------------------------------------------- /react-block-text/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-block-text", 3 | "description": "A block text editor for React", 4 | "version": "0.0.23", 5 | "type": "module", 6 | "main": "dist/react-block-text.js", 7 | "types": "dist/index.d.ts", 8 | "keywords": [ 9 | "React", 10 | "block", 11 | "text", 12 | "editor" 13 | ], 14 | "author": "David Hérault (https://github.com/dherault)", 15 | "license": "MIT", 16 | "bugs": { 17 | "url": "https://github.com/dherault/react-block-text/issues" 18 | }, 19 | "homepage": "https://github.com/dherault/react-block-text#readme", 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/dherault/react-block-text.git" 23 | }, 24 | "scripts": { 25 | "dev": "nodemon --watch src --ext ts,tsx,css --exec \"npm run build\"", 26 | "build": "tsc && vite build", 27 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 28 | "preview": "vite preview", 29 | "analysis": "BUNDLE_ANALYSIS=true npm run build", 30 | "count-locs": "(cd src && ( find ./ -name '*.ts*' -print0 | xargs -0 cat ) | wc -l)", 31 | "prepublishOnly": "npm i && npm run build" 32 | }, 33 | "dependencies": { 34 | "abc-list": "^1.0.4", 35 | "clsx": "^2.0.0", 36 | "color": "^4.2.3", 37 | "draft-js": "^0.11.7", 38 | "fuse.js": "^6.6.2", 39 | "ignore-warnings": "^0.2.1", 40 | "nanoid": "^4.0.2", 41 | "react-dnd": "^16.0.1", 42 | "react-dnd-html5-backend": "^16.0.1", 43 | "react-transition-group": "^4.4.5", 44 | "roman-numerals": "^0.3.2" 45 | }, 46 | "devDependencies": { 47 | "@types/color": "^3.0.3", 48 | "@types/draft-js": "^0.11.12", 49 | "@types/react": "^18.2.20", 50 | "@types/react-dom": "^18.2.7", 51 | "@types/react-transition-group": "^4.4.6", 52 | "@types/roman-numerals": "^0.3.0", 53 | "@vitejs/plugin-react": "^4.0.4", 54 | "autoprefixer": "^10.4.14", 55 | "esbuild-plugins-node-modules-polyfill": "^1.3.0", 56 | "nodemon": "^3.0.1", 57 | "postcss": "^8.4.27", 58 | "rollup-plugin-analyzer": "^4.0.0", 59 | "rollup-plugin-polyfill-node": "^0.12.0", 60 | "tailwindcss": "^3.3.3", 61 | "typescript": "^5.1.6", 62 | "vite": "^4.4.9", 63 | "vite-plugin-css-injected-by-js": "^3.3.0", 64 | "vite-plugin-dts": "^3.5.2" 65 | }, 66 | "peerDependencies": { 67 | "react": "^18.0.0", 68 | "react-dom": "^18.0.0" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /react-block-text/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /react-block-text/src/components/Block.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' 2 | import { useDrag, useDrop } from 'react-dnd' 3 | import { getEmptyImage } from 'react-dnd-html5-backend' 4 | import _ from 'clsx' 5 | import type { XYCoord } from 'react-dnd' 6 | 7 | import type { BlockProps, DragAndDropCollect, TopLeft } from '../types' 8 | 9 | import { ADD_ITEM_BUTTON_ID, BLOCK_ICONS_WIDTH, DRAG_ITEM_BUTTON_ID, INDENT_SIZE } from '../constants' 10 | 11 | import ColorsContext from '../contexts/ColorsContext' 12 | 13 | import AddIcon from '../icons/Add' 14 | import DragIcon from '../icons/Drag' 15 | 16 | import BlockMenu from './BlockMenu' 17 | 18 | const DRAG_INDICATOR_SIZE = 3 19 | const DRAG_TYPE = 'block' 20 | 21 | function Block(props: BlockProps) { 22 | const { 23 | children, 24 | pluginsData, 25 | item, 26 | index, 27 | readOnly, 28 | selected, 29 | hovered, 30 | isDraggingTop, 31 | paddingLeft: rawPaddingLeft, 32 | paddingRight: rawPaddingRight, 33 | noPadding = false, 34 | registerSelectionRef, 35 | onAddItem, 36 | onDeleteItem, 37 | onDuplicateItem, 38 | onMouseDown, 39 | onMouseMove, 40 | onMouseLeave, 41 | onRectSelectionMouseDown, 42 | onDragStart, 43 | onDrag, 44 | onDragEnd, 45 | onBlockMenuOpen, 46 | onBlockMenuClose, 47 | focusContent, 48 | focusContentAtStart, 49 | focusContentAtEnd, 50 | focusNextContent, 51 | blurContent, 52 | blockContentProps, 53 | } = props 54 | const rootRef = useRef(null) 55 | const dragRef = useRef(null) 56 | const contentRef = useRef(null) 57 | const { primaryColor, primaryColorTransparent } = useContext(ColorsContext) 58 | const [menuPosition, setMenuPosition] = useState(null) 59 | 60 | const plugin = useMemo(() => pluginsData.find(plugin => plugin.type === item.type), [pluginsData, item]) 61 | const isEmpty = useMemo(() => ( 62 | item.type === 'text' 63 | && !blockContentProps.editorState.getCurrentContent().getPlainText().length 64 | ), [item, blockContentProps.editorState]) 65 | const paddingTop = useMemo(() => noPadding ? 0 : plugin?.paddingTop ?? 3, [noPadding, plugin]) 66 | const paddingBottom = useMemo(() => noPadding ? 0 : plugin?.paddingBottom ?? 3, [noPadding, plugin]) 67 | const paddingLeft = useMemo(() => noPadding ? 0 : rawPaddingLeft ?? 0, [noPadding, rawPaddingLeft]) 68 | const paddingRight = useMemo(() => noPadding ? 0 : rawPaddingRight ?? 0, [noPadding, rawPaddingRight]) 69 | const indentWidth = useMemo(() => item.indent * INDENT_SIZE, [item.indent]) 70 | const iconsWidth = useMemo(() => readOnly ? 0 : BLOCK_ICONS_WIDTH, [readOnly]) 71 | 72 | /* --- 73 | DRAG AND DROP 74 | The `item` is the props of the dragged block 75 | --- */ 76 | const [{ handlerId }, drop] = useDrop({ 77 | accept: DRAG_TYPE, 78 | collect(monitor) { 79 | return { 80 | handlerId: monitor.getHandlerId(), 81 | } 82 | }, 83 | hover(dragItem, monitor) { 84 | if (!dragRef.current) return 85 | 86 | const dragIndex = dragItem.index 87 | const hoverIndex = index 88 | 89 | // Determine rectangle on screen 90 | const hoverBoundingRect = dragRef.current?.getBoundingClientRect() 91 | 92 | // Get vertical middle 93 | const hoverHeight = hoverBoundingRect.bottom - hoverBoundingRect.top 94 | const hoverMiddleY = hoverHeight / 2 95 | 96 | // Determine mouse position 97 | const clientOffset = monitor.getClientOffset() as XYCoord 98 | 99 | // Get pixels to the top 100 | const hoverClientY = clientOffset.y - hoverBoundingRect.top 101 | 102 | // Only perform the move when the mouse has crossed half of the items height 103 | // When dragging downwards, only move when the cursor is below 50% 104 | // When dragging upwards, only move when the cursor is above 50% 105 | 106 | // Dragging downwards 107 | if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) return 108 | 109 | // Dragging upwards 110 | if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) return 111 | 112 | // Don't display a drag indicator if the dragged block is hovered in the middle 113 | const isMiddleOfSameIndex = dragIndex === hoverIndex 114 | && hoverClientY > hoverHeight / 3 115 | && hoverClientY < hoverHeight * 2 / 3 116 | 117 | // Time to actually perform the action 118 | onDrag(hoverIndex, isMiddleOfSameIndex ? null : hoverClientY < hoverMiddleY) 119 | }, 120 | }) 121 | 122 | const [, drag, preview] = useDrag({ 123 | type: DRAG_TYPE, 124 | item() { 125 | onDragStart() 126 | 127 | return props 128 | }, 129 | end() { 130 | onDragEnd() 131 | }, 132 | }) 133 | 134 | drag(dragRef) 135 | drop(rootRef) 136 | 137 | /* --- 138 | BLOCK MENU POSITIONING ON OPEN 139 | --- */ 140 | const handleDragClick = useCallback(() => { 141 | if (!rootRef.current) return 142 | if (!dragRef.current) return 143 | 144 | const rootRect = rootRef.current.getBoundingClientRect() 145 | const dragRect = dragRef.current.getBoundingClientRect() 146 | 147 | setMenuPosition({ 148 | top: dragRect.top - rootRect.top - 4, 149 | left: dragRect.left - rootRect.left + 24 - paddingLeft + BLOCK_ICONS_WIDTH, 150 | }) 151 | onBlockMenuOpen() 152 | }, [paddingLeft, onBlockMenuOpen]) 153 | 154 | /* --- 155 | BLOCK MENU CLOSE 156 | --- */ 157 | const handleBlockMenuClose = useCallback(() => { 158 | setMenuPosition(null) 159 | onBlockMenuClose() 160 | }, [onBlockMenuClose]) 161 | 162 | /* --- 163 | USE EMPTY IMAGE FOR DND PREVIEW 164 | --- */ 165 | useEffect(() => { 166 | preview(getEmptyImage()) 167 | // eslint-disable-next-line react-hooks/exhaustive-deps 168 | }, []) 169 | 170 | /* --- 171 | MAIN RETURN STATEMENT 172 | --- */ 173 | return ( 174 |
!menuPosition && !readOnly && onMouseDown()} 181 | onMouseMove={() => !menuPosition && !readOnly && onMouseMove()} 182 | onMouseEnter={() => !menuPosition && !readOnly && onMouseMove()} 183 | onMouseLeave={() => !menuPosition && !readOnly && onMouseLeave()} 184 | > 185 | {/* padding left with click handler */} 186 |
192 |
193 | {/* Selection background element */} 194 |
206 | {/* Scroll into view offset element */} 207 |
208 |
209 | {/* Add and drag icons */} 210 | {!readOnly && ( 211 |
212 |
213 |
219 |
223 |
230 | 231 |
232 |
239 | 240 |
241 |
242 |
247 |
248 | {/* Separator/margin with click handler */} 249 |
254 |
255 | )} 256 | {/* Content */} 257 |
261 | {!noPadding && ( 262 | <> 263 |
273 |
278 | 279 | )} 280 |
281 | {children} 282 |
283 | {!noPadding && ( 284 | <> 285 |
290 |
300 | 301 | )} 302 |
303 | {/* Separator/margin with click handler */} 304 | {!readOnly && ( 305 |
310 | )} 311 | {/* Block menu */} 312 | {!!menuPosition && ( 313 | 319 | )} 320 |
321 | {/* padding right with click handler */} 322 |
328 |
329 | ) 330 | } 331 | 332 | export default Block 333 | -------------------------------------------------------------------------------- /react-block-text/src/components/BlockContentText.tsx: -------------------------------------------------------------------------------- 1 | import { KeyboardEvent, useMemo } from 'react' 2 | import { Editor, KeyBindingUtil, getDefaultKeyBinding } from 'draft-js' 3 | 4 | import type { BlockContentProps } from '../types' 5 | 6 | import { COMMANDS } from '../constants' 7 | 8 | function BlockContentText({ 9 | pluginsData, 10 | readOnly, 11 | focused, 12 | isSelecting, 13 | editorState, 14 | placeholder, 15 | fallbackPlaceholder, 16 | registerRef, 17 | onChange, 18 | onKeyCommand, 19 | onReturn, 20 | onUpArrow, 21 | onDownArrow, 22 | onFocus, 23 | onBlur, 24 | onPaste, 25 | }: BlockContentProps) { 26 | const styleMap = useMemo(() => ( 27 | pluginsData.reduce((acc, plugin) => ({ ...acc, ...(plugin.styleMap ?? {}) }), {}) 28 | ), [pluginsData]) 29 | 30 | return ( 31 | 48 | ) 49 | } 50 | 51 | /* --- 52 | BIND KEYBOARD SHORTCUTS 53 | --- */ 54 | function bindKey(event: KeyboardEvent): string | null { 55 | if (event.key === 'Tab') { 56 | event.preventDefault() 57 | 58 | return event.shiftKey ? COMMANDS.OUTDENT : COMMANDS.INDENT 59 | } 60 | 61 | if (event.key === 's' && KeyBindingUtil.hasCommandModifier(event)) { 62 | return COMMANDS.SAVE 63 | } 64 | 65 | return getDefaultKeyBinding(event) 66 | } 67 | 68 | export default BlockContentText 69 | -------------------------------------------------------------------------------- /react-block-text/src/components/BlockMenu.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from 'react' 2 | 3 | import type { BlockMenuItemProps, BlockMenuProps } from '../types' 4 | 5 | import { DRAG_ITEM_BUTTON_ID } from '../constants' 6 | 7 | import findParentWithId from '../utils/findParentWithId' 8 | 9 | import TrashIcon from '../icons/Trash' 10 | import DuplicateIcon from '../icons/Duplicate' 11 | 12 | function BlockMenu({ top, left, onDeleteItem, onDuplicateItem, onClose }: BlockMenuProps) { 13 | const rootRef = useRef(null) 14 | 15 | /* --- 16 | DELETE 17 | --- */ 18 | const handleDeleteItem = useCallback(() => { 19 | onDeleteItem() 20 | onClose() 21 | }, [onDeleteItem, onClose]) 22 | 23 | /* --- 24 | DUPLICATE 25 | --- */ 26 | const handleDuplicateItem = useCallback(() => { 27 | onDuplicateItem() 28 | onClose() 29 | }, [onDuplicateItem, onClose]) 30 | 31 | /* --- 32 | OUTSIDE CLICK 33 | --- */ 34 | const handleOutsideClick = useCallback((event: MouseEvent) => { 35 | if (findParentWithId(event.target as HTMLElement, DRAG_ITEM_BUTTON_ID)) return 36 | if (!rootRef.current || rootRef.current.contains(event.target as Node)) return 37 | 38 | onClose() 39 | }, [onClose]) 40 | 41 | useEffect(() => { 42 | window.addEventListener('click', handleOutsideClick) 43 | 44 | return () => { 45 | window.removeEventListener('click', handleOutsideClick) 46 | } 47 | }, [handleOutsideClick]) 48 | 49 | /* --- 50 | MAIN RETURN STATEMENT 51 | --- */ 52 | return ( 53 |
61 | 66 | )} 67 | label="Delete" 68 | onClick={handleDeleteItem} 69 | /> 70 | 76 | )} 77 | label="Duplicate" 78 | onClick={handleDuplicateItem} 79 | /> 80 |
81 | ) 82 | } 83 | 84 | /* --- 85 | MENU ITEM COMPONENT 86 | --- */ 87 | function BlockMenuItem({ icon, label, onClick }: BlockMenuItemProps) { 88 | return ( 89 |
93 | {icon} 94 | {label} 95 |
96 | ) 97 | } 98 | 99 | export default BlockMenu 100 | -------------------------------------------------------------------------------- /react-block-text/src/components/DragLayer.tsx: -------------------------------------------------------------------------------- 1 | import { type CSSProperties, useCallback, useEffect, useRef, useState } from 'react' 2 | import type { XYCoord } from 'react-dnd' 3 | import { useDragLayer } from 'react-dnd' 4 | 5 | import type { BlockProps, DragLayerProps } from '../types' 6 | 7 | import Block from './Block' 8 | 9 | const layerStyle: CSSProperties = { 10 | position: 'fixed', 11 | pointerEvents: 'none', 12 | zIndex: 100, 13 | left: 0, 14 | top: 0, 15 | width: '100%', 16 | height: '100%', 17 | } 18 | 19 | function getPreviewStyle( 20 | initialOffset: XYCoord | null, 21 | currentOffset: XYCoord | null, 22 | previewElement: HTMLDivElement | null, 23 | dragElement: HTMLDivElement | null, 24 | ) { 25 | if (!initialOffset || !currentOffset || !previewElement || !dragElement) { 26 | return { 27 | opacity: 0, 28 | transition: 'opacity 150ms linear', 29 | } 30 | } 31 | 32 | // `previewTop` and `dragTop` allow offseting the preview by the top of the dragged block 33 | // In case multiple blocks are dragged, so that the preview stays aligned with the drag handle 34 | const { top: previewTop } = previewElement.getBoundingClientRect() 35 | const { top: dragTop } = dragElement.getBoundingClientRect() 36 | const { x, y } = currentOffset 37 | const transform = `translate(${x + 19}px, ${y - dragTop + previewTop - 3}px)` 38 | 39 | return { 40 | transform, 41 | WebkitTransform: transform, 42 | opacity: 0.4, 43 | transition: 'opacity 150ms linear', 44 | } 45 | } 46 | 47 | function DragLayer({ pluginsData, blockProps, dragIndex }: DragLayerProps) { 48 | const previewRef = useRef(null) 49 | const dragRef = useRef(null) 50 | const [, forceRefresh] = useState(false) 51 | 52 | const { isDragging, item, initialOffset, currentOffset } = useDragLayer(monitor => ({ 53 | isDragging: monitor.isDragging(), 54 | item: monitor.getItem() as BlockProps, 55 | initialOffset: monitor.getInitialSourceClientOffset(), 56 | currentOffset: monitor.getSourceClientOffset(), 57 | })) 58 | 59 | const renderSingleItem = useCallback((props: Omit, index: number, noPadding = false) => { 60 | if (!props.item) return null 61 | 62 | const plugin = pluginsData.find(plugin => plugin.type === props.item.type) 63 | 64 | if (!plugin) return null 65 | 66 | const { BlockContent } = plugin 67 | 68 | return ( 69 |
73 | 81 | 85 | 86 |
87 | ) 88 | }, [pluginsData, dragIndex]) 89 | 90 | const renderPreview = useCallback(() => { 91 | if (!blockProps.length) return renderSingleItem(item, 0, true) 92 | 93 | return blockProps.map((props, i) => renderSingleItem(props, i)) 94 | }, [blockProps, item, renderSingleItem]) 95 | 96 | useEffect(() => { 97 | if (!isDragging) return 98 | 99 | setTimeout(() => { 100 | forceRefresh(x => !x) 101 | }, 0) 102 | }, [isDragging]) 103 | 104 | if (!isDragging) return null 105 | 106 | return ( 107 |
108 |
112 | {renderPreview()} 113 |
114 |
115 | ) 116 | } 117 | 118 | export default DragLayer 119 | -------------------------------------------------------------------------------- /react-block-text/src/components/QueryMenu.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' 2 | import Fuse from 'fuse.js' 3 | import _ from 'clsx' 4 | 5 | import type { BlockCategory, QueryMenuIconProps, QueryMenuItemProps, QueryMenuProps } from '../types' 6 | 7 | import { BLOCK_CATEGORY_TO_LABEL, QUERY_MENU_HEIGHT, QUERY_MENU_WIDTH } from '../constants' 8 | 9 | const fuseOptions = { 10 | keys: ['title', 'label', 'shortcuts'], 11 | threshold: 0.3, 12 | } 13 | 14 | function QueryMenu({ pluginsData, query, top, bottom, left, onSelect, onClose }: QueryMenuProps) { 15 | const rootRef = useRef(null) 16 | const [activeIndex, setActiveIndex] = useState(0) 17 | const [scrollIntoViewIndex, setScrollIntoViewIndex] = useState(-1) 18 | const [isHovering, setIsHovering] = useState(false) 19 | const fuse = useMemo(() => new Fuse(pluginsData, fuseOptions), [pluginsData]) 20 | const results = useMemo(() => query ? fuse.search(query) : pluginsData.map(item => ({ item })), [pluginsData, query, fuse]) 21 | const packs = useMemo(() => Object.keys(BLOCK_CATEGORY_TO_LABEL).map(blockCategory => ({ 22 | blockCategory: blockCategory as BlockCategory, 23 | results: results.filter(result => result.item.blockCategory === blockCategory), 24 | })), [results]) 25 | const flatPacks = useMemo(() => packs.reduce((acc, pack) => [...acc, ...pack.results], [] as any[]), [packs]) 26 | 27 | /* --- 28 | ARROW UP, DOWN, ENTER, ESCAPE HANDLERS 29 | --- */ 30 | const handleKeyDown = useCallback((event: KeyboardEvent) => { 31 | if (event.key === 'ArrowDown') { 32 | event.preventDefault() 33 | 34 | const nextIndex = (activeIndex + 1) % flatPacks.length 35 | 36 | setActiveIndex(nextIndex) 37 | setScrollIntoViewIndex(nextIndex) 38 | setIsHovering(false) 39 | 40 | return 41 | } 42 | 43 | if (event.key === 'ArrowUp') { 44 | event.preventDefault() 45 | 46 | const nextIndex = activeIndex === -1 ? flatPacks.length - 1 : (activeIndex - 1 + flatPacks.length) % flatPacks.length 47 | 48 | setActiveIndex(nextIndex) 49 | setScrollIntoViewIndex(nextIndex) 50 | setIsHovering(false) 51 | 52 | return 53 | } 54 | 55 | if (event.key === 'Enter') { 56 | event.preventDefault() 57 | 58 | if (activeIndex !== -1 && flatPacks[activeIndex]) { 59 | onSelect(flatPacks[activeIndex].item.type) 60 | } 61 | 62 | return 63 | } 64 | 65 | if (event.key === 'Escape') { 66 | event.preventDefault() 67 | 68 | onClose() 69 | } 70 | }, [flatPacks, activeIndex, onSelect, onClose]) 71 | 72 | /* --- 73 | OUTSIDE CLICK 74 | --- */ 75 | const handleOutsideClick = useCallback((event: MouseEvent) => { 76 | if (rootRef.current && !rootRef.current.contains(event.target as Node)) { 77 | onClose() 78 | } 79 | }, [onClose]) 80 | 81 | /* --- 82 | WINDOW EVENT LISTENERS 83 | --- */ 84 | useEffect(() => { 85 | window.addEventListener('keydown', handleKeyDown) 86 | 87 | return () => { 88 | window.removeEventListener('keydown', handleKeyDown) 89 | } 90 | }, [handleKeyDown]) 91 | 92 | useEffect(() => { 93 | window.addEventListener('click', handleOutsideClick) 94 | 95 | return () => { 96 | window.removeEventListener('click', handleOutsideClick) 97 | } 98 | }, [handleOutsideClick]) 99 | 100 | /* --- 101 | MAIN RETURN STATEMENT 102 | --- */ 103 | return ( 104 |
setIsHovering(true)} 107 | className="rbt-py-2 rbt-px-1 rbt-bg-white rbt-border rbt-shadow-xl rbt-rounded rbt-absolute rbt-z-50 rbt-overflow-y-auto" 108 | style={{ 109 | top, 110 | bottom, 111 | left, 112 | width: QUERY_MENU_WIDTH, 113 | maxHeight: QUERY_MENU_HEIGHT, 114 | }} 115 | > 116 | {results.length > 0 && ( 117 |
118 | {packs.map(({ blockCategory, results }) => !!results.length && ( 119 | 120 |
121 | {BLOCK_CATEGORY_TO_LABEL[blockCategory]} 122 |
123 | {results.map(result => { 124 | const index = flatPacks.indexOf(result) 125 | 126 | return ( 127 | isHovering && setActiveIndex(index)} 134 | onMouseLeave={() => isHovering && setActiveIndex(-1)} 135 | onClick={() => onSelect(result.item.type)} 136 | shouldScrollIntoView={index === scrollIntoViewIndex} 137 | resetShouldScrollIntoView={() => setScrollIntoViewIndex(-1)} 138 | /> 139 | ) 140 | })} 141 |
142 | ) 143 | )} 144 |
145 | )} 146 | {results.length === 0 && ( 147 |
148 | No results 149 |
150 | )} 151 |
152 | ) 153 | } 154 | 155 | /* --- 156 | QUERY MENU ITEM 157 | --- */ 158 | function QueryMenuItem({ 159 | title, 160 | label, 161 | icon, 162 | active, 163 | shouldScrollIntoView, 164 | onClick, 165 | onMouseEnter, 166 | onMouseLeave, 167 | resetShouldScrollIntoView, 168 | }: QueryMenuItemProps) { 169 | const rootRef = useRef(null) 170 | 171 | useEffect(() => { 172 | if (!shouldScrollIntoView) return 173 | if (!rootRef.current) return 174 | 175 | rootRef.current.scrollIntoView({ 176 | block: 'nearest', 177 | inline: 'nearest', 178 | }) 179 | 180 | resetShouldScrollIntoView() 181 | }, [shouldScrollIntoView, resetShouldScrollIntoView]) 182 | 183 | return ( 184 |
193 | 194 | {icon} 195 | 196 |
197 |
{title}
198 |
{label}
199 |
200 |
201 | ) 202 | } 203 | 204 | /* --- 205 | QUERY MENU ICON 206 | --- */ 207 | function QueryMenuIcon({ children }: QueryMenuIconProps) { 208 | return ( 209 |
210 | {children} 211 |
212 | ) 213 | } 214 | 215 | export default QueryMenu 216 | -------------------------------------------------------------------------------- /react-block-text/src/components/SelectionRect.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | 3 | import type { SelectionRectProps } from '../types' 4 | 5 | import ColorsContext from '../contexts/ColorsContext' 6 | 7 | function SelectionRect(props: SelectionRectProps) { 8 | const { primaryColorTransparent } = useContext(ColorsContext) 9 | 10 | return ( 11 |
18 | ) 19 | } 20 | 21 | export default SelectionRect 22 | -------------------------------------------------------------------------------- /react-block-text/src/constants.ts: -------------------------------------------------------------------------------- 1 | import type { BlockCategory } from './types' 2 | 3 | export const VERSION = '1.0.0' 4 | 5 | export const COMMANDS = { 6 | SAVE: 'SAVE', 7 | ARROW_UP: 'ARROW_UP', 8 | ARROW_DOWN: 'ARROW_DOWN', 9 | INDENT: 'INDENT', 10 | OUTDENT: 'OUTDENT', 11 | } 12 | 13 | export const INDENT_SIZE = 24 14 | 15 | export const BLOCK_ICONS_WIDTH = 50 16 | 17 | export const QUERY_MENU_WIDTH = 280 18 | 19 | export const QUERY_MENU_HEIGHT = 322 20 | 21 | export const ADD_ITEM_BUTTON_ID = 'react-block-text-add-item-button' 22 | 23 | export const DRAG_ITEM_BUTTON_ID = 'react-block-text-drag-item-button' 24 | 25 | export const DEFAULT_PRIMARY_COLOR = '#3b82f6' 26 | 27 | export const DEFAULT_TEXT_COLOR = '#37352f' 28 | 29 | export const SELECTION_RECT_SCROLL_OFFSET = 64 30 | 31 | export const BASE_SCROLL_SPEED = 16 32 | 33 | export const BLOCK_CATEGORY_TO_LABEL: Record = { 34 | basic: 'Basic blocks', 35 | media: 'Media', 36 | database: 'Database', 37 | advanced: 'Advanced blocks', 38 | inline: 'Inline', 39 | embed: 'Embeds', 40 | } 41 | -------------------------------------------------------------------------------- /react-block-text/src/contexts/ColorsContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react' 2 | 3 | import { DEFAULT_PRIMARY_COLOR, DEFAULT_TEXT_COLOR } from '../constants' 4 | 5 | type ColorsContextType = { 6 | primaryColor: string 7 | primaryColorTransparent: string 8 | primaryColorDark: string 9 | textColor: string 10 | } 11 | 12 | export default createContext({ 13 | primaryColor: DEFAULT_PRIMARY_COLOR, 14 | primaryColorTransparent: DEFAULT_PRIMARY_COLOR, 15 | primaryColorDark: DEFAULT_PRIMARY_COLOR, 16 | textColor: DEFAULT_TEXT_COLOR, 17 | }) 18 | -------------------------------------------------------------------------------- /react-block-text/src/hooks/usePrevious.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | 3 | function usePrevious(value: T, refresh = false) { 4 | const ref = useRef(value) 5 | 6 | useEffect(() => { 7 | ref.current = value 8 | }, [value, refresh]) 9 | 10 | return ref.current 11 | } 12 | 13 | export default usePrevious 14 | -------------------------------------------------------------------------------- /react-block-text/src/icons/Add.tsx: -------------------------------------------------------------------------------- 1 | import { SVGAttributes } from 'react' 2 | 3 | function AddIcon(props: SVGAttributes) { 4 | return ( 5 | 11 | 15 | 16 | ) 17 | } 18 | 19 | export default AddIcon 20 | -------------------------------------------------------------------------------- /react-block-text/src/icons/Drag.tsx: -------------------------------------------------------------------------------- 1 | import { SVGAttributes } from 'react' 2 | 3 | function DragIcon(props: SVGAttributes) { 4 | return ( 5 | 11 | 15 | 19 | 23 | 27 | 31 | 35 | 36 | ) 37 | } 38 | 39 | export default DragIcon 40 | -------------------------------------------------------------------------------- /react-block-text/src/icons/Duplicate.tsx: -------------------------------------------------------------------------------- 1 | import { SVGAttributes } from 'react' 2 | 3 | function DuplicateIcon(props: SVGAttributes) { 4 | return ( 5 | 11 | 17 | 21 | 22 | 23 | 24 | 25 | 26 | ) 27 | } 28 | 29 | export default DuplicateIcon 30 | -------------------------------------------------------------------------------- /react-block-text/src/icons/Trash.tsx: -------------------------------------------------------------------------------- 1 | import { SVGAttributes } from 'react' 2 | 3 | function TrashIcon(props: SVGAttributes) { 4 | return ( 5 | 10 | 15 | 22 | 27 | 34 | 41 | 48 | 49 | ) 50 | } 51 | 52 | export default TrashIcon 53 | -------------------------------------------------------------------------------- /react-block-text/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .public-DraftEditorPlaceholder-root { 6 | user-select: none; 7 | color: #bdc1c9 !important; 8 | } 9 | -------------------------------------------------------------------------------- /react-block-text/src/index.ts: -------------------------------------------------------------------------------- 1 | import './index.css' 2 | 3 | import ReactBlockText from './components/ReactBlockText' 4 | 5 | import headerPlugin from './plugins/header' 6 | import listPlugin from './plugins/list' 7 | import quotePlugin from './plugins/quote' 8 | import todoPlugin from './plugins/todo' 9 | import imagePlugin from './plugins/image' 10 | 11 | export { VERSION } from './constants' 12 | 13 | export { default as ColorsContext } from './contexts/ColorsContext' 14 | 15 | export { 16 | headerPlugin, 17 | listPlugin, 18 | quotePlugin, 19 | todoPlugin, 20 | imagePlugin, 21 | } 22 | 23 | export type { 24 | ReactBlockTextData, 25 | ReactBlockTextDataItem, 26 | ReactBlockTextDataItemType, 27 | ReactBlockTextOnChange, 28 | ReactBlockTextPlugin, 29 | ReactBlockTextPluginData, 30 | ReactBlockTextPluginOptions, 31 | ReactBlockTextPlugins, 32 | ReactBlockTextProps, 33 | } from './types' 34 | 35 | export type { 36 | ReactBlockTextImagePluginSubmition, 37 | ReactBlockTextImagePluginSubmitter, 38 | } from './plugins/image' 39 | 40 | export default ReactBlockText 41 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/header/components/BlockContent.tsx: -------------------------------------------------------------------------------- 1 | import _ from 'clsx' 2 | 3 | import type { BlockContentProps } from '../types' 4 | 5 | const typeToPlaceholder = { 6 | heading1: 'Heading 1', 7 | heading2: 'Heading 2', 8 | heading3: 'Heading 3', 9 | } 10 | 11 | const itemTypeToCssKey = { 12 | heading1: 'h1', 13 | heading2: 'h2', 14 | heading3: 'h3', 15 | } 16 | 17 | function BlockContent(props: BlockContentProps) { 18 | const { item, BlockContentText } = props 19 | 20 | const placeholder = typeToPlaceholder[item.type as keyof typeof typeToPlaceholder] 21 | 22 | return ( 23 |
31 | 36 |
37 | ) 38 | } 39 | 40 | export default BlockContent 41 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/header/components/Icon.tsx: -------------------------------------------------------------------------------- 1 | import type { IconProps } from '../types' 2 | 3 | function Icon({ children }: IconProps) { 4 | return ( 5 | <> 6 |
7 | {children} 8 |
9 |
10 |
11 |
12 |
13 |
14 | 15 | ) 16 | } 17 | 18 | export default Icon 19 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/header/index.ts: -------------------------------------------------------------------------------- 1 | import headerPlugin from './plugin' 2 | 3 | export default headerPlugin 4 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/header/plugin.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactBlockTextPlugins } from '../../types' 2 | 3 | import type { PluginOptions } from './types' 4 | 5 | import BlockContent from './components/BlockContent' 6 | import Icon from './components/Icon' 7 | 8 | const TYPES = ['heading1', 'heading2', 'heading3'] 9 | const TITLES = ['Heading 1', 'Heading 2', 'Heading 3'] 10 | const LABELS = ['Big section heading.', 'Medium section heading.', 'Small section heading.'] 11 | const PADDING_TOPS = [24, 18, 12] 12 | const PADDING_BOTTOMS = [9, 9, 9] 13 | const ICONS_PADDING_TOPS = [7, 5, 2] 14 | 15 | function headerPlugin(options: PluginOptions = {}): ReactBlockTextPlugins { 16 | return TYPES.map((type, i) => () => ({ 17 | type, 18 | blockCategory: 'basic', 19 | title: TITLES[i], 20 | label: LABELS[i], 21 | icon: ( 22 | 23 | H 24 | {i + 1} 25 | 26 | ), 27 | isConvertibleToText: true, 28 | shortcuts: `h${i + 1}`, 29 | paddingTop: PADDING_TOPS[i], 30 | paddingBottom: PADDING_BOTTOMS[i], 31 | iconsPaddingTop: ICONS_PADDING_TOPS[i], 32 | BlockContent: props => ( 33 | 38 | ), 39 | })) 40 | } 41 | 42 | export default headerPlugin 43 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/header/types.ts: -------------------------------------------------------------------------------- 1 | import type { CSSProperties, ReactNode } from 'react' 2 | 3 | import { BlockContentProps as ReactBlockTextBlockContentProps } from '../../types' 4 | 5 | export type PluginOptions = { 6 | classNames?: Partial> 7 | styles?: Partial> 8 | } 9 | 10 | export type BlockContentProps = ReactBlockTextBlockContentProps & PluginOptions 11 | 12 | export type IconProps = { 13 | children: ReactNode 14 | } 15 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/image/assets/icon-image-low-res.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dherault/react-block-text/abb5aa5f14a6097ede973d78774a7370c8032a98/react-block-text/src/plugins/image/assets/icon-image-low-res.png -------------------------------------------------------------------------------- /react-block-text/src/plugins/image/assets/icon-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dherault/react-block-text/abb5aa5f14a6097ede973d78774a7370c8032a98/react-block-text/src/plugins/image/assets/icon-image.png -------------------------------------------------------------------------------- /react-block-text/src/plugins/image/components/BlockContent.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo, useState } from 'react' 2 | 3 | import type { ReactBlockTextDataItem } from '../../..' 4 | import type { BlockContentProps, Metadata, ReactBlockTextImagePluginSubmitter } from '../types' 5 | 6 | import ResizableImage from './ResizableImage' 7 | import ImageSelector from './ImageSelector' 8 | 9 | function BlockContent(props: BlockContentProps) { 10 | const { item, editorState, maxFileSize, onSubmitFile, onSubmitUrl, getUrl, onItemChange } = props 11 | const [url, setUrl] = useState('') 12 | const [file, setFile] = useState(null) 13 | const [submitter, setSubmitter] = useState(null) 14 | const [progress, setProgress] = useState(1) 15 | const [nextImageKey, setNextImageKey] = useState('') 16 | const [isError, setIsError] = useState(false) 17 | 18 | // Width is relative from 0 to 1 --> 0% to 100% 19 | const { imageKey, width, ratio } = useMemo(() => { 20 | try { 21 | const { imageKey, width, ratio } = JSON.parse(item.metadata) as Metadata 22 | 23 | return { imageKey, width, ratio } 24 | } 25 | catch { 26 | return { imageKey: '', width: 1, ratio: 1.618 } 27 | } 28 | }, [item.metadata]) 29 | 30 | const [nextWidth, setNextWidth] = useState(width) 31 | const [nextRatio, setNextRatio] = useState(ratio) 32 | 33 | const handleSubmitFile = useCallback(async (file: File) => { 34 | setFile(file) 35 | 36 | const submitter = await onSubmitFile(file) 37 | 38 | setSubmitter(() => submitter) 39 | }, [onSubmitFile]) 40 | 41 | const handleSubmitUrl = useCallback(async (url: string) => { 42 | setUrl(url) 43 | 44 | const submitter = await onSubmitUrl(url) 45 | 46 | setSubmitter(() => submitter) 47 | }, [onSubmitUrl]) 48 | 49 | const handleFetchUrl = useCallback(async (imageKey: string) => { 50 | const url = await getUrl(imageKey) 51 | 52 | setUrl(url) 53 | }, [getUrl]) 54 | 55 | useEffect(() => { 56 | if (typeof submitter !== 'function') return 57 | 58 | const intervalId = setInterval(() => { 59 | const { progress, imageKey, isError } = submitter() 60 | 61 | setProgress(progress) 62 | 63 | if (imageKey) setNextImageKey(imageKey) 64 | if (isError) setIsError(true) 65 | }, 200) 66 | 67 | return () => { 68 | clearInterval(intervalId) 69 | } 70 | }, [submitter]) 71 | 72 | useEffect(() => { 73 | if (!nextImageKey) return 74 | if (imageKey === nextImageKey && width === nextWidth && ratio === nextRatio) return 75 | 76 | const metadata: Metadata = { 77 | imageKey: imageKey || nextImageKey, 78 | width: nextWidth, 79 | ratio: nextRatio, 80 | } 81 | const nextItem: ReactBlockTextDataItem = { 82 | ...item, 83 | metadata: JSON.stringify(metadata), 84 | } 85 | 86 | onItemChange(nextItem, editorState) 87 | }, [imageKey, nextImageKey, width, nextWidth, ratio, nextRatio, item, editorState, onItemChange]) 88 | 89 | useEffect(() => { 90 | if (!(nextImageKey || imageKey)) return 91 | 92 | handleFetchUrl(nextImageKey || imageKey) 93 | }, [nextImageKey, imageKey, handleFetchUrl]) 94 | 95 | if (isError) { 96 | return ( 97 |
98 | An error occurred while uploading the image. 99 |
100 | ) 101 | } 102 | 103 | if (url || file) { 104 | return ( 105 | 113 | ) 114 | } 115 | 116 | if (!item.metadata) { 117 | return ( 118 | 123 | ) 124 | } 125 | 126 | // Skeleton mode 127 | return ( 128 | 135 | ) 136 | } 137 | 138 | export default BlockContent 139 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/image/components/Icon.tsx: -------------------------------------------------------------------------------- 1 | function Icon() { 2 | return ( 3 |
9 | ) 10 | } 11 | 12 | export default Icon 13 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/image/components/ImageIcon.tsx: -------------------------------------------------------------------------------- 1 | import { SVGAttributes } from 'react' 2 | 3 | function ImageIcon(props: SVGAttributes) { 4 | return ( 5 | 9 | 13 | 14 | ) 15 | } 16 | 17 | export default ImageIcon 18 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/image/components/ImageSelector.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from 'react' 2 | import { Transition } from 'react-transition-group' 3 | 4 | import type { ImageSelectorProps } from '../types' 5 | 6 | import ImageIcon from './ImageIcon' 7 | import ImageUploader from './ImageUploader' 8 | 9 | const transitionDuration = 100 10 | 11 | const defaultStyle = { 12 | transition: `opacity ${transitionDuration}ms ease-in-out`, 13 | opacity: 0, 14 | } 15 | 16 | const transitionStyles = { 17 | entering: { opacity: 1 }, 18 | entered: { opacity: 1 }, 19 | exiting: { opacity: 0 }, 20 | exited: { opacity: 0 }, 21 | } 22 | 23 | function ImageSelector({ maxFileSize, onSubmitFile, onSubmitUrl }: ImageSelectorProps) { 24 | const dialogRef = useRef(null) 25 | const [open, setOpen] = useState(false) 26 | 27 | return ( 28 |
29 |
setOpen(x => !x)} 33 | > 34 | 35 |
36 | Add an image 37 |
38 |
39 |
40 | 47 | {state => ( 48 |
56 | 61 |
62 | )} 63 |
64 |
65 | {open && ( 66 |
setOpen(false)} 69 | /> 70 | )} 71 |
72 | ) 73 | } 74 | 75 | export default ImageSelector 76 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/image/components/ImageUploader.tsx: -------------------------------------------------------------------------------- 1 | import { type ChangeEvent, type FormEvent, useCallback, useContext, useRef, useState } from 'react' 2 | 3 | import { ColorsContext } from '../../..' 4 | 5 | import type { ImageUploaderProps, Mode } from '../types' 6 | 7 | function ImageUploader({ maxFileSize, onSubmitFile, onSubmitUrl }: ImageUploaderProps) { 8 | const { primaryColor, primaryColorDark, primaryColorTransparent } = useContext(ColorsContext) 9 | 10 | const fileInputRef = useRef(null) 11 | const [mode, setMode] = useState('upload') 12 | const [url, setUrl] = useState('') 13 | 14 | const handleUploadClick = useCallback(() => { 15 | fileInputRef.current?.click() 16 | }, []) 17 | 18 | const handleUploadChange = useCallback((event: ChangeEvent) => { 19 | const file = event.target.files?.[0] 20 | 21 | if (!file) return 22 | 23 | onSubmitFile(file) 24 | }, [onSubmitFile]) 25 | 26 | const handleUrlSubmit = useCallback((event: FormEvent) => { 27 | event.preventDefault() 28 | 29 | if (!url) return 30 | 31 | onSubmitUrl(url) 32 | }, [url, onSubmitUrl]) 33 | 34 | const renderTabItem = useCallback((label: string, itemMode: Mode) => ( 35 |
setMode(itemMode)} 38 | > 39 |
40 | {label} 41 |
42 | {mode === itemMode && ( 43 |
44 | )} 45 |
46 | ), [mode]) 47 | 48 | const renderUpload = useCallback(() => ( 49 | <> 50 | 57 | 64 | {!!maxFileSize && ( 65 |
66 | The maximum size per file is 67 | {' '} 68 | {maxFileSize} 69 | . 70 |
71 | )} 72 | 73 | ), [maxFileSize, handleUploadClick, handleUploadChange]) 74 | 75 | const renderUrl = useCallback(() => ( 76 |
77 | setUrl(event.target.value)} 81 | placeholder="Paste the image link..." 82 | style={{ '--shadow-color': primaryColorTransparent } as any} 83 | className="rbt-w-full rbt-h-[28px] rbt-bg-zinc-50 rbt-border rbt-rounded rbt-text-sm rbt-p-1.5 rbt-outline-none focus:rbt-ring focus:rbt-ring-[var(--shadow-color)]" 84 | /> 85 |
86 | 96 |
97 |
98 | Works with any image from the web 99 |
100 |
101 | ), [primaryColor, primaryColorTransparent, primaryColorDark, url, handleUrlSubmit]) 102 | 103 | return ( 104 |
105 |
106 | {renderTabItem('Upload', 'upload')} 107 | {renderTabItem('Embed link', 'url')} 108 |
109 |
110 | {mode === 'upload' && renderUpload()} 111 | {mode === 'url' && renderUrl()} 112 |
113 |
114 | ) 115 | } 116 | 117 | export default ImageUploader 118 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/image/components/ResizableImage.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | import type { ResizableImageProps } from '../types' 4 | 5 | function ResizableImage({ src, width, ratio, progress, setWidth, setRatio }: ResizableImageProps) { 6 | const [image, setImage] = useState(null) 7 | console.log(false && setWidth) 8 | 9 | useEffect(() => { 10 | if (!src) return 11 | 12 | const img = new Image() 13 | 14 | img.src = src 15 | 16 | img.addEventListener('load', () => { 17 | setImage(img) 18 | setRatio(img.width / img.height) 19 | }) 20 | }, [src, setRatio]) 21 | 22 | return ( 23 |
24 |
28 |
29 | {!!image && ( 30 | Something went wrong... 35 | )} 36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | {progress < 1 && ( 46 |
47 |
48 |
52 |
53 | {Math.round(progress * 100)} 54 | % 55 |
56 |
57 |
58 | )} 59 |
60 | ) 61 | } 62 | 63 | export default ResizableImage 64 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/image/index.ts: -------------------------------------------------------------------------------- 1 | import imagePlugin from './plugin' 2 | 3 | export default imagePlugin 4 | 5 | export type { ReactBlockTextImagePluginSubmition, ReactBlockTextImagePluginSubmitter } from './types' 6 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/image/plugin.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactBlockTextPlugins } from '../../types' 2 | 3 | import type { PluginOptions } from './types' 4 | import BlockContent from './components/BlockContent' 5 | import Icon from './components/Icon' 6 | 7 | function imagePlugin(options: PluginOptions): ReactBlockTextPlugins { 8 | if (typeof options.onSubmitFile !== 'function') throw new Error('Image plugin: you must provide a "onSubmitFile" function to handle file uploads.') 9 | if (typeof options.onSubmitUrl !== 'function') throw new Error('Image plugin: you must provide a "onSubmitUrl" function to handle url uploads.') 10 | if (typeof options.getUrl !== 'function') throw new Error('Image plugin: you must provide a "getUrl" function to handle image downloads.') 11 | 12 | return [ 13 | ({ onChange }) => ({ 14 | type: 'image', 15 | blockCategory: 'media', 16 | title: 'Image', 17 | label: 'Upload or embed with a link.', 18 | shortcuts: 'img,photo,picture', 19 | icon: , 20 | isConvertibleToText: false, 21 | paddingTop: 5, 22 | paddingBottom: 5, 23 | BlockContent: props => ( 24 | 32 | ), 33 | }), 34 | ] 35 | } 36 | 37 | export default imagePlugin 38 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/image/types.ts: -------------------------------------------------------------------------------- 1 | import type { Dispatch, SetStateAction } from 'react' 2 | 3 | import type { BlockContentProps as ReactBlockTextBlockContentProps, ReactBlockTextOnChange } from '../../types' 4 | 5 | export type Metadata = { 6 | imageKey: string 7 | width: number // between 0 and 1 8 | ratio: number // width / height 9 | } 10 | 11 | export type ReactBlockTextImagePluginSubmition = { 12 | progress: number // Between 0 and 1 13 | imageKey?: string // The reference to the image once it's uploaded 14 | isError?: boolean 15 | } 16 | 17 | export type ReactBlockTextImagePluginSubmitter = () => ReactBlockTextImagePluginSubmition 18 | 19 | export type PluginOptions = { 20 | maxFileSize?: string 21 | onSubmitFile: (file: File) => Promise 22 | onSubmitUrl: (url: string) => Promise 23 | getUrl: (imageKey: string) => Promise 24 | } 25 | 26 | export type BlockContentProps = ReactBlockTextBlockContentProps & { 27 | maxFileSize?: string 28 | onItemChange: ReactBlockTextOnChange 29 | onSubmitFile: (file: File) => Promise 30 | onSubmitUrl: (url: string) => Promise 31 | getUrl: (imageKey: string) => Promise 32 | } 33 | 34 | export type ImageSelectorProps = { 35 | maxFileSize?: string 36 | onSubmitFile: (file: File) => void 37 | onSubmitUrl: (url: string) => void 38 | } 39 | 40 | export type Mode = 'upload' | 'url' 41 | 42 | export type ImageUploaderProps = { 43 | maxFileSize?: string 44 | onSubmitFile: (file: File) => void 45 | onSubmitUrl: (url: string) => void 46 | } 47 | 48 | export type LoadingImageProps = { 49 | file?: File | null 50 | url?: string 51 | } 52 | 53 | export type ResizableImageProps = { 54 | src?: string 55 | width: number 56 | ratio: number 57 | progress: number 58 | setWidth: Dispatch> 59 | setRatio: Dispatch> 60 | } 61 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/list/components/BlockContent.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | import { toRoman } from 'roman-numerals' 3 | import { toAbc } from 'abc-list' 4 | 5 | import type { BlockContentProps } from '../../../types' 6 | 7 | const depthToBullet = ['•', '◦', '▪'] 8 | 9 | function BlockContentList(props: BlockContentProps) { 10 | const { item, onBlockSelection, onRectSelectionMouseDown, BlockContentText } = props 11 | 12 | const { index, depth } = useMemo(() => { 13 | try { 14 | const { index, depth } = JSON.parse(item.metadata) 15 | 16 | return { index, depth } 17 | } 18 | catch { 19 | return { index: 0, depth: 0 } 20 | } 21 | }, [item.metadata]) 22 | 23 | const label = useMemo(() => { 24 | const cycledDepth = depth % 3 25 | 26 | if (cycledDepth === 0) return `${index + 1}` 27 | if (cycledDepth === 1) return toAbc(index) 28 | 29 | return toRoman(index + 1).toLowerCase() 30 | }, [index, depth]) 31 | 32 | return ( 33 |
34 |
39 | {item.metadata.length ? ( 40 |
47 | ) : ( 48 |
52 | )} 53 |
54 |
59 |
60 | 64 |
65 |
66 | ) 67 | } 68 | 69 | export default BlockContentList 70 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/list/components/BulletedListIcon.tsx: -------------------------------------------------------------------------------- 1 | function BulletedListIcon() { 2 | return ( 3 |
4 |
5 | • 6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | ) 14 | } 15 | 16 | export default BulletedListIcon 17 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/list/components/NumberedListIcon.tsx: -------------------------------------------------------------------------------- 1 | function NumberedListIcon() { 2 | return ( 3 |
4 |
5 | 1 6 | 7 | . 8 | 9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | ) 17 | } 18 | 19 | export default NumberedListIcon 20 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/list/index.ts: -------------------------------------------------------------------------------- 1 | import listPlugin from './plugin' 2 | 3 | export default listPlugin 4 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/list/plugin.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactBlockTextPlugins } from '../../types' 2 | 3 | import type { PluginOptions } from './types' 4 | 5 | import applyMetadatas from './utils/applyMetadata' 6 | 7 | import BlockContent from './components/BlockContent' 8 | import BulletedListIcon from './components/BulletedListIcon' 9 | import NumberedListIcon from './components/NumberedListIcon' 10 | 11 | const TYPES = ['bulleted-list', 'numbered-list'] 12 | const TITLES = ['Bulleted list', 'Numbered list'] 13 | const LABELS = ['Create a simple bulleted list.', 'Create a list with numbering.'] 14 | const ICONS = [, ] 15 | 16 | function listPlugin(options?: PluginOptions): ReactBlockTextPlugins { 17 | const bulleted = options?.bulleted ?? true 18 | const numbered = options?.numbered ?? true 19 | 20 | const plugins: ReactBlockTextPlugins = [] 21 | 22 | if (bulleted) { 23 | plugins.push(() => ({ 24 | type: TYPES[0], 25 | blockCategory: 'basic', 26 | title: TITLES[0], 27 | label: LABELS[0], 28 | icon: ICONS[0], 29 | isConvertibleToText: true, 30 | isNewItemOfSameType: true, 31 | shortcuts: 'task', 32 | maxIndent: 5, 33 | applyMetadatas, 34 | BlockContent, 35 | })) 36 | } 37 | 38 | if (numbered) { 39 | plugins.push(() => ({ 40 | type: TYPES[1], 41 | blockCategory: 'basic', 42 | title: TITLES[1], 43 | label: LABELS[1], 44 | icon: ICONS[1], 45 | isConvertibleToText: true, 46 | isNewItemOfSameType: true, 47 | shortcuts: 'task', 48 | maxIndent: 5, 49 | applyMetadatas, 50 | BlockContent, 51 | })) 52 | } 53 | 54 | return plugins 55 | } 56 | 57 | export default listPlugin 58 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/list/types.ts: -------------------------------------------------------------------------------- 1 | export type PluginOptions = { 2 | bulleted?: boolean 3 | numbered?: boolean 4 | } 5 | 6 | export type NumberedListMetada = { 7 | index: number 8 | depth: number 9 | } 10 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/list/utils/applyMetadata.ts: -------------------------------------------------------------------------------- 1 | import type { ReactBlockTextDataItem } from '../../../types' 2 | 3 | function applyMetadatas(_index: number, value: ReactBlockTextDataItem[]) { 4 | const nextValue = [...value] 5 | 6 | for (let i = 0; i < nextValue.length; i++) { 7 | const { type } = nextValue[i] 8 | 9 | if (type === 'numbered-list') nextValue[i] = applyNumberedListMetadata(nextValue, i) 10 | if (type === 'bulleted-list') nextValue[i] = applyBulletedListMetadata(nextValue, i) 11 | } 12 | 13 | return nextValue 14 | } 15 | 16 | function applyNumberedListMetadata(value: ReactBlockTextDataItem[], index: number) { 17 | const item = value[index] 18 | const previousListItem = findPreviousListItem(value, index) 19 | 20 | if (previousListItem) { 21 | const { index: previousIndex, depth } = JSON.parse(previousListItem.metadata) 22 | const isIndented = previousListItem.indent < item.indent 23 | 24 | return { 25 | ...item, 26 | metadata: JSON.stringify({ 27 | index: isIndented ? 0 : previousIndex + 1, 28 | depth: isIndented ? depth + 1 : depth, 29 | }), 30 | } 31 | } 32 | 33 | const previousItem = value[index - 1] 34 | 35 | if (previousItem.type === 'numbered-list') { 36 | return { 37 | ...item, 38 | indent: previousItem.indent + 1, 39 | metadata: JSON.stringify({ 40 | index: 0, 41 | depth: previousItem.indent + 1, 42 | }), 43 | } 44 | } 45 | 46 | return { 47 | ...item, 48 | indent: 0, 49 | metadata: JSON.stringify({ 50 | index: 0, 51 | depth: 0, 52 | }), 53 | } 54 | 55 | } 56 | 57 | function applyBulletedListMetadata(value: ReactBlockTextDataItem[], index: number) { 58 | const item = value[index] 59 | const previousListItem = findPreviousListItem(value, index) 60 | 61 | if (previousListItem) return item 62 | 63 | const previousItem = value[index - 1] 64 | 65 | if (previousItem && previousItem.type === 'bulleted-list') { 66 | return { 67 | ...item, 68 | indent: Math.min(previousItem.indent + 1, item.indent), 69 | } 70 | } 71 | 72 | return { 73 | ...item, 74 | indent: 0, 75 | } 76 | } 77 | 78 | function findPreviousListItem(value: ReactBlockTextDataItem[], index: number) { 79 | const item = value[index] 80 | let lastIndent = item.indent 81 | 82 | for (let i = index - 1; i >= 0; i--) { 83 | if (value[i].indent > lastIndent) continue 84 | if (value[i].type !== item.type) return null 85 | if (value[i].indent === item.indent) return value[i] 86 | 87 | lastIndent = value[i].indent 88 | } 89 | 90 | return null 91 | } 92 | 93 | export default applyMetadatas 94 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/quote/components/BlockContent.tsx: -------------------------------------------------------------------------------- 1 | import type { BlockContentProps } from '../../../types' 2 | 3 | function BlockContent(props: BlockContentProps) { 4 | const { onBlockSelection, onRectSelectionMouseDown, BlockContentText } = props 5 | 6 | return ( 7 |
8 |
13 |
18 |
19 | 20 |
21 |
22 | ) 23 | } 24 | 25 | export default BlockContent 26 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/quote/components/Icon.tsx: -------------------------------------------------------------------------------- 1 | function Icon() { 2 | return ( 3 |
4 |
5 |
6 | To be 7 |
8 | or not 9 |
10 | to be 11 |
12 |
13 | ) 14 | } 15 | 16 | export default Icon 17 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/quote/index.ts: -------------------------------------------------------------------------------- 1 | import quotePlugin from './plugin' 2 | 3 | export default quotePlugin 4 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/quote/plugin.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactBlockTextPlugins } from '../../types' 2 | 3 | import BlockContent from './components/BlockContent' 4 | import Icon from './components/Icon' 5 | 6 | function quotePlugin(): ReactBlockTextPlugins { 7 | return [ 8 | () => ({ 9 | type: 'quote', 10 | blockCategory: 'basic', 11 | title: 'Quote', 12 | label: 'Capture a quote.', 13 | shortcuts: 'citation', 14 | icon: , 15 | isConvertibleToText: true, 16 | paddingTop: 5, 17 | paddingBottom: 5, 18 | BlockContent, 19 | }), 20 | ] 21 | } 22 | 23 | export default quotePlugin 24 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/text/components/BlockContent.tsx: -------------------------------------------------------------------------------- 1 | import type { BlockContentProps } from '../../../types' 2 | 3 | function BlockContent(props: BlockContentProps) { 4 | const { BlockContentText } = props 5 | 6 | return ( 7 | 8 | ) 9 | 10 | } 11 | 12 | export default BlockContent 13 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/text/components/Icon.tsx: -------------------------------------------------------------------------------- 1 | function Icon() { 2 | return ( 3 |
4 | Aa 5 |
6 | ) 7 | } 8 | 9 | export default Icon 10 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/text/index.ts: -------------------------------------------------------------------------------- 1 | import textPlugin from './plugin' 2 | 3 | export default textPlugin 4 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/text/plugin.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactBlockTextPlugins } from '../../types' 2 | 3 | import BlockContent from './components/BlockContent' 4 | import Icon from './components/Icon' 5 | 6 | function textPlugin(): ReactBlockTextPlugins { 7 | return [ 8 | () => ({ 9 | type: 'text', 10 | blockCategory: 'basic', 11 | title: 'Text', 12 | label: 'Just start writing with plain text.', 13 | shortcuts: 'txt', 14 | icon: , 15 | BlockContent, 16 | }), 17 | ] 18 | } 19 | 20 | export default textPlugin 21 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/todo/components/BlockContent.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | 3 | import type { BlockContentProps } from '../types' 4 | 5 | import applyTodoStyle from '../utils/applyTodoStyle' 6 | 7 | import Checkbox from './Checkbox' 8 | 9 | function BlockContent(props: BlockContentProps) { 10 | const { item, editorState, readOnly, BlockContentText, onItemChange, forceBlurContent } = props 11 | 12 | const handleCheck = useCallback((checked: boolean) => { 13 | if (readOnly) return 14 | 15 | const nextItem = { ...item, metadata: checked ? 'true' : 'false' } 16 | const nextEditorState = applyTodoStyle(nextItem, editorState, false) 17 | 18 | onItemChange(nextItem, nextEditorState) 19 | 20 | // Blur the to-do on next render 21 | forceBlurContent() 22 | }, [readOnly, item, editorState, onItemChange, forceBlurContent]) 23 | 24 | return ( 25 |
26 |
27 | 33 |
34 |
39 |
40 | 44 |
45 |
46 | ) 47 | } 48 | 49 | export default BlockContent 50 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/todo/components/CheckIcon.tsx: -------------------------------------------------------------------------------- 1 | import { SVGAttributes } from 'react' 2 | 3 | function CheckIcon(props: SVGAttributes) { 4 | return ( 5 | 11 | 12 | 13 | ) 14 | } 15 | 16 | export default CheckIcon 17 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/todo/components/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useContext } from 'react' 2 | import _ from 'clsx' 3 | 4 | import type { CheckboxProps } from '../types' 5 | 6 | import { ColorsContext } from '../../..' 7 | 8 | import CheckIcon from './CheckIcon' 9 | 10 | function Checkbox({ checked, onCheck, ...props }: CheckboxProps) { 11 | const { primaryColor } = useContext(ColorsContext) 12 | 13 | const handleClick = useCallback(() => { 14 | onCheck(!checked) 15 | }, [checked, onCheck]) 16 | 17 | return ( 18 |
19 |
23 |
29 |
35 | 36 |
37 |
38 |
39 | ) 40 | } 41 | 42 | export default Checkbox 43 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/todo/components/Icon.tsx: -------------------------------------------------------------------------------- 1 | import Checkbox from './Checkbox' 2 | 3 | function Icon() { 4 | return ( 5 |
6 |
7 | {}} 10 | /> 11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | ) 19 | } 20 | 21 | export default Icon 22 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/todo/constants.ts: -------------------------------------------------------------------------------- 1 | export const INLINE_STYLES = { 2 | TODO_CHECKED: 'TODO_CHECKED', 3 | } 4 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/todo/index.ts: -------------------------------------------------------------------------------- 1 | import todoPlugin from './plugin' 2 | 3 | export default todoPlugin 4 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/todo/plugin.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactBlockTextPlugins } from '../../types' 2 | 3 | import { INLINE_STYLES } from './constants' 4 | 5 | import applyTodoStyle from './utils/applyTodoStyle' 6 | 7 | import BlockContent from './components/BlockContent' 8 | import Icon from './components/Icon' 9 | 10 | function todoPlugin(): ReactBlockTextPlugins { 11 | return [ 12 | ({ onChange }) => ({ 13 | type: 'todo', 14 | blockCategory: 'basic', 15 | title: 'To-do list', 16 | label: 'Track tasks with a to-do list.', 17 | shortcuts: 'todo', 18 | icon: , 19 | isConvertibleToText: true, 20 | isNewItemOfSameType: true, 21 | paddingTop: 5, 22 | paddingBottom: 5, 23 | styleMap: { 24 | [INLINE_STYLES.TODO_CHECKED]: { 25 | color: '#9ca3af', 26 | textDecoration: 'line-through', 27 | textDecorationThickness: 'from-font', 28 | }, 29 | }, 30 | applyStyles: applyTodoStyle, 31 | BlockContent: props => ( 32 | 36 | ), 37 | }), 38 | ] 39 | } 40 | 41 | export default todoPlugin 42 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/todo/types.ts: -------------------------------------------------------------------------------- 1 | import type { HTMLAttributes } from 'react' 2 | 3 | import type { BlockContentProps as ReactBlockTextBlockContentProps, ReactBlockTextOnChange } from '../../types' 4 | 5 | export type CheckboxProps = HTMLAttributes & { 6 | checked: boolean 7 | onCheck: (checked: boolean) => void 8 | } 9 | 10 | export type BlockContentProps = ReactBlockTextBlockContentProps & { 11 | onItemChange: ReactBlockTextOnChange 12 | } 13 | -------------------------------------------------------------------------------- /react-block-text/src/plugins/todo/utils/applyTodoStyle.ts: -------------------------------------------------------------------------------- 1 | import { EditorState, Modifier, SelectionState } from 'draft-js' 2 | 3 | import { ReactBlockTextDataItem } from '../../../types' 4 | 5 | import { INLINE_STYLES } from '../constants' 6 | 7 | function applyTodoStyle(item: ReactBlockTextDataItem, editorState: EditorState, skipSelection = true) { 8 | let currentSelection = editorState.getSelection() 9 | const contentState = editorState.getCurrentContent() 10 | const firstBlock = contentState.getFirstBlock() 11 | const lastBlock = contentState.getLastBlock() 12 | const selection = SelectionState.createEmpty(firstBlock.getKey()).merge({ 13 | anchorKey: firstBlock.getKey(), 14 | anchorOffset: 0, 15 | focusKey: lastBlock.getKey(), 16 | focusOffset: lastBlock.getText().length, 17 | }) 18 | const modify = item.metadata === 'true' ? Modifier.applyInlineStyle : Modifier.removeInlineStyle 19 | const nextContentState = modify(contentState, selection, INLINE_STYLES.TODO_CHECKED) 20 | const nextEditorState = EditorState.push(editorState, nextContentState, 'change-inline-style') 21 | 22 | if (skipSelection) return EditorState.forceSelection(nextEditorState, currentSelection) 23 | 24 | if (currentSelection.getAnchorOffset() !== currentSelection.getFocusOffset()) { 25 | currentSelection = currentSelection.merge({ 26 | anchorOffset: currentSelection.getFocusOffset(), 27 | }) 28 | } 29 | 30 | return EditorState.forceSelection(nextEditorState, currentSelection) 31 | } 32 | 33 | export default applyTodoStyle 34 | -------------------------------------------------------------------------------- /react-block-text/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { CSSProperties, ComponentType, MouseEvent as ReactMouseEvent, ReactNode } from 'react' 2 | import type { DraftHandleValue, Editor, EditorState } from 'draft-js' 3 | 4 | export type ReactBlockTextDataItemType = 'text' | string 5 | 6 | export type ReactBlockTextDataItem = { 7 | reactBlockTextVersion: string 8 | id: string 9 | type: ReactBlockTextDataItemType 10 | data: string 11 | metadata: string 12 | indent: number 13 | } 14 | 15 | export type ReactBlockTextData = ReactBlockTextDataItem[] 16 | 17 | export type ReactBlockTextOnChange = (item: ReactBlockTextDataItem, editorState: EditorState) => void 18 | 19 | export type ReactBlockTextPluginOptions = { 20 | onChange: ReactBlockTextOnChange 21 | } 22 | 23 | export type BlockCategory = 'basic' | 'media' | 'database' | 'advanced' | 'inline' | 'embed' 24 | 25 | export type ReactBlockTextPluginData = { 26 | type: string 27 | blockCategory: BlockCategory 28 | title: string 29 | label: string 30 | shortcuts: string 31 | icon: ReactNode 32 | isConvertibleToText?: boolean 33 | isNewItemOfSameType?: boolean 34 | maxIndent?: number // default: 0 35 | paddingTop?: number // default: 3 36 | paddingBottom?: number // default: 3 37 | iconsPaddingTop?: number // default: 0 38 | styleMap?: Record 39 | applyStyles?: (item: ReactBlockTextDataItem, editorState: EditorState) => EditorState 40 | applyMetadatas?: (index: number, value: ReactBlockTextDataItem[], editorStates: ReactBlockTextEditorStates) => ReactBlockTextDataItem[] 41 | BlockContent: ComponentType 42 | } 43 | 44 | export type ReactBlockTextPlugin = (options: ReactBlockTextPluginOptions) => ReactBlockTextPluginData 45 | 46 | export type ReactBlockTextPlugins = ReactBlockTextPlugin[] 47 | 48 | export type ReactBlockTextEditorStates = Record 49 | 50 | export type ReactBlockTextProps = { 51 | value?: string 52 | plugins?: ReactBlockTextPlugins 53 | readOnly?: boolean 54 | paddingTop?: number 55 | paddingBottom?: number 56 | paddingLeft?: number 57 | paddingRight?: number 58 | primaryColor?: string | null | undefined 59 | textColor?: string | null | undefined 60 | className?: string 61 | style?: CSSProperties 62 | onChange?: (value: string) => void 63 | onSave?: () => void 64 | } 65 | 66 | export type BlockProps = { 67 | children: ReactNode 68 | pluginsData: ReactBlockTextPluginData[] 69 | item: ReactBlockTextDataItem 70 | index: number 71 | readOnly: boolean 72 | selected: boolean 73 | hovered: boolean 74 | isDraggingTop: boolean | null 75 | paddingLeft?: number 76 | paddingRight?: number 77 | noPadding?: boolean 78 | registerSelectionRef: (ref: any) => void 79 | onAddItem: () => void 80 | onDeleteItem: () => void 81 | onDuplicateItem: () => void 82 | onMouseDown: () => void 83 | onMouseMove: () => void 84 | onMouseLeave: () => void 85 | onRectSelectionMouseDown: (event: ReactMouseEvent) => void 86 | onDragStart: () => void 87 | onDrag: (index: number, isTop: boolean | null) => void 88 | onDragEnd: () => void 89 | onBlockMenuOpen: () => void 90 | onBlockMenuClose: () => void 91 | focusContent: () => void 92 | focusContentAtStart: () => void 93 | focusContentAtEnd: () => void 94 | focusNextContent: () => void 95 | blurContent: () => void 96 | blockContentProps: BlockContentProps 97 | } 98 | 99 | export type BlockContentProps = { 100 | BlockContentText: ComponentType 101 | pluginsData: ReactBlockTextPluginData[] 102 | item: ReactBlockTextDataItem 103 | index: number 104 | editorState: EditorState 105 | readOnly: boolean 106 | focused: boolean 107 | isSelecting: boolean 108 | placeholder: string 109 | fallbackPlaceholder: string 110 | registerRef: (ref: any) => void 111 | onChange: (editorState: EditorState) => void 112 | onKeyCommand: (command: string) => DraftHandleValue 113 | onReturn: (event: any) => DraftHandleValue 114 | onUpArrow: (event: any) => void 115 | onDownArrow: (event: any) => void 116 | onFocus: () => void 117 | onBlur: () => void 118 | onPaste: () => DraftHandleValue 119 | onBlockSelection: () => void 120 | onRectSelectionMouseDown: (event: ReactMouseEvent) => void 121 | focusContent: () => void 122 | focusContentAtStart: () => void 123 | focusContentAtEnd: () => void 124 | focusNextContent: () => void 125 | blurContent: () => void 126 | forceBlurContent: () => void 127 | } 128 | 129 | export type BlockCommonProps = { 130 | [K in keyof BlockProps & keyof BlockContentProps]: BlockProps[K] | BlockContentProps[K] 131 | } 132 | 133 | export type QueryMenuProps = { 134 | pluginsData: ReactBlockTextPluginData[] 135 | query: string 136 | top?: number 137 | bottom?: number 138 | left: number 139 | onSelect: (command: ReactBlockTextDataItemType) => void 140 | onClose: () => void 141 | } 142 | 143 | export type QueryMenuItemProps = { 144 | title: string 145 | label: string 146 | icon: ReactNode 147 | active: boolean 148 | shouldScrollIntoView: boolean 149 | resetShouldScrollIntoView: () => void 150 | onClick: () => void 151 | onMouseEnter: () => void 152 | onMouseLeave: () => void 153 | } 154 | 155 | export type QueryMenuIconProps = { 156 | children: ReactNode 157 | } 158 | 159 | export type BlockMenuProps = { 160 | top: number 161 | left: number 162 | onDeleteItem: () => void 163 | onDuplicateItem: () => void 164 | onClose: () => void 165 | } 166 | 167 | export type BlockMenuItemProps = { 168 | icon: ReactNode 169 | label: string 170 | onClick: () => void 171 | } 172 | 173 | export type DragLayerProps = { 174 | pluginsData: ReactBlockTextPluginData[] 175 | blockProps: Omit[] 176 | dragIndex: number 177 | } 178 | 179 | export type SelectionRectProps = { 180 | top: number 181 | left: number 182 | width: number 183 | height: number 184 | } 185 | 186 | export type QueryMenuData = { 187 | id: string 188 | query: string 189 | top?: number 190 | bottom?: number 191 | left: number 192 | noSlash?: boolean 193 | } 194 | 195 | export type TopLeft = { 196 | top: number 197 | left: number 198 | } 199 | 200 | export type SelectionTextData = { 201 | items: ReactBlockTextDataItem[] 202 | startId: string 203 | text: string 204 | } 205 | 206 | export type SelectionRectData = SelectionRectProps & { 207 | isSelecting: boolean 208 | anchorTop: number 209 | anchorLeft: number 210 | selectedIds: string[] 211 | } 212 | 213 | export type DragData = { 214 | dragIndex: number 215 | dropIndex: number 216 | isTop: boolean | null 217 | } 218 | 219 | export type EditorRefRegistry = Record 220 | 221 | export type XY = { 222 | x: number 223 | y: number 224 | } 225 | 226 | export type DragAndDropCollect = { 227 | handlerId: string | symbol | null 228 | } 229 | 230 | export type ArrowData = { 231 | isTop: boolean 232 | index: number 233 | offset: number 234 | } 235 | -------------------------------------------------------------------------------- /react-block-text/src/utils/appendItemData.ts: -------------------------------------------------------------------------------- 1 | import { type EditorState, convertToRaw } from 'draft-js' 2 | 3 | import type { ReactBlockTextDataItem } from '../types' 4 | 5 | function appendItemData(item: Omit, editorState: EditorState) { 6 | return { 7 | metadata: '', 8 | ...item, 9 | data: JSON.stringify(convertToRaw(editorState.getCurrentContent())), 10 | } as ReactBlockTextDataItem 11 | } 12 | 13 | export default appendItemData 14 | -------------------------------------------------------------------------------- /react-block-text/src/utils/countCharactersOnLastBlockLines.ts: -------------------------------------------------------------------------------- 1 | import findParentBlock from './findParentBlock' 2 | import findChildWithProperty from './findChildWithProperty' 3 | 4 | function countCharactersOnLastBlockLines(id: string, editorElement: HTMLElement | null | undefined, injectionElement: HTMLElement) { 5 | if (!(editorElement && injectionElement)) return 0 6 | 7 | // We want to reconstitute the Block element with its content 8 | const blockElement = findParentBlock(id, editorElement) 9 | 10 | if (!blockElement) return null 11 | 12 | // So we clone it 13 | const blockElementClone = blockElement.cloneNode(true) as HTMLElement 14 | // Find its content 15 | const contentElement = findChildWithProperty(blockElementClone, 'data-contents', 'true') 16 | 17 | if (!contentElement) return null 18 | 19 | injectionElement.appendChild(blockElementClone) 20 | 21 | // Remove first content blocks, keep last one 22 | for (let i = 0; i < contentElement.children.length - 1; i++) { 23 | contentElement.removeChild(contentElement.children[i]) 24 | } 25 | 26 | // We count the characters of each line 27 | const count = [0] 28 | const text = contentElement.innerText ?? '' 29 | const words = text.split(/ |-/) 30 | let height = contentElement.offsetHeight 31 | 32 | contentElement.innerText = text 33 | 34 | const { length } = words 35 | 36 | for (let i = 0; i < length; i++) { 37 | const lastWord = words.pop() ?? '' 38 | 39 | count[0] += lastWord.length + 1 40 | 41 | contentElement.innerText = contentElement.innerText.slice(0, -(lastWord.length + 1)) 42 | 43 | if (contentElement.offsetHeight < height) { 44 | height = contentElement.offsetHeight 45 | count[0] -= 1 46 | 47 | if (contentElement.innerText.length === 0) break 48 | 49 | count.unshift(0) 50 | } 51 | } 52 | 53 | injectionElement.removeChild(blockElementClone) 54 | 55 | return count 56 | } 57 | 58 | export default countCharactersOnLastBlockLines 59 | -------------------------------------------------------------------------------- /react-block-text/src/utils/findAttributeInParents.ts: -------------------------------------------------------------------------------- 1 | function findAttributeInParents(element: HTMLElement, attribute: string) { 2 | if (element.hasAttribute(attribute)) return element.getAttribute(attribute) 3 | 4 | if (!element.parentElement) return null 5 | 6 | return findAttributeInParents(element.parentElement, attribute) 7 | } 8 | 9 | export default findAttributeInParents 10 | -------------------------------------------------------------------------------- /react-block-text/src/utils/findChildWithProperty.ts: -------------------------------------------------------------------------------- 1 | function findChildWithProperty(parent: HTMLElement, name: string, value: string): HTMLElement | null { 2 | if (parent.getAttribute(name) === value) return parent 3 | if (!parent.children) return null 4 | 5 | for (let i = 0; i < parent.children.length; i++) { 6 | const child = parent.children[i] as HTMLElement 7 | const found = findChildWithProperty(child, name, value) 8 | 9 | if (found) return found 10 | } 11 | 12 | return null 13 | } 14 | 15 | export default findChildWithProperty 16 | -------------------------------------------------------------------------------- /react-block-text/src/utils/findParentBlock.ts: -------------------------------------------------------------------------------- 1 | function findParentBlock(id: string, element: HTMLElement) { 2 | if (element.id === id) return element 3 | if (!element.parentElement) return null 4 | 5 | return findParentBlock(id, element.parentElement) 6 | } 7 | 8 | export default findParentBlock 9 | -------------------------------------------------------------------------------- /react-block-text/src/utils/findParentWithId.ts: -------------------------------------------------------------------------------- 1 | function findParentWithId(element: HTMLElement, id: string) { 2 | if (!element) return 3 | if (element.id === id) return element 4 | if (!element.parentElement) return null 5 | 6 | return findParentWithId(element.parentElement, id) 7 | } 8 | 9 | export default findParentWithId 10 | -------------------------------------------------------------------------------- /react-block-text/src/utils/findScrollParent.ts: -------------------------------------------------------------------------------- 1 | // https://stackoverflow.com/questions/35939886/find-first-scrollable-parent 2 | const properties = ['overflow', 'overflow-x', 'overflow-y'] 3 | 4 | const isScrollable = (node: Element) => { 5 | if (!(node instanceof HTMLElement || node instanceof SVGElement)) return false 6 | 7 | const style = getComputedStyle(node) 8 | 9 | return properties.some(propertyName => { 10 | const value = style.getPropertyValue(propertyName) 11 | 12 | return value === 'auto' || value === 'scroll' 13 | }) 14 | } 15 | 16 | export const findScrollParent = (node: Element): HTMLElement => { 17 | let currentParent = node.parentElement 18 | 19 | while (currentParent) { 20 | if (isScrollable(currentParent)) return currentParent 21 | 22 | currentParent = currentParent.parentElement 23 | } 24 | 25 | return (document.scrollingElement as HTMLElement) || document.documentElement 26 | } 27 | 28 | export default findScrollParent 29 | -------------------------------------------------------------------------------- /react-block-text/src/utils/findSelectionRectIds.ts: -------------------------------------------------------------------------------- 1 | import type { SelectionRectData } from '../types' 2 | 3 | // Lookup the ids under the given selectionRect 4 | function findSelectionRectIds(selectionRefs: Record, selectionRect: SelectionRectData): string[] { 5 | if (!selectionRect.width || !selectionRect.height || !selectionRefs) return [] 6 | 7 | const ids: string[] = [] 8 | 9 | Object.entries(selectionRefs).forEach(([id, element]) => { 10 | if (!element) return 11 | 12 | const contentElement = element.parentElement 13 | 14 | if (!contentElement) return 15 | 16 | if ( 17 | selectionRect.left + selectionRect.width < element.offsetLeft + contentElement.offsetLeft 18 | || selectionRect.left > element.offsetLeft + element.offsetWidth + contentElement.offsetLeft 19 | || selectionRect.top + selectionRect.height < element.offsetTop + contentElement.offsetTop 20 | || selectionRect.top > element.offsetTop + element.offsetHeight + contentElement.offsetTop 21 | ) { 22 | return 23 | } 24 | 25 | ids.push(id) 26 | }) 27 | 28 | return ids 29 | } 30 | 31 | export default findSelectionRectIds 32 | -------------------------------------------------------------------------------- /react-block-text/src/utils/forceContentFocus.ts: -------------------------------------------------------------------------------- 1 | import type { EditorRefRegistry } from '../types' 2 | 3 | function forceContentFocus(editorRefs: EditorRefRegistry, id: string) { 4 | if (!editorRefs[id]) return 5 | if (editorRefs[id]?.editorContainer?.contains(document.activeElement)) return 6 | 7 | editorRefs[id]?.focus() 8 | } 9 | 10 | export default forceContentFocus 11 | -------------------------------------------------------------------------------- /react-block-text/src/utils/getFirstLineFocusOffset.ts: -------------------------------------------------------------------------------- 1 | import findParentBlock from './findParentBlock' 2 | import findChildWithProperty from './findChildWithProperty' 3 | 4 | function getFirstLineFocusOffset(id: string, focusOffset: number, editorElement: HTMLElement | null | undefined, injectionElement: HTMLElement) { 5 | if (!(editorElement && injectionElement)) return 0 6 | 7 | // We want to reconstitute the Block element with its content 8 | const blockElement = findParentBlock(id, editorElement) 9 | 10 | if (!blockElement) return 0 11 | 12 | // So we clone it 13 | const blockElementClone = blockElement.cloneNode(true) as HTMLElement 14 | // Find its content 15 | const contentElement = findChildWithProperty(blockElementClone, 'data-contents', 'true') 16 | 17 | if (!contentElement) return 0 18 | 19 | injectionElement.appendChild(blockElementClone) 20 | 21 | // Remove first content blocks, keep last one 22 | for (let i = 0; i < contentElement.children.length - 1; i++) { 23 | contentElement.removeChild(contentElement.children[i]) 24 | } 25 | 26 | // Then we calculate the offset from the start of the last line 27 | const text = contentElement.innerText ?? '' 28 | const words = text.split(/ |-/) 29 | const height = contentElement.offsetHeight 30 | 31 | contentElement.innerText = text 32 | 33 | for (let i = 0; i < words.length; i++) { 34 | const lastWord = words.pop() ?? '' 35 | 36 | contentElement.innerText = contentElement.innerText.slice(0, -(lastWord.length + 1)) 37 | 38 | if (contentElement.offsetHeight < height) { 39 | injectionElement.removeChild(blockElementClone) 40 | 41 | return focusOffset + words.join(' ').length + 1 42 | } 43 | } 44 | 45 | injectionElement.removeChild(blockElementClone) 46 | 47 | return focusOffset 48 | } 49 | 50 | export default getFirstLineFocusOffset 51 | -------------------------------------------------------------------------------- /react-block-text/src/utils/getLastLineFocusOffset.ts: -------------------------------------------------------------------------------- 1 | import findParentBlock from './findParentBlock' 2 | import findChildWithProperty from './findChildWithProperty' 3 | 4 | function getLastLineFocusOffset(id: string, focusOffset: number, editorElement: HTMLElement | null | undefined, injectionElement: HTMLElement) { 5 | if (!(editorElement && injectionElement)) return 0 6 | 7 | // We want to reconstitute the Block element with its content 8 | const blockElement = findParentBlock(id, editorElement) 9 | 10 | if (!blockElement) return 0 11 | 12 | // So we clone it 13 | const blockElementClone = blockElement.cloneNode(true) as HTMLElement 14 | // Find its content 15 | const contentElement = findChildWithProperty(blockElementClone, 'data-contents', 'true') 16 | 17 | if (!contentElement) return 0 18 | 19 | injectionElement.appendChild(blockElementClone) 20 | 21 | // Remove first content blocks, keep last one 22 | for (let i = 0; i < contentElement.children.length - 1; i++) { 23 | contentElement.removeChild(contentElement.children[i]) 24 | } 25 | 26 | // Then we calculate the offset from the start of the last line 27 | let offset = 0 28 | let hasAcheivedOffset = false 29 | const text = contentElement.innerText ?? '' 30 | const words = text.split(/ |-/) 31 | const height = contentElement.offsetHeight 32 | 33 | contentElement.innerText = text 34 | 35 | const { length } = words 36 | 37 | for (let i = 0; i < length; i++) { 38 | if (contentElement.offsetHeight < height) { 39 | injectionElement.removeChild(blockElementClone) 40 | 41 | return offset 42 | } 43 | 44 | const lastWord = words.pop() ?? '' 45 | 46 | contentElement.innerText = contentElement.innerText.slice(0, -(lastWord.length + 1)) 47 | 48 | if (contentElement.innerText.length < focusOffset) { 49 | if (hasAcheivedOffset) { 50 | offset += lastWord.length + 1 51 | } 52 | else { 53 | offset += focusOffset - contentElement.innerText.length - 1 54 | hasAcheivedOffset = true 55 | } 56 | } 57 | } 58 | 59 | injectionElement.removeChild(blockElementClone) 60 | 61 | return focusOffset 62 | } 63 | 64 | export default getLastLineFocusOffset 65 | -------------------------------------------------------------------------------- /react-block-text/src/utils/getQueryMenuData.ts: -------------------------------------------------------------------------------- 1 | import type { EditorRefRegistry, QueryMenuData } from '../types' 2 | 3 | import { QUERY_MENU_HEIGHT } from '../constants' 4 | 5 | // Get the query menu position based on the current selection 6 | function getQueryMenuData(editorRefs: EditorRefRegistry, id: string, rootElement: HTMLElement): QueryMenuData | null { 7 | const range = window.getSelection()?.getRangeAt(0)?.cloneRange() 8 | 9 | if (!range) return null 10 | 11 | range.collapse(true) 12 | 13 | const rects = range.getClientRects() 14 | const rootRect = rootElement.getBoundingClientRect() 15 | 16 | if (rects.length) { 17 | return { 18 | id, 19 | query: '', 20 | left: rects[0].right - rootRect.left - 6, 21 | ...getQueryMenuYPosition(rects[0], rootRect, rootElement.offsetTop, false), 22 | } 23 | } 24 | 25 | const editorRef = editorRefs[id] 26 | 27 | if (!editorRef) return null 28 | 29 | const editorRects = editorRef.editorContainer?.getClientRects() 30 | 31 | if (!editorRects?.length) return null 32 | 33 | return { 34 | id, 35 | query: '', 36 | left: editorRects[0].left - rootRect.left - 2, 37 | ...getQueryMenuYPosition(editorRects[0], rootRect, rootElement.offsetTop, true), 38 | } 39 | } 40 | 41 | function getQueryMenuYPosition(rect: DOMRectReadOnly, rootRect: DOMRect, rootOffsetTop: number, isEditorRect: boolean) { 42 | const top = (isEditorRect ? rect.top + 24 : rect.bottom + 4) - rootRect.top 43 | 44 | if (top + rootOffsetTop + QUERY_MENU_HEIGHT < window.innerHeight) return { top } 45 | 46 | const bottom = rootRect.height - rect.top + rootRect.top + 4 47 | 48 | return { bottom } 49 | } 50 | 51 | export default getQueryMenuData 52 | -------------------------------------------------------------------------------- /react-block-text/src/utils/getRelativeMousePosition.ts: -------------------------------------------------------------------------------- 1 | import type { XY } from '../types' 2 | 3 | function getRelativeMousePosition(element: HTMLElement, mousePosition: XY) { 4 | const rootRect = element.getBoundingClientRect() 5 | 6 | return { 7 | x: mousePosition.x - element.clientLeft - rootRect.left, 8 | y: mousePosition.y - element.clientTop - rootRect.top, 9 | } 10 | } 11 | 12 | export default getRelativeMousePosition 13 | -------------------------------------------------------------------------------- /react-block-text/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /react-block-text/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | './index.html', 5 | './src/**/*.{js,ts,jsx,tsx}', 6 | ], 7 | theme: { 8 | extend: {}, 9 | }, 10 | plugins: [], 11 | prefix: 'rbt-', 12 | corePlugins: { 13 | preflight: false, 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /react-block-text/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /react-block-text/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /react-block-text/vite.config.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | import path from 'node:path' 3 | 4 | import { defineConfig } from 'vite' 5 | import react from '@vitejs/plugin-react' 6 | import dts from 'vite-plugin-dts' 7 | import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js' 8 | import rollupNodePolyFill from 'rollup-plugin-polyfill-node' 9 | import analyze from 'rollup-plugin-analyzer' 10 | import { nodeModulesPolyfillPlugin } from 'esbuild-plugins-node-modules-polyfill' 11 | 12 | const rollupPlugins = [rollupNodePolyFill()] 13 | 14 | if (process.env.BUNDLE_ANALYSIS) { 15 | rollupPlugins.push(analyze({ 16 | writeTo: string => { 17 | fs.writeFileSync(path.join(__dirname, 'bundle-analysis.txt'), string) 18 | }, 19 | })) 20 | } 21 | 22 | // https://vitejs.dev/config/ 23 | export default defineConfig({ 24 | plugins: [react(), dts(), cssInjectedByJsPlugin()], 25 | optimizeDeps: { 26 | esbuildOptions: { 27 | plugins: [ 28 | nodeModulesPolyfillPlugin({ 29 | globals: { 30 | process: true, 31 | Buffer: false, 32 | }, 33 | }), 34 | ], 35 | }, 36 | }, 37 | define: { 38 | global: 'globalThis', 39 | }, 40 | build: { 41 | lib: { 42 | entry: 'src/index.ts', 43 | name: 'react-block-text', 44 | }, 45 | rollupOptions: { 46 | external: ['react', 'react-dom'], 47 | plugins: rollupPlugins, 48 | output: { 49 | exports: 'named', 50 | globals: { 51 | react: 'React', 52 | 'react-dom': 'ReactDOM', 53 | }, 54 | }, 55 | }, 56 | }, 57 | }) 58 | --------------------------------------------------------------------------------