├── .gitattributes ├── .gitignore ├── .prettierrc ├── .travis.yml ├── LICENSE.md ├── README.md ├── package.json ├── rollup.config.js ├── src ├── EditorContext.ts ├── EditorMarker.tsx ├── EditorMenu.tsx ├── EditorMenuDropdown.tsx ├── TextareaMarkdownEditor.scss ├── TextareaMarkdownEditor.tsx ├── __mocks__ │ └── svg.ts ├── __tests__ │ ├── __snapshots__ │ │ └── index.test.tsx.snap │ └── index.test.tsx ├── icon │ ├── arrow.svg │ ├── edit.svg │ ├── eye.svg │ ├── help.svg │ ├── link.svg │ ├── ordered-list.svg │ └── unordered-list.svg ├── index.tsx ├── lang.json ├── setupTests.ts └── type.ts ├── tsconfig.json ├── tslint.json └── yarn.lock /.gitattributes: -------------------------------------------------------------------------------- 1 | # Automatically normalize line endings for all text-based files 2 | # http://git-scm.com/docs/gitattributes#_end_of_line_conversion 3 | * text=auto 4 | 5 | # For the following file types, normalize line endings to LF on 6 | # checkin and prevent conversion to CRLF when they are checked out 7 | # (this is required in order to prevent newline related issues like, 8 | # for example, after the build script is run) 9 | .* text eol=lf 10 | *.html text eol=lf 11 | *.css text eol=lf 12 | *.less text eol=lf 13 | *.styl text eol=lf 14 | *.scss text eol=lf 15 | *.sass text eol=lf 16 | *.sss text eol=lf 17 | *.js text eol=lf 18 | *.jsx text eol=lf 19 | *.json text eol=lf 20 | *.md text eol=lf 21 | *.mjs text eol=lf 22 | *.sh text eol=lf 23 | *.svg text eol=lf 24 | *.txt text eol=lf 25 | *.xml text eol=lf 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # Dependencies 4 | node_modules/ 5 | 6 | # Compiled output 7 | build 8 | 9 | # Runtime data 10 | database.sqlite 11 | 12 | # Test coverage 13 | coverage 14 | 15 | # Logs 16 | npm-debug.log* 17 | yarn-debug.log* 18 | yarn-error.log* 19 | 20 | # Editors and IDEs 21 | .idea 22 | .vscode/* 23 | !.vscode/settings.json 24 | !.vscode/tasks.json 25 | !.vscode/launch.json 26 | !.vscode/extensions.json 27 | 28 | # Misc 29 | .DS_Store 30 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "trailingComma": "all", 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '10' 4 | env: 5 | - CXX=g++-4.8 6 | addons: 7 | apt: 8 | sources: 9 | - ubuntu-toolchain-r-test 10 | packages: 11 | - g++-4.8 12 | script: 13 | - yarn lint 14 | - yarn test-coveralls 15 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # License information 2 | 3 | ## Contribution License Agreement 4 | 5 | If you contribute code to this project, you are implicitly allowing your code 6 | to be distributed under the MIT license. You are also implicitly verifying that 7 | all code is your original work. `` 8 | 9 | ## Marked 10 | 11 | Copyright (c) 2011-2019, Abel Chee (Yipeng Qi) (https://github.com/abelchee/) 12 | 13 | Permission is hereby granted, free of charge, to any person obtaining a copy 14 | of this software and associated documentation files (the "Software"), to deal 15 | in the Software without restriction, including without limitation the rights 16 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 17 | copies of the Software, and to permit persons to whom the Software is 18 | furnished to do so, subject to the following conditions: 19 | 20 | The above copyright notice and this permission notice shall be included in 21 | all copies or substantial portions of the Software. 22 | 23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 28 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 29 | THE SOFTWARE. 30 | 31 | ## Markdown 32 | 33 | Copyright © 2004, John Gruber 34 | http://daringfireball.net/ 35 | All rights reserved. 36 | 37 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 38 | 39 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 40 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 41 | * Neither the name “Markdown” nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 42 | 43 | This software is provided by the copyright holders and contributors “as is” and any express or implied warranties, including, but not limited to, the implied warranties of merchantability and fitness for a particular purpose are disclaimed. In no event shall the copyright owner or contributors be liable for any direct, indirect, incidental, special, exemplary, or consequential damages (including, but not limited to, procurement of substitute goods or services; loss of use, data, or profits; or business interruption) however caused and on any theory of liability, whether in contract, strict liability, or tort (including negligence or otherwise) arising in any way out of the use of this software, even if advised of the possibility of such damage. 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-textarea-markdown-editor 2 | ![Travis (.org) branch](https://img.shields.io/travis/abelchee/react-textarea-markdown-editor/master) 3 | [![GitHub issues](https://img.shields.io/github/issues/abelchee/react-textarea-markdown-editor)](https://github.com/abelchee/react-textarea-markdown-editor/issues) 4 | [![GitHub forks](https://img.shields.io/github/forks/abelchee/react-textarea-markdown-editor)](https://github.com/abelchee/react-textarea-markdown-editor/network) 5 | [![GitHub stars](https://img.shields.io/github/stars/abelchee/react-textarea-markdown-editor)](https://github.com/abelchee/react-textarea-markdown-editor/stargazers) 6 | ![NPM](https://img.shields.io/npm/l/react-textarea-markdown-editor) 7 | ![Coveralls github](https://img.shields.io/coveralls/github/abelchee/react-textarea-markdown-editor) 8 | ![npm](https://img.shields.io/npm/dw/react-textarea-markdown-editor) 9 | 10 | A highly **customizable**, **light weight** *React* markdown editor which is 11 | * Based on pure textarea 12 | * Not bundled with any markdown parser. Free free to use [markdown-it](https://www.npmjs.com/package/markdown-it), [marked](https://www.npmjs.com/package/marked) or other markdown parsers. 13 | * Support dropping and pasting image by customization (Please check the example) 14 | * Customizable menu bar 15 | 16 | ## Table of contents 17 | * [Example](#example) 18 | * [Installation](#installation) 19 | * [Usage](#usage) 20 | * [Reference](#reference) 21 | * [TODO](#todo) 22 | 23 | 24 | ## Example 25 | https://abelchee.github.io/react-textarea-markdown-editor/ 26 | 27 | Git repo for example: https://github.com/abelchee/react-textarea-markdown-editor/tree/pages 28 | 29 | ## installation 30 | ```bash 31 | yarn add react-textarea-markdown-editor 32 | ``` 33 | or 34 | ```bash 35 | npm install react-textarea-markdown-editor 36 | ``` 37 | 38 | ## Usage 39 | ```jsx harmony 40 | import React from 'react'; 41 | import md from 'mardown-it'; 42 | import 'react-textarea-markdown-editor/build/TextareaMarkdownEditor.css'; 43 | import TextareaMarkdownEditor from 'react-textarea-markdown-editor'; 44 | 45 | function App(){ 46 | return 47 | } 48 | 49 | ``` 50 | 51 | You can also import scss file 52 | ```typescript 53 | import 'react-textarea-markdown-editor/build/TextareaMarkdownEditor.scss'; 54 | ``` 55 | 56 | ## Reference 57 | ### Properties 58 | ```typescript 59 | // Component menu item which is used for customization 60 | export interface ICmp { 61 | key: string; 62 | type: 'component'; 63 | title?: string; 64 | name: string | React.ReactElement; 65 | } 66 | 67 | // Inline marker 68 | export interface IMarker { 69 | key: string; 70 | type: 'marker'; 71 | prefix: string; // For bold it is ** 72 | suffix: string; // For bold it is ** 73 | multipleLine?: boolean; // If true, it will add extra \n before and after the prefix and suffix 74 | name: string | React.ReactElement; // Menu item text 75 | defaultText?: string; 76 | title?: string; 77 | } 78 | 79 | // Menu item to add a string template 80 | export interface ITemplateMarker { 81 | key: string; 82 | type: 'template'; 83 | template: string; 84 | multipleLine?: boolean; // If true, it will add extra \n before and after the template 85 | name: string | React.ReactElement; 86 | title?: string; 87 | } 88 | 89 | // Menu item to mark the whole line such as ordered and unordered list 90 | export interface ILineMarker { 91 | key: string; 92 | type: 'line-marker'; 93 | marker: string; 94 | name: string | React.ReactElement; 95 | title?: string; 96 | } 97 | 98 | // Dropdown 99 | export interface IDropdown { 100 | key: string; 101 | type: 'dropdown'; 102 | markers: Array; 103 | } 104 | 105 | export interface IMarkerGroup { 106 | key: string; 107 | markers: Array; 108 | } 109 | export interface ITextareaMarkdownEditor { 110 | id?: string; // Id of the container 111 | textareaId?: string; // id of the textarea 112 | className?: string; // className of the container 113 | placeholder?: string; // Placeholder of the textarea 114 | style?: object; // style of the container 115 | textareaStyle?: object; // style of the textarea 116 | rows?: number; // how many roles 117 | defaultValue?: string; 118 | value?: string; 119 | autoFocus?: boolean; // auto focus 120 | readOnly?: boolean; 121 | onChange?: (value: string) => {}; 122 | onKeyDown?: (event: React.KeyboardEvent) => {}; 123 | onKeyPress?: (event: React.KeyboardEvent) => {}; 124 | doParse: (text: string) => string; // Pass in the markdown parser 125 | language?: string; // en for English or zh for Simplified Chinese 126 | markers?: IMarkerGroup[]; // Mainly for menu customization, please check the example below 127 | onCopy?: (event: React.ClipboardEvent) => void; 128 | onCopyCapture?: (event: React.ClipboardEvent) => void; 129 | onPaste?: (event: React.ClipboardEvent) => void; 130 | onPasteCapture?: (event: React.ClipboardEvent) => void; 131 | } 132 | ``` 133 | 134 | `markers` example 135 | 136 | ```jsx harmony 137 | import React, { useRef } from 'react'; 138 | import { Icon } from 'semantic-ui-react'; 139 | import md from 'mardown-it'; 140 | import 'react-textarea-markdown-editor/build/TextareaMarkdownEditor.css'; 141 | import TextareaMarkdownEditor from 'react-textarea-markdown-editor'; 142 | import languages from './lang.json'; 143 | 144 | function App(props){ 145 | const editorRef = useRef(null); 146 | const {language} = props; 147 | const markers = [ 148 | { 149 | key: 'header', 150 | markers: [ 151 | { 152 | key: 'header', 153 | markers: [ 154 | { 155 | key: 'h1', 156 | marker: '# ', 157 | name: H1, 158 | title: languages[language].header1, 159 | type: 'line-marker', 160 | }, 161 | { 162 | key: 'h2', 163 | marker: '## ', 164 | name: H2, 165 | title: languages[language].header2, 166 | type: 'line-marker', 167 | }, 168 | { 169 | key: 'h3', 170 | marker: '### ', 171 | name: H3, 172 | title: languages[language].header3, 173 | type: 'line-marker', 174 | }, 175 | { 176 | key: 'h4', 177 | marker: '#### ', 178 | name: H4, 179 | title: languages[language].header4, 180 | type: 'line-marker', 181 | }, 182 | ], 183 | type: 'dropdown', 184 | }, 185 | ], 186 | }, 187 | { 188 | key: 'text', 189 | markers: [ 190 | { 191 | key: 'text', 192 | markers: [ 193 | { 194 | defaultText: 'bold', 195 | key: 'bold', 196 | name: {languages[language].bold}, 197 | prefix: '**', 198 | suffix: '**', 199 | title: languages[language].bold, 200 | type: 'marker', 201 | }, 202 | { 203 | defaultText: 'italic', 204 | key: 'italic', 205 | name: {languages[language].italic}, 206 | prefix: '*', 207 | suffix: '*', 208 | title: languages[language].italic, 209 | type: 'marker', 210 | }, 211 | { 212 | defaultText: 'strikethrough', 213 | key: 'strikethrough', 214 | name: {languages[language].strikethrough}, 215 | prefix: '~~', 216 | suffix: '~~', 217 | title: languages[language].strikethrough, 218 | type: 'marker', 219 | }, 220 | { 221 | key: 'blockquote', 222 | marker: '> ', 223 | name: languages[language].blockquote, 224 | title: languages[language].blockquote, 225 | type: 'line-marker', 226 | }, 227 | { 228 | defaultText: 'inline code', 229 | key: 'inline-code', 230 | name: languages[language].inlineCode, 231 | prefix: '`', 232 | suffix: '`', 233 | title: languages[language].inlineCode, 234 | type: 'marker', 235 | }, 236 | { 237 | defaultText: 'code', 238 | key: 'code', 239 | multipleLine: true, 240 | name: languages[language].code, 241 | prefix: '```', 242 | suffix: '```', 243 | title: languages[language].code, 244 | type: 'marker', 245 | }, 246 | { 247 | key: 'hr', 248 | multipleLine: true, 249 | name:
, 250 | template: '---', 251 | title: languages[language].hr, 252 | type: 'template', 253 | }, 254 | ], 255 | type: 'dropdown', 256 | }, 257 | ], 258 | }, 259 | { 260 | key: 'list', 261 | markers: [ 262 | { 263 | key: 'unordered-list', 264 | marker: '* ', 265 | name: , 266 | title: languages[language].unorderedList, 267 | type: 'line-marker', 268 | }, 269 | { 270 | key: 'ordered-list', 271 | marker: '1. ', 272 | name: , 273 | title: languages[language].orderedList, 274 | type: 'line-marker', 275 | }, 276 | { 277 | key: 'table', 278 | multipleLine: true, 279 | name: , 280 | template: `| Tables | Are | Cool | 281 | | ------------- |:-------------:| -----:| 282 | | col 3 is | right-aligned | $1600 | 283 | | col 2 is | centered | $12 | 284 | | zebra stripes | are neat | $1 |`, 285 | title: languages[language].table, 286 | type: 'template', 287 | }, 288 | ], 289 | }, 290 | { 291 | key: 'additional', 292 | markers: [ 293 | { 294 | defaultText: 'text', 295 | key: 'link', 296 | name: , 297 | prefix: '[', 298 | suffix: '](url)', 299 | title: languages[language].link, 300 | type: 'marker', 301 | }, 302 | { 303 | defaultText: 'YMmdQw17TU4', 304 | key: 'youtube', 305 | name: , 306 | prefix: '@[youtube](', 307 | suffix: ')', 308 | title: languages[language].youtube, 309 | type: 'marker', 310 | }, 311 | ], 312 | }, 313 | ]; 314 | return 315 | } 316 | 317 | ``` 318 | 319 | ### Component Ref Methods 320 | ```jsx harmony 321 | import React, { useRef } from 'react'; 322 | import md from 'mardown-it'; 323 | import 'react-textarea-markdown-editor/build/TextareaMarkdownEditor.css'; 324 | import TextareaMarkdownEditor from 'react-textarea-markdown-editor'; 325 | 326 | function App(){ 327 | const editorRef = useRef(null); 328 | return 329 | } 330 | 331 | ``` 332 | #### `public mark(prefix: string, suffix: string, defaultText: string, multipleLine: boolean) => void` 333 | For bold 334 | ```typescript 335 | editorRef.current.mark('**','**','bold') 336 | ``` 337 | #### `public markLine(marker: string) => void` 338 | For unordered-list 339 | ```typescript 340 | editorRef.current.markLine('* ') 341 | ``` 342 | #### `public append(content: string)` 343 | Append content at the end of textarea 344 | #### `public registerLineMarker(marker: string) => void` 345 | For automatically add line marker when user clicks enter key 346 | #### `public markTemplate(template: string, multipleLine: boolean) => void` 347 | For add a template string 348 | 349 | ## TODO 350 | * Example in CodeSandbox 351 | * Add badge in file 352 | * More unit test 353 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-textarea-markdown-editor", 3 | "version": "2.3.0", 4 | "description": "React Markdown Editor based on Textarea", 5 | "main": "build/index.js", 6 | "module": "build/index.es.js", 7 | "jsnext:main": "build/index.es.js", 8 | "types": "build/index.d.ts", 9 | "keywords": [ 10 | "react", 11 | "markdown", 12 | "editor" 13 | ], 14 | "scripts": { 15 | "clean": "rimraf build", 16 | "prepare": "yarn build", 17 | "prepublishOnly": "yarn test && yarn lint", 18 | "preversion": "yarn lint", 19 | "version": "yarn format && git add -A src", 20 | "postversion": "git push && git push --tags", 21 | "test": "jest --coverage", 22 | "test-coveralls": "jest --coverage --coverageReporters=text-lcov | coveralls", 23 | "build": "yarn clean && rollup -c && yarn sass", 24 | "sass": "node-sass src -o build && copyfiles -f src/*.scss build", 25 | "format": "prettier --write \"src/**/*.ts\" \"src/**/*.tsx\"", 26 | "lint": "tslint -p tsconfig.json" 27 | }, 28 | "repository": { 29 | "type": "git", 30 | "url": "git+ssh://git@github.com/abelchee/react-textarea-markdown-editor.git" 31 | }, 32 | "bugs": { 33 | "url": "https://github.com/abelchee/react-textarea-markdown-editor/issues" 34 | }, 35 | "files": [ 36 | "build" 37 | ], 38 | "homepage": "https://github.com/abelchee/react-textarea-markdown-editor", 39 | "author": "Abel Chee ", 40 | "license": "MIT", 41 | "private": false, 42 | "jest": { 43 | "preset": "ts-jest", 44 | "testEnvironment": "node", 45 | "setupFiles": [ 46 | "raf/polyfill" 47 | ], 48 | "setupFilesAfterEnv": [ 49 | "/src/setupTests.ts" 50 | ], 51 | "moduleNameMapper": { 52 | "\\.svg": "/src/__mocks__/svg.ts" 53 | } 54 | }, 55 | "peerDependencies": { 56 | "react": "^16.9.0", 57 | "react-dom": "^16.9.0" 58 | }, 59 | "devDependencies": { 60 | "@types/classnames": "^2.2.10", 61 | "@types/enzyme": "^3.10.6", 62 | "@types/enzyme-adapter-react-16": "^1.0.6", 63 | "@types/jest": "^26.0.14", 64 | "@types/jsdom": "^16.2.4", 65 | "@types/markdown-it": "^12.0.1", 66 | "@types/react": "^16.9.49", 67 | "@types/react-dom": "^16.9.8", 68 | "@types/react-test-renderer": "^16.9.3", 69 | "copyfiles": "^2.3.0", 70 | "coveralls": "^3.1.0", 71 | "enzyme": "^3.11.0", 72 | "enzyme-adapter-react-16": "^1.15.4", 73 | "jest": "^26.4.2", 74 | "jsdom": "^16.4.0", 75 | "node-sass": "^4.14.1", 76 | "prettier": "^2.1.2", 77 | "raf": "^3.4.1", 78 | "react": "^16.13.1", 79 | "react-dom": "^16.13.1", 80 | "react-scripts-ts": "^3.1.0", 81 | "react-test-renderer": "^16.13.1", 82 | "rimraf": "^3.0.2", 83 | "rollup": "^2.27.1", 84 | "rollup-plugin-commonjs": "^10.1.0", 85 | "rollup-plugin-json": "^4.0.0", 86 | "rollup-plugin-node-resolve": "^5.2.0", 87 | "rollup-plugin-peer-deps-external": "^2.2.3", 88 | "rollup-plugin-svg": "^2.0.0", 89 | "rollup-plugin-typescript2": "^0.30.0", 90 | "ts-jest": "^26.3.0", 91 | "tslib": "^2.1.0", 92 | "tslint": "^6.1.3", 93 | "tslint-config-prettier": "^1.18.0", 94 | "typescript": "^4.0.2" 95 | }, 96 | "dependencies": { 97 | "classnames": "^2.2.6", 98 | "react-enhanced-textarea": "3.1.2", 99 | "react-use": "^17.2.1" 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from 'rollup-plugin-typescript2'; 2 | import commonjs from 'rollup-plugin-commonjs'; 3 | import external from 'rollup-plugin-peer-deps-external'; 4 | import resolve from 'rollup-plugin-node-resolve'; 5 | import json from 'rollup-plugin-json'; 6 | import svg from 'rollup-plugin-svg'; 7 | 8 | import pkg from './package.json'; 9 | 10 | export default { 11 | input: 'src/index.tsx', 12 | output: [ 13 | { 14 | file: pkg.main, 15 | format: 'cjs', 16 | exports: 'named', 17 | sourcemap: true, 18 | }, 19 | { 20 | file: pkg.module, 21 | format: 'es', 22 | exports: 'named', 23 | sourcemap: true, 24 | }, 25 | ], 26 | plugins: [ 27 | json(), 28 | svg({ 29 | base64: true, 30 | }), 31 | external(), 32 | resolve({ 33 | preferBuiltins: false, 34 | }), 35 | typescript({ 36 | rollupCommonJSResolveHack: true, 37 | exclude: '**/__tests__/**', 38 | clean: true, 39 | }), 40 | commonjs({ 41 | include: ['node_modules/**'], 42 | namedExports: { 43 | 'node_modules/react/react.js': ['Children', 'Component', 'PropTypes', 'createElement'], 44 | 'node_modules/react-dom/index.js': ['render'], 45 | }, 46 | }), 47 | ], 48 | }; 49 | -------------------------------------------------------------------------------- /src/EditorContext.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | interface IEditorContext { 4 | mark?: (prefix: string, suffix: string, defaultText: string, multipleLine: boolean) => void; 5 | markLine?: (marker: string) => void; 6 | template?: (template: string, multipleLine: boolean) => void; 7 | registerLineMarker?: (marker: string) => void; 8 | focus?: () => void; 9 | } 10 | 11 | export default React.createContext({}); 12 | -------------------------------------------------------------------------------- /src/EditorMarker.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import * as React from 'react'; 3 | import { useContext, useEffect } from 'react'; 4 | import EditorContext from './EditorContext'; 5 | import { ICmp, ILineMarker, IMarker, ITemplateMarker } from './type'; 6 | 7 | export interface IEditorMarkerProps { 8 | config: IMarker | ILineMarker | ITemplateMarker | ICmp; 9 | className?: string; 10 | } 11 | 12 | const EditorMarker: React.FunctionComponent = (props) => { 13 | const { mark, markLine, registerLineMarker, template } = useContext(EditorContext); 14 | useEffect(() => { 15 | if (config.type === 'line-marker') { 16 | registerLineMarker!(config.marker); 17 | } 18 | }); 19 | const { config } = props; 20 | let handler; 21 | switch (config.type) { 22 | case 'marker': 23 | handler = () => mark!(config.prefix, config.suffix, config.defaultText || '', config.multipleLine || false); 24 | break; 25 | case 'line-marker': 26 | handler = () => markLine!(config.marker); 27 | break; 28 | case 'template': 29 | handler = () => template!(config.template, config.multipleLine || false); 30 | break; 31 | case 'component': 32 | handler = undefined; 33 | break; 34 | } 35 | return ( 36 | 37 | {config.name} 38 | 39 | ); 40 | }; 41 | 42 | export default EditorMarker; 43 | -------------------------------------------------------------------------------- /src/EditorMenu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import EditorMarker from './EditorMarker'; 3 | import EditorMenuDropdown from './EditorMenuDropdown'; 4 | // @ts-ignore 5 | import editIcon from './icon/edit.svg'; 6 | // @ts-ignore 7 | import previewIcon from './icon/eye.svg'; 8 | // @ts-ignore 9 | import helpIcon from './icon/help.svg'; 10 | // @ts-ignore 11 | import linkIcon from './icon/link.svg'; 12 | // @ts-ignore 13 | import orderedListIcon from './icon/ordered-list.svg'; 14 | // @ts-ignore 15 | import unorderedListIcon from './icon/unordered-list.svg'; 16 | import languages from './lang.json'; 17 | import { IMarkerGroup } from './type'; 18 | 19 | export interface IEditorMenuProps { 20 | isEditing?: boolean; 21 | toggleEdit?: () => void; 22 | language: string; 23 | readOnly: boolean; 24 | markers?: IMarkerGroup[]; 25 | } 26 | 27 | const EditorMenu: React.FunctionComponent = (props) => { 28 | const { toggleEdit, isEditing, language, readOnly } = props; 29 | let { markers } = props; 30 | if (!markers) { 31 | markers = [ 32 | { 33 | key: 'header', 34 | markers: [ 35 | { 36 | key: 'header', 37 | markers: [ 38 | { 39 | key: 'h1', 40 | marker: '# ', 41 | name: H1, 42 | title: languages[language].header1, 43 | type: 'line-marker', 44 | }, 45 | { 46 | key: 'h2', 47 | marker: '## ', 48 | name: H2, 49 | title: languages[language].header2, 50 | type: 'line-marker', 51 | }, 52 | { 53 | key: 'h3', 54 | marker: '### ', 55 | name: H3, 56 | title: languages[language].header3, 57 | type: 'line-marker', 58 | }, 59 | ], 60 | type: 'dropdown', 61 | }, 62 | ], 63 | }, 64 | { 65 | key: 'text', 66 | markers: [ 67 | { 68 | key: 'text', 69 | markers: [ 70 | { 71 | defaultText: 'bold', 72 | key: 'bold', 73 | name: {languages[language].bold}, 74 | prefix: '**', 75 | suffix: '**', 76 | title: languages[language].bold, 77 | type: 'marker', 78 | }, 79 | { 80 | defaultText: 'italic', 81 | key: 'italic', 82 | name: {languages[language].italic}, 83 | prefix: '*', 84 | suffix: '*', 85 | title: languages[language].italic, 86 | type: 'marker', 87 | }, 88 | { 89 | defaultText: 'strikethrough', 90 | key: 'strikethrough', 91 | name: {languages[language].strikethrough}, 92 | prefix: '~~', 93 | suffix: '~~', 94 | title: languages[language].strikethrough, 95 | type: 'marker', 96 | }, 97 | { 98 | key: 'blockquote', 99 | marker: '> ', 100 | name: languages[language].blockquote, 101 | title: languages[language].blockquote, 102 | type: 'line-marker', 103 | }, 104 | { 105 | defaultText: 'inline code', 106 | key: 'inline-code', 107 | name: languages[language].inlineCode, 108 | prefix: '`', 109 | suffix: '`', 110 | title: languages[language].inlineCode, 111 | type: 'marker', 112 | }, 113 | { 114 | defaultText: 'code', 115 | key: 'code', 116 | multipleLine: true, 117 | name: languages[language].code, 118 | prefix: '```', 119 | suffix: '```', 120 | title: languages[language].code, 121 | type: 'marker', 122 | }, 123 | ], 124 | type: 'dropdown', 125 | }, 126 | ], 127 | }, 128 | { 129 | key: 'list', 130 | markers: [ 131 | { 132 | key: 'unordered-list', 133 | marker: '* ', 134 | name: , 135 | title: languages[language].unorderedList, 136 | type: 'line-marker', 137 | }, 138 | { 139 | key: 'ordered-list', 140 | marker: '1. ', 141 | name: , 142 | title: languages[language].orderedList, 143 | type: 'line-marker', 144 | }, 145 | ], 146 | }, 147 | { 148 | key: 'additional', 149 | markers: [ 150 | { 151 | defaultText: 'text', 152 | key: 'link', 153 | name: , 154 | prefix: '[', 155 | suffix: '](url)', 156 | title: languages[language].link, 157 | type: 'marker', 158 | }, 159 | ], 160 | }, 161 | ]; 162 | } 163 | return ( 164 |
165 | {isEditing && 166 | markers.map((group) => ( 167 |
    168 | {group.markers.map((marker) => { 169 | switch (marker.type) { 170 | case 'dropdown': 171 | return ; 172 | default: 173 | return ( 174 |
  • 175 | 176 |
  • 177 | ); 178 | } 179 | })} 180 |
181 | ))} 182 |
    183 |
  • 184 | 185 | 186 | 187 |
  • 188 | {!readOnly && ( 189 |
  • toggleEdit!()} 192 | title={isEditing ? languages[language].preview : languages[language].edit} 193 | > 194 | 195 | 196 | 197 |
  • 198 | )} 199 |
200 |
201 | ); 202 | }; 203 | 204 | export default EditorMenu; 205 | -------------------------------------------------------------------------------- /src/EditorMenuDropdown.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import * as React from 'react'; 3 | import { useContext, useRef, useState } from 'react'; 4 | import useClickAway from 'react-use/lib/useClickAway'; 5 | import EditorContext from './EditorContext'; 6 | import EditorMarker from './EditorMarker'; 7 | // @ts-ignore 8 | import arrowIcon from './icon/arrow.svg'; 9 | import { IDropdown } from './type'; 10 | 11 | export interface IEditorMenuDropdownProps { 12 | className?: string | undefined; 13 | config: IDropdown; 14 | } 15 | 16 | const EditorMenuDropdown: React.FunctionComponent = (props) => { 17 | const { config } = props; 18 | const [show, setShow] = useState(false); 19 | const [index, setIndex] = useState(0); 20 | const { focus } = useContext(EditorContext); 21 | const ref = useRef(null); 22 | useClickAway(ref, () => { 23 | setShow(false); 24 | }); 25 | return ( 26 |
  • 31 | 32 | { 35 | setShow(!show); 36 | if (!show) { 37 | focus!(); 38 | } 39 | }} 40 | > 41 | 42 | 43 |
    48 |
      49 | {config.markers.map((marker, i) => ( 50 |
    • { 54 | setIndex(i); 55 | setShow(false); 56 | }} 57 | title={marker.title} 58 | > 59 | 60 |
    • 61 | ))} 62 |
    63 |
    64 |
  • 65 | ); 66 | }; 67 | 68 | export default EditorMenuDropdown; 69 | -------------------------------------------------------------------------------- /src/TextareaMarkdownEditor.scss: -------------------------------------------------------------------------------- 1 | $tme-arrow-size: 5px; 2 | 3 | .tme-container { 4 | box-sizing: border-box; 5 | * { 6 | box-sizing: inherit; 7 | } 8 | 9 | a { 10 | text-decoration: none; 11 | } 12 | 13 | .tme-viewer, 14 | textarea.tme-textarea { 15 | border: 1px solid #d4d4d5; 16 | border-radius: 2px 2px; 17 | border-top: none; 18 | padding: 5px; 19 | } 20 | 21 | textarea.tme-textarea { 22 | overflow-y: auto; 23 | width: 100%; 24 | resize: vertical; 25 | border-top: none; 26 | border-top-left-radius: 0; 27 | border-top-right-radius: 0; 28 | height: auto; 29 | } 30 | .tme-viewer { 31 | min-height: 20px; 32 | } 33 | 34 | .tme-menu { 35 | background-color: #f5f5f5; 36 | padding: 5px 0; 37 | border: 1px solid #d4d4d5; 38 | border-radius: 2px 2px 0 0; 39 | width: 100%; 40 | display: flex; 41 | flex-direction: row; 42 | align-items: stretch; 43 | flex-wrap: wrap; 44 | 45 | ul.tme-menu-group { 46 | border-radius: 2px; 47 | background-color: #fff; 48 | list-style-type: none; 49 | margin-block-start: 0; 50 | margin-block-end: 0; 51 | padding-inline-start: 0; 52 | display: flex; 53 | flex-direction: row; 54 | align-items: stretch; 55 | 56 | & > li.tme-menu-item { 57 | position: relative; 58 | z-index: 1; 59 | &:not(:first-child) { 60 | margin-left: -1px; 61 | } 62 | &:first-child { 63 | border-radius: 2px 0 0 2px; 64 | } 65 | &:last-child { 66 | border-radius: 0 2px 2px 0; 67 | } 68 | border: 1px solid #ccc; 69 | user-select: none; 70 | text-align: center; 71 | display: flex; 72 | flex-direction: row; 73 | align-items: stretch; 74 | 75 | &:hover { 76 | border: 1px solid #959595; 77 | background-color: #f9f9f9; 78 | cursor: pointer; 79 | z-index: 100; 80 | } 81 | 82 | &:active { 83 | background-image: none; 84 | background-color: #f2f2f2; 85 | box-shadow: inset 0 3px 6px rgba(0, 0, 0, 0.1); 86 | text-shadow: none; 87 | text-decoration: none; 88 | } 89 | 90 | &.tme-link { 91 | & > a { 92 | width: 100%; 93 | min-width: 30px; 94 | display: flex; 95 | align-items: center; 96 | justify-content: center; 97 | } 98 | } 99 | 100 | .tme-menu-item-inner { 101 | min-width: 30px; 102 | min-height: 30px; 103 | padding: 3px; 104 | display: flex; 105 | align-items: center; 106 | justify-content: center; 107 | } 108 | 109 | .tme-dropdown-trigger { 110 | align-items: stretch; 111 | } 112 | 113 | &.tme-dropdown { 114 | .tme-dropdown-arrow { 115 | width: 11px; 116 | height: 100%; 117 | display: flex; 118 | align-items: center; 119 | justify-content: center; 120 | border-left: 1px solid #ccc; 121 | img { 122 | width: 100%; 123 | } 124 | &:hover { 125 | border-left: 1px solid #959595; 126 | background-color: #f1f1f1; 127 | cursor: pointer; 128 | } 129 | 130 | &:active { 131 | background-image: none; 132 | background-color: #f2f2f2; 133 | box-shadow: inset 0 3px 6px rgba(0, 0, 0, 0.1); 134 | text-shadow: none; 135 | text-decoration: none; 136 | } 137 | } 138 | } 139 | .tme-dropdown-content { 140 | position: absolute; 141 | border: 1px solid #d4d4d5; 142 | display: none; 143 | top: 29px; 144 | left: -1px; 145 | min-width: 50px; 146 | background-color: #fff; 147 | z-index: 2; 148 | &.show { 149 | display: block; 150 | } 151 | } 152 | ul { 153 | list-style-type: none; 154 | margin-block-start: 0; 155 | margin-block-end: 0; 156 | padding-inline-start: 0; 157 | text-align: left; 158 | 159 | > li { 160 | display: inline-block; 161 | width: 100%; 162 | white-space: nowrap; 163 | border-bottom: 1px solid #d4d4d5; 164 | &:hover { 165 | background-color: #f9f9f9; 166 | cursor: pointer; 167 | } 168 | &.tme-dropdown-item { 169 | padding: 0; 170 | & > .tme-menu-item-inner { 171 | justify-content: flex-start; 172 | padding: 5px; 173 | } 174 | } 175 | } 176 | } 177 | } 178 | 179 | &.left { 180 | margin-left: 5px; 181 | } 182 | 183 | &.right { 184 | margin-left: auto; 185 | margin-right: 5px; 186 | } 187 | } 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/TextareaMarkdownEditor.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import * as React from 'react'; 3 | import { RefObject } from 'react'; 4 | import EnhancedTextarea from 'react-enhanced-textarea'; 5 | import EditContext from './EditorContext'; 6 | import EditorMenu from './EditorMenu'; 7 | import { IMarkerGroup } from './type'; 8 | 9 | export interface ITextareaMarkdownEditor { 10 | id?: string; 11 | textareaId?: string; 12 | className?: string; 13 | viewerClassName?: string; 14 | viewerStyle?: object; 15 | placeholder?: string; 16 | style?: object; 17 | textareaStyle?: object; 18 | rows?: number; 19 | maxLength?: number; 20 | defaultValue?: string; 21 | value?: string; 22 | autoFocus?: boolean; 23 | readOnly?: boolean; 24 | onChange?: (value: string) => {}; 25 | onKeyDown?: (event: React.KeyboardEvent) => {}; 26 | onKeyPress?: (event: React.KeyboardEvent) => {}; 27 | doParse: (text: string) => string; 28 | language?: string; 29 | markers?: IMarkerGroup[]; 30 | onCopy?: (event: React.ClipboardEvent) => void; 31 | onCopyCapture?: (event: React.ClipboardEvent) => void; 32 | onPaste?: (event: React.ClipboardEvent) => void; 33 | onPasteCapture?: (event: React.ClipboardEvent) => void; 34 | } 35 | 36 | interface ITextareaMarkdownEditorState { 37 | edit: boolean; 38 | lineMarkers: string[]; 39 | value?: string; 40 | } 41 | 42 | class TextareaMarkdownEditor extends React.Component { 43 | public static defaultProps = { 44 | language: 'en', 45 | readOnly: false, 46 | rows: 5, 47 | }; 48 | 49 | private textareaRef: RefObject; 50 | 51 | constructor(props: ITextareaMarkdownEditor) { 52 | super(props); 53 | this.textareaRef = React.createRef(); 54 | this.state = { 55 | edit: !props.readOnly, 56 | lineMarkers: [], 57 | value: props.defaultValue, 58 | }; 59 | this.toggleEdit = this.toggleEdit.bind(this); 60 | this.onChange = this.onChange.bind(this); 61 | this.focus = this.focus.bind(this); 62 | this.mark = this.mark.bind(this); 63 | this.markLine = this.markLine.bind(this); 64 | this.registerLineMarker = this.registerLineMarker.bind(this); 65 | this.markTemplate = this.markTemplate.bind(this); 66 | } 67 | 68 | public focus() { 69 | this.textareaRef.current!.focus(); 70 | } 71 | 72 | public append(content: string) { 73 | this.textareaRef.current!.append(content); 74 | } 75 | 76 | public mark(prefix: string, suffix: string, defaultText: string, multipleLine?: boolean) { 77 | if (multipleLine) { 78 | this.textareaRef.current!.toggleMultipleLineMarker({ prefix, suffix, defaultText }); 79 | } else { 80 | this.textareaRef.current!.toggleMarker({ prefix, suffix, defaultText }); 81 | } 82 | } 83 | public markLine(marker: string) { 84 | this.textareaRef.current!.toggleLineMarker(marker); 85 | } 86 | public registerLineMarker(marker: string) { 87 | const index = this.state.lineMarkers.indexOf(marker); 88 | if (index < 0) { 89 | this.setState({ ...this.state, lineMarkers: [...this.state.lineMarkers, marker] }); 90 | } 91 | } 92 | public markTemplate(template: string, multipleLine?: boolean) { 93 | if (multipleLine) { 94 | this.textareaRef.current!.toggleMultipleLineTemplate(template); 95 | } else { 96 | this.textareaRef.current!.toggleTemplate(template); 97 | } 98 | } 99 | 100 | public render() { 101 | const { readOnly = false } = this.props; 102 | return ( 103 |
    104 | 113 | 120 | {this.state.edit ? ( 121 | 141 | ) : ( 142 |
    153 | )} 154 | 155 |
    156 | ); 157 | } 158 | 159 | private toggleEdit() { 160 | this.setState({ ...this.state, edit: !this.state.edit }); 161 | } 162 | 163 | private onChange(value: string) { 164 | this.setState({ ...this.state, value }); 165 | if (this.props.onChange) { 166 | this.props.onChange(value); 167 | } 168 | } 169 | } 170 | 171 | export default TextareaMarkdownEditor; 172 | -------------------------------------------------------------------------------- /src/__mocks__/svg.ts: -------------------------------------------------------------------------------- 1 | const content = 'test'; 2 | export const ReactComponent = content; 3 | export default content; 4 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`TextareaMarkdownEditor should have Chinese words displayed 1`] = ` 4 |
    13 |
    16 |
      19 |
    • 23 | 27 | 28 | H1 29 | 30 | 31 | 35 | 39 | 40 |
      43 |
        44 |
      • 49 | 53 | 54 | H1 55 | 56 | 57 |
      • 58 |
      • 63 | 67 | 68 | H2 69 | 70 | 71 |
      • 72 |
      • 77 | 81 | 82 | H3 83 | 84 | 85 |
      • 86 |
      87 |
      88 |
    • 89 |
    90 |
      93 |
    • 97 | 101 | 102 | 加粗 103 | 104 | 105 | 109 | 113 | 114 |
      117 |
        118 |
      • 123 | 127 | 128 | 加粗 129 | 130 | 131 |
      • 132 |
      • 137 | 141 | 142 | 斜体 143 | 144 | 145 |
      • 146 |
      • 151 | 155 | 156 | 划线 157 | 158 | 159 |
      • 160 |
      • 165 | 169 | 引用 170 | 171 |
      • 172 |
      • 177 | 181 | 内置代码 182 | 183 |
      • 184 |
      • 189 | 193 | 代码 194 | 195 |
      • 196 |
      197 |
      198 |
    • 199 |
    200 |
      203 |
    • 207 | 211 | 215 | 216 |
    • 217 |
    • 221 | 225 | 229 | 230 |
    • 231 |
    232 |
      235 |
    • 239 | 243 | 247 | 248 |
    • 249 |
    250 |
      253 |
    • 256 | 260 | 264 | 265 |
    • 266 |
    • 271 | 274 | 278 | 279 |
    • 280 |
    281 |
    282 |