├── docs ├── faq.md ├── configure.zh-CN.md ├── configure.md ├── plugin.zh-CN.md ├── plugin.md ├── api.zh-CN.md └── api.md ├── src ├── variable.less ├── plugins │ ├── divider │ │ ├── index.less │ │ └── index.tsx │ ├── header │ │ ├── HeaderList.less │ │ ├── HeaderList.tsx │ │ └── index.tsx │ ├── tabInsert │ │ ├── TabMapList.less │ │ ├── TabMapList.tsx │ │ └── index.tsx │ ├── table │ │ ├── table.less │ │ ├── index.tsx │ │ └── table.tsx │ ├── block │ │ ├── wrap.tsx │ │ ├── quote.tsx │ │ ├── code-block.tsx │ │ └── code-inline.tsx │ ├── Plugin.ts │ ├── clear.tsx │ ├── link.tsx │ ├── font │ │ ├── bold.tsx │ │ ├── underline.tsx │ │ ├── italic.tsx │ │ └── strikethrough.tsx │ ├── list │ │ ├── ordered.tsx │ │ └── unordered.tsx │ ├── fullScreen.tsx │ ├── Image │ │ ├── inputFile.tsx │ │ └── index.tsx │ ├── logger │ │ ├── logger.ts │ │ └── index.tsx │ ├── autoResize.tsx │ └── modeToggle.tsx ├── components │ ├── Icon │ │ ├── fonts │ │ │ ├── iconfont.eot │ │ │ ├── iconfont.ttf │ │ │ └── iconfont.css │ │ └── index.tsx │ ├── ToolBar │ │ ├── index.tsx │ │ └── index.less │ ├── DropList │ │ ├── index.less │ │ └── index.tsx │ └── NavigationBar │ │ ├── index.tsx │ │ └── index.less ├── share │ ├── emitter.ts │ └── var.ts ├── index.less ├── i18n │ ├── lang │ │ ├── zh-CN.ts │ │ └── en-US.ts │ └── index.ts ├── utils │ ├── mergeConfig.ts │ ├── uploadPlaceholder.ts │ ├── tool.ts │ └── decorate.ts ├── editor │ ├── defaultConfig.ts │ ├── preview.tsx │ ├── index.less │ └── index.tsx └── index.ts ├── .yarnrc ├── .eslintignore ├── .browserslistrc ├── .prettierrc ├── postcss.config.js ├── image ├── react-markdown-editor-lite-v0.6.0.PNG ├── react-markdown-editor-lite-v1.0.0.PNG └── react-markdown-editor-lite-v1.0.0-plugins.PNG ├── .editorconfig ├── test ├── mocha.opts ├── utils │ ├── mergeConfig.spec.ts │ ├── decorate.spec.ts │ └── tool.spec.ts ├── plugins │ └── logger.spec.ts ├── components.spec.tsx ├── editor.spec.tsx └── api.spec.tsx ├── tsconfig.json ├── .github ├── ISSUE_TEMPLATE │ ├── feature-request.md │ ├── question.md │ └── bug.md └── workflows │ ├── publish.yml │ └── main.yml ├── .gitignore ├── LICENSE ├── webpack.plugin.js ├── .eslintrc ├── tslint.json ├── package.json ├── demo └── basic.md ├── README_CN.md └── README.md /docs/faq.md: -------------------------------------------------------------------------------- 1 | # FAQ -------------------------------------------------------------------------------- /src/variable.less: -------------------------------------------------------------------------------- 1 | @prefix: .rc-md-editor; -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | registry "https://registry.yarnpkg.com" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | build/ 3 | .* 4 | ~* 5 | node_modules/ 6 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | defaults 2 | not ie < 11 3 | last 2 version 4 | > 1% 5 | iOS 7 6 | last 3 iOS versions -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "printWidth": 200, 5 | "trailingComma": "all" 6 | } 7 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // parser: 'sugarss', 3 | plugins: [ 4 | require('autoprefixer') 5 | ] 6 | } -------------------------------------------------------------------------------- /src/plugins/divider/index.less: -------------------------------------------------------------------------------- 1 | .rc-md-divider { 2 | display: block; 3 | width: 1px; 4 | background-color: #e0e0e0; 5 | } -------------------------------------------------------------------------------- /src/components/Icon/fonts/iconfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HarryChen0506/react-markdown-editor-lite/HEAD/src/components/Icon/fonts/iconfont.eot -------------------------------------------------------------------------------- /src/components/Icon/fonts/iconfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HarryChen0506/react-markdown-editor-lite/HEAD/src/components/Icon/fonts/iconfont.ttf -------------------------------------------------------------------------------- /image/react-markdown-editor-lite-v0.6.0.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HarryChen0506/react-markdown-editor-lite/HEAD/image/react-markdown-editor-lite-v0.6.0.PNG -------------------------------------------------------------------------------- /image/react-markdown-editor-lite-v1.0.0.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HarryChen0506/react-markdown-editor-lite/HEAD/image/react-markdown-editor-lite-v1.0.0.PNG -------------------------------------------------------------------------------- /image/react-markdown-editor-lite-v1.0.0-plugins.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HarryChen0506/react-markdown-editor-lite/HEAD/image/react-markdown-editor-lite-v1.0.0-plugins.PNG -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = false 9 | insert_final_newline = false -------------------------------------------------------------------------------- /src/plugins/header/HeaderList.less: -------------------------------------------------------------------------------- 1 | .header-list { 2 | .list-item { 3 | width: 100px; 4 | box-sizing: border-box; 5 | padding: 8px 0; 6 | &:hover { 7 | background: #f5f5f5 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /src/plugins/tabInsert/TabMapList.less: -------------------------------------------------------------------------------- 1 | .tab-map-list { 2 | .list-item { 3 | width: 120px; 4 | box-sizing: border-box; 5 | &:hover { 6 | background: #f5f5f5 7 | } 8 | &.active { 9 | font-weight: bold; 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /src/components/Icon/index.tsx: -------------------------------------------------------------------------------- 1 | // Icon 2 | import * as React from 'react'; 3 | 4 | interface IconProps { 5 | type: string; 6 | } 7 | 8 | export default function Icon(props: IconProps) { 9 | return ; 10 | } 11 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require ts-node/register 2 | --require tsconfig-paths/register 3 | --require source-map-support/register 4 | --require jsdom-global/register 5 | --require ignore-styles 6 | --reporter mochawesome 7 | --recursive 8 | --full-trace 9 | --bail 10 | test/**/*.spec.{ts,tsx} -------------------------------------------------------------------------------- /src/plugins/divider/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { PluginComponent } from '../Plugin'; 3 | 4 | export default class Divider extends PluginComponent { 5 | static pluginName = 'divider'; 6 | 7 | render() { 8 | return ; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/components/ToolBar/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | interface ToolBarProps { 4 | style?: React.CSSProperties; 5 | children: any; 6 | } 7 | 8 | export default function ToolBar(props: ToolBarProps) { 9 | return ( 10 |
11 | {props.children} 12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/components/ToolBar/index.less: -------------------------------------------------------------------------------- 1 | .tool-bar { 2 | position: absolute; 3 | z-index: 1; 4 | right: 8px; 5 | top: 8px; 6 | .button { 7 | min-width: 24px; 8 | height: 28px; 9 | margin-right: 5px; 10 | display: inline-block; 11 | cursor: pointer; 12 | font-size: 14px; 13 | line-height: 28px; 14 | text-align: center; 15 | color: #999; 16 | &:hover { 17 | color: #333; 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /src/plugins/table/table.less: -------------------------------------------------------------------------------- 1 | .table-list.wrap { 2 | // background: yellow; 3 | position: relative; 4 | margin: 0 10px; 5 | box-sizing: border-box; 6 | .list-item { 7 | position: absolute; 8 | top: 0; 9 | left: 0; 10 | display: inline-block; 11 | width: 20px; 12 | height: 20px; 13 | background-color: #e0e0e0; // grey lighten-2 14 | border-radius: 3px; 15 | &.active { 16 | background: #9e9e9e; // grey 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /src/share/emitter.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'eventemitter3'; 2 | 3 | class Emitter extends EventEmitter { 4 | EVENT_CHANGE = 'a1'; 5 | EVENT_FULL_SCREEN = 'a2'; 6 | EVENT_VIEW_CHANGE = 'a3'; 7 | EVENT_KEY_DOWN = 'a4'; 8 | EVENT_EDITOR_KEY_DOWN = 'a5'; 9 | EVENT_FOCUS = 'a5'; 10 | EVENT_BLUR = 'a6'; 11 | EVENT_SCROLL = 'a7'; 12 | EVENT_LANG_CHANGE = 'b1'; 13 | } 14 | const globalEmitter = new Emitter(); 15 | 16 | export { globalEmitter }; 17 | export default Emitter; 18 | -------------------------------------------------------------------------------- /src/index.less: -------------------------------------------------------------------------------- 1 | @import './variable.less'; 2 | @import './components/Icon/fonts/iconfont.css'; 3 | @import './editor/index.less'; 4 | 5 | @{prefix} { 6 | @import './components/DropList/index.less'; 7 | @import './components/NavigationBar/index.less'; 8 | @import './components/ToolBar/index.less'; 9 | // Plugins 10 | @import './plugins/divider/index.less'; 11 | @import './plugins/table/table.less'; 12 | @import './plugins/tabInsert/TabMapList.less'; 13 | @import './plugins/header/HeaderList.less'; 14 | } -------------------------------------------------------------------------------- /src/components/DropList/index.less: -------------------------------------------------------------------------------- 1 | .drop-wrap { 2 | display: block; 3 | &.hidden { 4 | display: none !important; 5 | } 6 | position: absolute; 7 | left: 0; 8 | top: 28px; 9 | z-index: 2; 10 | min-width: 20px; 11 | padding: 10px 0; 12 | text-align: center; 13 | background-color: #fff; 14 | border: 1px solid #f1f1f1; 15 | border-right-color: #ddd; 16 | border-bottom-color: #ddd; 17 | // .drop-item { 18 | // padding: 5px 0; 19 | // &:hover { 20 | // background: #f1f1f1 21 | // } 22 | // } 23 | } -------------------------------------------------------------------------------- /src/plugins/block/wrap.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Icon from '../../components/Icon'; 3 | import i18n from '../../i18n'; 4 | import { PluginComponent } from '../Plugin'; 5 | 6 | export default class BlockWrap extends PluginComponent { 7 | static pluginName = 'block-wrap'; 8 | 9 | render() { 10 | return ( 11 | this.editor.insertMarkdown('hr')} 15 | > 16 | 17 | 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/plugins/block/quote.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Icon from '../../components/Icon'; 3 | import i18n from '../../i18n'; 4 | import { PluginComponent } from '../Plugin'; 5 | 6 | export default class BlockQuote extends PluginComponent { 7 | static pluginName = 'block-quote'; 8 | 9 | render() { 10 | return ( 11 | this.editor.insertMarkdown('quote')} 15 | > 16 | 17 | 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/plugins/block/code-block.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Icon from '../../components/Icon'; 3 | import i18n from '../../i18n'; 4 | import { PluginComponent } from '../Plugin'; 5 | 6 | export default class BlockCodeBlock extends PluginComponent { 7 | static pluginName = 'block-code-block'; 8 | 9 | render() { 10 | return ( 11 | this.editor.insertMarkdown('code')} 15 | > 16 | 17 | 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/plugins/block/code-inline.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Icon from '../../components/Icon'; 3 | import i18n from '../../i18n'; 4 | import { PluginComponent } from '../Plugin'; 5 | 6 | export default class BlockCodeInline extends PluginComponent { 7 | static pluginName = 'block-code-inline'; 8 | 9 | render() { 10 | return ( 11 | this.editor.insertMarkdown('inlinecode')} 15 | > 16 | 17 | 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "allowSyntheticDefaultImports": true, 5 | "jsx": "react", 6 | "lib": [ 7 | "dom", 8 | "es2017" 9 | ], 10 | "rootDir": "./src", 11 | "outDir": "./lib", 12 | "module": "commonjs", 13 | "moduleResolution": "node", 14 | "noEmit": false, 15 | "preserveConstEnums": true, 16 | "removeComments": false, 17 | "skipLibCheck": true, 18 | "sourceMap": true, 19 | "strict": true, 20 | "target": "es5", 21 | "declaration": true 22 | }, 23 | "exclude": ["./lib", "./image"], 24 | "include": ["./src"] 25 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 功能建议 3 | about: Want to add or optimize some features 希望增加或优化某些功能 4 | title: "[Feature Request]" 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Feature description 功能描述 11 | For example, when type some keys, something will happen. You can also add some diagrams to make it easier for others to understand what you mean. 12 | 例如:按了某个键,会发生什么事。您也可以加上一些示意图,来方便其他人理解你的意思。 13 | 14 | ## Similar product 类似功能 15 | If there are similar functions in other projects, you can attach them here to help us understand the function. 16 | 如果其他项目中有类似功能,可以在此附上,方便我们理解该功能的作用。 17 | -------------------------------------------------------------------------------- /test/utils/mergeConfig.spec.ts: -------------------------------------------------------------------------------- 1 | import mergeConfig from '../../src/utils/mergeConfig'; 2 | import { expect } from 'chai'; 3 | 4 | describe("Test mergeConfig", function() { 5 | it("Merge objects", function() { 6 | const obj1 = { a: 1, b: 5 }; 7 | const obj2 = { b: 2, c: 3 }; 8 | const res = mergeConfig(obj1, obj2); 9 | expect(res).to.deep.equal({ a: 1, b: 2 }); 10 | }); 11 | it("Merge should ignore non-objects", function() { 12 | const obj1 = { a: 1, b: 5 }; 13 | const obj2 = 123; 14 | const obj3 = { a: 2, c: 6 }; 15 | const res = mergeConfig(obj1, obj2, obj3); 16 | expect(res).to.deep.equal({ a: 2, b: 5 }); 17 | }); 18 | }); -------------------------------------------------------------------------------- /src/components/NavigationBar/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | interface NavigationBarProps { 4 | left?: React.ReactElement[]; 5 | right?: React.ReactElement[]; 6 | visible: boolean; 7 | } 8 | 9 | export default function NavigationBar(props: NavigationBarProps) { 10 | return ( 11 |
12 |
13 |
{props.left}
14 |
15 |
16 |
{props.right}
17 |
18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 提问 3 | about: Questions in use 使用上的问题 4 | title: "[Question]" 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Please note 提问前请注意 11 | * This option is used to ask others some usage questions. If you confirm this is a bug, please submit a bug. 12 | * Please read [How To Ask Questions](http://www.catb.org/~esr/faqs/smart-questions.html) before asking questions. Inefficient questions not only waste other people's time, but also keep you from getting timely answers. 13 | * 该选项用来向其他人询问一些使用上的问题,如果你确认这是一个BUG,请提交BUG。 14 | * 提问前,请先阅读[提问的智慧](https://github.com/ryanhanwu/How-To-Ask-Questions-The-Smart-Way/blob/main/README-zh_CN.md)。低效的问题不仅会浪费其他人的时间,也会使你无法得到及时的解答。 -------------------------------------------------------------------------------- /src/i18n/lang/zh-CN.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | clearTip: '您确定要清空所有内容吗?', 3 | btnHeader: '标题', 4 | btnClear: '清空', 5 | btnBold: '加粗', 6 | btnItalic: '斜体', 7 | btnUnderline: '下划线', 8 | btnStrikethrough: '删除线', 9 | btnUnordered: '无序列表', 10 | btnOrdered: '有序列表', 11 | btnQuote: '引用', 12 | btnLineBreak: '换行', 13 | btnInlineCode: '行内代码', 14 | btnCode: '代码块', 15 | btnTable: '表格', 16 | btnImage: '图片', 17 | btnLink: '链接', 18 | btnUndo: '撤销', 19 | btnRedo: '重做', 20 | btnFullScreen: '全屏', 21 | btnExitFullScreen: '退出全屏', 22 | btnModeEditor: '仅显示编辑器', 23 | btnModePreview: '仅显示预览', 24 | btnModeAll: '显示编辑器与预览', 25 | selectTabMap: '按下 Tab 键时实际的输入', 26 | tab: '制表符', 27 | spaces: '空格', 28 | }; 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .idea/ 3 | .vscode/ 4 | ~* 5 | 6 | # Packages # 7 | ############ 8 | # it's better to unpack these files and commit the raw source 9 | # git has its own built in compression methods 10 | *.7z 11 | *.dmg 12 | *.gz 13 | *.iso 14 | *.jar 15 | *.rar 16 | *.tar 17 | *.zip 18 | # Optional npm cache directory 19 | .npm 20 | 21 | # dist 22 | esm 23 | cjs 24 | lib 25 | dist 26 | preview 27 | 28 | # Logs and databases # 29 | ###################### 30 | *.log 31 | *.sql 32 | *.sqlite 33 | 34 | # tests and coverages 35 | .nyc_output/ 36 | coverage/ 37 | coverage/* 38 | mochawesome-report/ 39 | 40 | # OS generated files # 41 | ###################### 42 | .DS_Store 43 | *.swp 44 | .DS_Store? 45 | ._* 46 | .Spotlight-V100 47 | .Trashes 48 | ehthumbs.db 49 | Thumbs.db 50 | 51 | # NPM 52 | package-lock.json 53 | -------------------------------------------------------------------------------- /src/components/DropList/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | interface DropListProps { 4 | show: boolean; 5 | onClose?: () => void; 6 | } 7 | 8 | class DropList extends React.Component { 9 | constructor(props: any) { 10 | super(props); 11 | this.handleClose = this.handleClose.bind(this); 12 | } 13 | 14 | handleClose(e: React.MouseEvent) { 15 | e.stopPropagation(); 16 | const { onClose } = this.props; 17 | if (typeof onClose === 'function') { 18 | onClose(); 19 | } 20 | } 21 | 22 | render() { 23 | return ( 24 |
25 | {this.props.children} 26 |
27 | ); 28 | } 29 | } 30 | export default DropList; 31 | -------------------------------------------------------------------------------- /src/utils/mergeConfig.ts: -------------------------------------------------------------------------------- 1 | function mergeObject(obj1: any, obj2: any) { 2 | const result: any = {}; 3 | Object.keys(obj1).forEach(k => { 4 | if (typeof obj2[k] === 'undefined') { 5 | result[k] = obj1[k]; 6 | return; 7 | } 8 | if (typeof obj2[k] === 'object') { 9 | if (Array.isArray(obj2[k])) { 10 | result[k] = [...obj2[k]]; 11 | } else { 12 | result[k] = mergeObject(obj1[k], obj2[k]); 13 | } 14 | return; 15 | } 16 | result[k] = obj2[k]; 17 | }); 18 | return result; 19 | } 20 | 21 | export default function(defaultConfig: any, ...configs: any[]) { 22 | let res = { ...defaultConfig }; 23 | configs.forEach(conf => { 24 | // only object 25 | if (typeof conf !== 'object') { 26 | return; 27 | } 28 | res = mergeObject(res, conf); 29 | }); 30 | return res; 31 | } 32 | -------------------------------------------------------------------------------- /src/editor/defaultConfig.ts: -------------------------------------------------------------------------------- 1 | import { EditorConfig } from '../share/var'; 2 | 3 | const defaultConfig: EditorConfig = { 4 | theme: 'default', 5 | view: { 6 | menu: true, 7 | md: true, 8 | html: true, 9 | }, 10 | canView: { 11 | menu: true, 12 | md: true, 13 | html: true, 14 | both: true, 15 | fullScreen: true, 16 | hideMenu: true, 17 | }, 18 | htmlClass: '', 19 | markdownClass: '', 20 | syncScrollMode: ['rightFollowLeft', 'leftFollowRight'], 21 | imageUrl: '', 22 | imageAccept: '', 23 | linkUrl: '', 24 | loggerMaxSize: 100, 25 | loggerInterval: 600, 26 | table: { 27 | maxRow: 4, 28 | maxCol: 6, 29 | }, 30 | allowPasteImage: true, 31 | onImageUpload: undefined, 32 | onCustomImageUpload: undefined, 33 | shortcuts: true, 34 | onChangeTrigger: 'both', 35 | }; 36 | 37 | export default defaultConfig; 38 | -------------------------------------------------------------------------------- /src/i18n/lang/en-US.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | clearTip: 'Are you sure you want to clear all contents?', 3 | btnHeader: 'Header', 4 | btnClear: 'Clear', 5 | btnBold: 'Bold', 6 | btnItalic: 'Italic', 7 | btnUnderline: 'Underline', 8 | btnStrikethrough: 'Strikethrough', 9 | btnUnordered: 'Unordered list', 10 | btnOrdered: 'Ordered list', 11 | btnQuote: 'Quote', 12 | btnLineBreak: 'Line break', 13 | btnInlineCode: 'Inline code', 14 | btnCode: 'Code', 15 | btnTable: 'Table', 16 | btnImage: 'Image', 17 | btnLink: 'Link', 18 | btnUndo: 'Undo', 19 | btnRedo: 'Redo', 20 | btnFullScreen: 'Full screen', 21 | btnExitFullScreen: 'Exit full screen', 22 | btnModeEditor: 'Only display editor', 23 | btnModePreview: 'Only display preview', 24 | btnModeAll: 'Display both editor and preview', 25 | selectTabMap: 'Actual input when typing a Tab key', 26 | tab: 'Tab', 27 | spaces: 'Spaces', 28 | }; 29 | -------------------------------------------------------------------------------- /src/plugins/Plugin.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type Editor from '../editor'; 3 | import { EditorConfig } from '../share/var'; 4 | 5 | export interface PluginProps { 6 | editor: Editor; 7 | editorConfig: EditorConfig; 8 | config: any; 9 | } 10 | 11 | export abstract class PluginComponent extends React.Component { 12 | static pluginName: string = ''; 13 | 14 | static align: string = 'left'; 15 | 16 | static defaultConfig = {}; 17 | 18 | protected get editor(): Editor { 19 | return this.props.editor; 20 | } 21 | 22 | protected get editorConfig(): EditorConfig { 23 | return this.props.editorConfig; 24 | } 25 | 26 | protected getConfig(key: string, defaultValue?: any) { 27 | return typeof this.props.config[key] !== 'undefined' && this.props.config[key] !== null 28 | ? this.props.config[key] 29 | : defaultValue; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /test/plugins/logger.spec.ts: -------------------------------------------------------------------------------- 1 | import Logger from '../../src/plugins/logger/logger'; 2 | import { expect } from 'chai'; 3 | 4 | describe("Test Logger", function() { 5 | describe("undo", function() { 6 | it("Return previous if top is not current", function() { 7 | const logger = new Logger(); 8 | logger.push('S'); 9 | logger.push('Sh'); 10 | expect(logger.undo('She')).to.equal('Sh'); 11 | expect(logger.undo('Sh')).to.equal('S'); 12 | }); 13 | 14 | it("Return previous if top is current", function() { 15 | const logger = new Logger(); 16 | logger.push('S'); 17 | logger.push('Sh'); 18 | logger.push('She'); 19 | expect(logger.undo('She')).to.equal('Sh'); 20 | }); 21 | 22 | it("Will return initValue", function() { 23 | const logger = new Logger(); 24 | logger.initValue = 'init'; 25 | expect(logger.undo()).to.equals('init'); 26 | }) 27 | }); 28 | }); -------------------------------------------------------------------------------- /src/plugins/clear.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Icon from '../components/Icon'; 3 | import i18n from '../i18n'; 4 | import { PluginComponent } from './Plugin'; 5 | 6 | export default class Clear extends PluginComponent { 7 | static pluginName = 'clear'; 8 | 9 | constructor(props: any) { 10 | super(props); 11 | 12 | this.handleClick = this.handleClick.bind(this); 13 | } 14 | 15 | handleClick() { 16 | if (this.editor.getMdValue() === '') { 17 | return; 18 | } 19 | if (window.confirm && typeof window.confirm === 'function') { 20 | const result = window.confirm(i18n.get('clearTip')); 21 | if (result) { 22 | this.editor.setText(''); 23 | } 24 | } 25 | } 26 | 27 | render() { 28 | return ( 29 | 30 | 31 | 32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /test/components.spec.tsx: -------------------------------------------------------------------------------- 1 | import { cleanup, fireEvent, render, screen } from '@testing-library/react'; 2 | import { expect } from 'chai'; 3 | import * as React from 'react'; 4 | import DropList from '../src/components/DropList'; 5 | import Icon from '../src/components/Icon'; 6 | 7 | describe('Test Components', function() { 8 | // render 9 | it('DropList render', function() { 10 | let isClosed = false; 11 | render( isClosed = true}>dropdown-item); 12 | 13 | const item = screen.queryByText('dropdown-item'); 14 | 15 | expect(item).not.to.be.null; 16 | 17 | // click a item 18 | if (item !== null) { 19 | fireEvent.click(item); 20 | } 21 | 22 | expect(isClosed).to.be.true; 23 | }); 24 | 25 | 26 | it('Icon render', function() { 27 | const { container } = render(); 28 | 29 | expect(container.querySelector('.rmel-iconfont')).not.to.be.null; 30 | expect(container.querySelector('.rmel-icon-test')).not.to.be.null; 31 | }); 32 | 33 | afterEach(cleanup); 34 | }); -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 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 | -------------------------------------------------------------------------------- /src/plugins/tabInsert/TabMapList.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import * as React from 'react'; 3 | import i18n from '../../i18n'; 4 | 5 | interface TabMapListProps { 6 | value: number; 7 | onSelectMapValue?: (mapValue: number) => void; 8 | } 9 | 10 | class TabMapList extends React.Component { 11 | handleSelectMapValue(mapValue: number) { 12 | const { onSelectMapValue } = this.props; 13 | if (typeof onSelectMapValue === 'function') { 14 | onSelectMapValue(mapValue); 15 | } 16 | } 17 | 18 | render() { 19 | const { value } = this.props; 20 | 21 | return ( 22 |
    23 | {[1, 2, 4, 8].map((it) => ( 24 |
  • 30 |
    31 | {it === 1 ? i18n.get('tab') : `${it} ${i18n.get('spaces')}`} 32 |
    33 |
  • 34 | ))} 35 |
36 | ); 37 | } 38 | } 39 | export default TabMapList; 40 | -------------------------------------------------------------------------------- /src/utils/uploadPlaceholder.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuid } from 'uuid'; 2 | import { UploadFunc } from '../share/var'; 3 | import getDecorated from './decorate'; 4 | import { isPromise } from './tool'; 5 | 6 | function getUploadPlaceholder(file: File, onImageUpload: UploadFunc) { 7 | const placeholder = getDecorated('', 'image', { 8 | target: `Uploading_${uuid()}`, 9 | imageUrl: '', 10 | }).text; 11 | const uploaded = new Promise((resolve: (url: string) => void) => { 12 | let isCallback = true; 13 | const handleUploaded = (url: string) => { 14 | if (isCallback) { 15 | console.warn('Deprecated: onImageUpload should return a Promise, callback will be removed in future'); 16 | } 17 | resolve( 18 | getDecorated('', 'image', { 19 | target: file.name, 20 | imageUrl: url, 21 | }).text, 22 | ); 23 | }; 24 | // 兼容回调和Promise 25 | const upload = onImageUpload(file, handleUploaded); 26 | if (isPromise(upload)) { 27 | isCallback = false; 28 | upload.then(handleUploaded); 29 | } 30 | }); 31 | return { placeholder, uploaded }; 32 | } 33 | 34 | export default getUploadPlaceholder; 35 | -------------------------------------------------------------------------------- /src/plugins/link.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Icon from '../components/Icon'; 3 | import i18n from '../i18n'; 4 | import { KeyboardEventListener } from '../share/var'; 5 | import { PluginComponent } from './Plugin'; 6 | 7 | export default class Link extends PluginComponent { 8 | static pluginName = 'link'; 9 | 10 | private handleKeyboard: KeyboardEventListener; 11 | 12 | constructor(props: any) { 13 | super(props); 14 | 15 | this.handleKeyboard = { 16 | key: 'k', 17 | keyCode: 75, 18 | aliasCommand: true, 19 | withKey: ['ctrlKey'], 20 | callback: () => this.editor.insertMarkdown('link'), 21 | }; 22 | } 23 | 24 | componentDidMount() { 25 | if (this.editorConfig.shortcuts) { 26 | this.editor.onKeyboard(this.handleKeyboard); 27 | } 28 | } 29 | 30 | componentWillUnmount() { 31 | this.editor.offKeyboard(this.handleKeyboard); 32 | } 33 | 34 | render() { 35 | return ( 36 | this.editor.insertMarkdown('link')} 40 | > 41 | 42 | 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/plugins/font/bold.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Icon from '../../components/Icon'; 3 | import i18n from '../../i18n'; 4 | import { KeyboardEventListener } from '../../share/var'; 5 | import { PluginComponent } from '../Plugin'; 6 | 7 | export default class FontBold extends PluginComponent { 8 | static pluginName = 'font-bold'; 9 | 10 | private handleKeyboard: KeyboardEventListener; 11 | 12 | constructor(props: any) { 13 | super(props); 14 | 15 | this.handleKeyboard = { 16 | key: 'b', 17 | keyCode: 66, 18 | aliasCommand: true, 19 | withKey: ['ctrlKey'], 20 | callback: () => this.editor.insertMarkdown('bold'), 21 | }; 22 | } 23 | 24 | componentDidMount() { 25 | if (this.editorConfig.shortcuts) { 26 | this.editor.onKeyboard(this.handleKeyboard); 27 | } 28 | } 29 | 30 | componentWillUnmount() { 31 | this.editor.offKeyboard(this.handleKeyboard); 32 | } 33 | 34 | render() { 35 | return ( 36 | this.editor.insertMarkdown('bold')} 40 | > 41 | 42 | 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: BUG 3 | about: Report bugs 报告 BUG 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Please note 提问前请注意 11 | * Your problem is NOT related to markdown rendering (eg some markdown syntax doesn't display correctly). For these problems you should ask help from the markdown renderer you are using. 12 | * Please submit according to this template, otherwise issue may be closed directly. 13 | * 你的问题与markdown渲染无关(例如,某些 Markdown 语法无法正确显示)。这些问题你应该询问你所使用的 Markdown 渲染器。 14 | * 请按照此模板提交,否则 issue 可能会被直接关闭。 15 | 16 | ## Your environment 17 | For example, Windows 10, Chrome 80.0. 18 | 例如:Windows 10,Chrome 80.0 19 | 20 | ## Description 21 | For example, click a button, input some text, and something went wrong. 22 | 例如:点击某个按钮,输入某些文本,然后发现问题。 23 | 24 | ## Reproduction URL 25 | Please make a minimal reproduction with [codesandbox](https://codesandbox.io/). 26 | If your bug involves a build setup, please create a project using [create-react-app](https://github.com/facebook/create-react-app) and provide the link to a GitHub repository. 27 | 请使用[codesandbox](https://codesandbox.io/)建立一个最小复现。 28 | 如果您遇到的错误涉及构建设置,请使用[create-react-app](https://github.com/facebook/create-react-app)创建一个项目,并提供GitHub仓库链接。 29 | -------------------------------------------------------------------------------- /src/plugins/font/underline.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Icon from '../../components/Icon'; 3 | import i18n from '../../i18n'; 4 | import { KeyboardEventListener } from '../../share/var'; 5 | import { PluginComponent } from '../Plugin'; 6 | 7 | export default class FontUnderline extends PluginComponent { 8 | static pluginName = 'font-underline'; 9 | 10 | private handleKeyboard: KeyboardEventListener; 11 | 12 | constructor(props: any) { 13 | super(props); 14 | 15 | this.handleKeyboard = { 16 | key: 'u', 17 | keyCode: 85, 18 | withKey: ['ctrlKey'], 19 | callback: () => this.editor.insertMarkdown('underline'), 20 | }; 21 | } 22 | 23 | componentDidMount() { 24 | if (this.editorConfig.shortcuts) { 25 | this.editor.onKeyboard(this.handleKeyboard); 26 | } 27 | } 28 | 29 | componentWillUnmount() { 30 | this.editor.offKeyboard(this.handleKeyboard); 31 | } 32 | 33 | render() { 34 | return ( 35 | this.editor.insertMarkdown('underline')} 39 | > 40 | 41 | 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/plugins/font/italic.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Icon from '../../components/Icon'; 3 | import i18n from '../../i18n'; 4 | import { KeyboardEventListener } from '../../share/var'; 5 | import { PluginComponent } from '../Plugin'; 6 | 7 | export default class FontItalic extends PluginComponent { 8 | static pluginName = 'font-italic'; 9 | 10 | private handleKeyboard: KeyboardEventListener; 11 | 12 | constructor(props: any) { 13 | super(props); 14 | 15 | this.handleKeyboard = { 16 | key: 'i', 17 | keyCode: 73, 18 | aliasCommand: true, 19 | withKey: ['ctrlKey'], 20 | callback: () => this.editor.insertMarkdown('italic'), 21 | }; 22 | } 23 | 24 | componentDidMount() { 25 | if (this.editorConfig.shortcuts) { 26 | this.editor.onKeyboard(this.handleKeyboard); 27 | } 28 | } 29 | 30 | componentWillUnmount() { 31 | this.editor.offKeyboard(this.handleKeyboard); 32 | } 33 | 34 | render() { 35 | return ( 36 | this.editor.insertMarkdown('italic')} 40 | > 41 | 42 | 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/plugins/list/ordered.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Icon from '../../components/Icon'; 3 | import i18n from '../../i18n'; 4 | import { KeyboardEventListener } from '../../share/var'; 5 | import { PluginComponent } from '../Plugin'; 6 | 7 | export default class ListOrdered extends PluginComponent { 8 | static pluginName = 'list-ordered'; 9 | 10 | private handleKeyboard: KeyboardEventListener; 11 | 12 | constructor(props: any) { 13 | super(props); 14 | 15 | this.handleKeyboard = { 16 | key: '7', 17 | keyCode: 55, 18 | withKey: ['ctrlKey', 'shiftKey'], 19 | aliasCommand: true, 20 | callback: () => this.editor.insertMarkdown('order'), 21 | }; 22 | } 23 | 24 | componentDidMount() { 25 | if (this.editorConfig.shortcuts) { 26 | this.editor.onKeyboard(this.handleKeyboard); 27 | } 28 | } 29 | 30 | componentWillUnmount() { 31 | this.editor.offKeyboard(this.handleKeyboard); 32 | } 33 | 34 | render() { 35 | return ( 36 | this.editor.insertMarkdown('order')} 40 | > 41 | 42 | 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/plugins/list/unordered.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Icon from '../../components/Icon'; 3 | import i18n from '../../i18n'; 4 | import { KeyboardEventListener } from '../../share/var'; 5 | import { PluginComponent } from '../Plugin'; 6 | 7 | export default class ListUnordered extends PluginComponent { 8 | static pluginName = 'list-unordered'; 9 | 10 | private handleKeyboard: KeyboardEventListener; 11 | 12 | constructor(props: any) { 13 | super(props); 14 | 15 | this.handleKeyboard = { 16 | key: '8', 17 | keyCode: 56, 18 | withKey: ['ctrlKey', 'shiftKey'], 19 | aliasCommand: true, 20 | callback: () => this.editor.insertMarkdown('unordered'), 21 | }; 22 | } 23 | 24 | componentDidMount() { 25 | if (this.editorConfig.shortcuts) { 26 | this.editor.onKeyboard(this.handleKeyboard); 27 | } 28 | } 29 | 30 | componentWillUnmount() { 31 | this.editor.offKeyboard(this.handleKeyboard); 32 | } 33 | 34 | render() { 35 | return ( 36 | this.editor.insertMarkdown('unordered')} 40 | > 41 | 42 | 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/plugins/font/strikethrough.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Icon from '../../components/Icon'; 3 | import i18n from '../../i18n'; 4 | import { KeyboardEventListener } from '../../share/var'; 5 | import { PluginComponent } from '../Plugin'; 6 | 7 | export default class FontStrikethrough extends PluginComponent { 8 | static pluginName = 'font-strikethrough'; 9 | 10 | private handleKeyboard: KeyboardEventListener; 11 | 12 | constructor(props: any) { 13 | super(props); 14 | 15 | this.handleKeyboard = { 16 | key: 'd', 17 | keyCode: 68, 18 | aliasCommand: true, 19 | withKey: ['ctrlKey'], 20 | callback: () => this.editor.insertMarkdown('strikethrough'), 21 | }; 22 | } 23 | 24 | componentDidMount() { 25 | if (this.editorConfig.shortcuts) { 26 | this.editor.onKeyboard(this.handleKeyboard); 27 | } 28 | } 29 | 30 | componentWillUnmount() { 31 | this.editor.offKeyboard(this.handleKeyboard); 32 | } 33 | 34 | render() { 35 | return ( 36 | this.editor.insertMarkdown('strikethrough')} 40 | > 41 | 42 | 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/plugins/header/HeaderList.tsx: -------------------------------------------------------------------------------- 1 | // HeaderList 2 | import * as React from 'react'; 3 | 4 | interface HeaderListProps { 5 | onSelectHeader?: (header: string) => void; 6 | } 7 | 8 | class HeaderList extends React.Component { 9 | handleHeader(header: string) { 10 | const { onSelectHeader } = this.props; 11 | if (typeof onSelectHeader === 'function') { 12 | onSelectHeader(header); 13 | } 14 | } 15 | 16 | render() { 17 | return ( 18 |
    19 |
  • 20 |

    H1

    21 |
  • 22 |
  • 23 |

    H2

    24 |
  • 25 |
  • 26 |

    H3

    27 |
  • 28 |
  • 29 |

    H4

    30 |
  • 31 |
  • 32 |
    H5
    33 |
  • 34 |
  • 35 |
    H6
    36 |
  • 37 |
38 | ); 39 | } 40 | } 41 | export default HeaderList; 42 | -------------------------------------------------------------------------------- /src/plugins/header/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import DropList from '../../components/DropList'; 3 | import Icon from '../../components/Icon'; 4 | import i18n from '../../i18n'; 5 | import { PluginComponent } from '../Plugin'; 6 | import HeaderList from './HeaderList'; 7 | 8 | interface State { 9 | show: boolean; 10 | } 11 | 12 | export default class Header extends PluginComponent { 13 | static pluginName = 'header'; 14 | 15 | constructor(props: any) { 16 | super(props); 17 | 18 | this.show = this.show.bind(this); 19 | this.hide = this.hide.bind(this); 20 | 21 | this.state = { 22 | show: false, 23 | }; 24 | } 25 | 26 | private show() { 27 | this.setState({ 28 | show: true, 29 | }); 30 | } 31 | 32 | private hide() { 33 | this.setState({ 34 | show: false, 35 | }); 36 | } 37 | 38 | render() { 39 | return ( 40 | 46 | 47 | 48 | this.editor.insertMarkdown(header)} /> 49 | 50 | 51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/editor/preview.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export type HtmlType = string | React.ReactElement; 4 | 5 | export interface PreviewProps { 6 | html: HtmlType; 7 | className?: string; 8 | } 9 | 10 | export abstract class Preview extends React.Component { 11 | protected el: React.RefObject; 12 | 13 | constructor(props: any) { 14 | super(props); 15 | this.el = React.createRef(); 16 | } 17 | 18 | abstract getHtml(): string; 19 | 20 | getElement(): T | null { 21 | return this.el.current; 22 | } 23 | 24 | getHeight() { 25 | return this.el.current ? this.el.current.offsetHeight : 0; 26 | } 27 | } 28 | 29 | export class HtmlRender extends Preview { 30 | getHtml() { 31 | if (typeof this.props.html === 'string') { 32 | return this.props.html; 33 | } 34 | if (this.el.current) { 35 | return this.el.current.innerHTML; 36 | } 37 | return ''; 38 | } 39 | 40 | render() { 41 | return typeof this.props.html === 'string' 42 | ? React.createElement('div', { 43 | ref: this.el, 44 | dangerouslySetInnerHTML: { __html: this.props.html }, 45 | className: this.props.className || 'custom-html-style', 46 | }) 47 | : React.createElement( 48 | 'div', 49 | { 50 | ref: this.el, 51 | className: this.props.className || 'custom-html-style', 52 | }, 53 | this.props.html, 54 | ); 55 | } 56 | } 57 | 58 | export default HtmlRender; 59 | -------------------------------------------------------------------------------- /src/plugins/fullScreen.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Icon from '../components/Icon'; 3 | import i18n from '../i18n'; 4 | import { PluginComponent } from './Plugin'; 5 | 6 | interface FullScreenState { 7 | enable: boolean; 8 | } 9 | 10 | export default class FullScreen extends PluginComponent { 11 | static pluginName = 'full-screen'; 12 | 13 | static align = 'right'; 14 | 15 | constructor(props: any) { 16 | super(props); 17 | 18 | this.handleClick = this.handleClick.bind(this); 19 | this.handleChange = this.handleChange.bind(this); 20 | 21 | this.state = { 22 | enable: this.editor.isFullScreen(), 23 | }; 24 | } 25 | 26 | private handleClick() { 27 | this.editor.fullScreen(!this.state.enable); 28 | } 29 | 30 | private handleChange(enable: boolean) { 31 | this.setState({ enable }); 32 | } 33 | 34 | componentDidMount() { 35 | this.editor.on('fullscreen', this.handleChange); 36 | } 37 | 38 | componentWillUnmount() { 39 | this.editor.off('fullscreen', this.handleChange); 40 | } 41 | 42 | render() { 43 | if (this.editorConfig.canView && this.editorConfig.canView.fullScreen) { 44 | const { enable } = this.state; 45 | return ( 46 | 51 | 52 | 53 | ); 54 | } 55 | return null; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [12.x] 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | # Install node 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | registry-url: 'https://registry.npmjs.org' 23 | # Install Yarn 24 | - name: Install Yarn 25 | run: npm i -g yarn 26 | # Yarn caches 27 | - name: Get yarn cache directory path 28 | id: yarn-cache-dir-path 29 | run: echo "::set-output name=dir::$(yarn cache dir)" 30 | - uses: actions/cache@v1 31 | id: yarn-cache 32 | with: 33 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 34 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 35 | restore-keys: | 36 | ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 37 | # Update version 38 | - name: Get version 39 | id: get_version 40 | run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\/v/} 41 | - name: Update version 42 | run: npm version --no-git-tag-version ${{ steps.get_version.outputs.VERSION }} 43 | # Install dependencies 44 | - name: Install dependencies 45 | run: yarn install --frozen-lockfile 46 | - name: Build 47 | run: yarn run prod 48 | - name: Test 49 | run: yarn run test 50 | - run: npm publish 51 | env: 52 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 53 | -------------------------------------------------------------------------------- /src/plugins/Image/inputFile.tsx: -------------------------------------------------------------------------------- 1 | // TableList 2 | import * as React from 'react'; 3 | 4 | interface InputFileProps { 5 | accept: string; 6 | onChange: (event: React.ChangeEvent) => void; 7 | } 8 | 9 | class InputFile extends React.Component { 10 | private timerId?: number; 11 | 12 | private locked: boolean; 13 | 14 | private input: React.RefObject; 15 | 16 | constructor(props: any) { 17 | super(props); 18 | this.timerId = undefined; 19 | this.locked = false; 20 | this.input = React.createRef(); 21 | } 22 | 23 | click() { 24 | if (this.locked || !this.input.current) { 25 | return; 26 | } 27 | this.locked = true; 28 | this.input.current.value = ''; 29 | this.input.current.click(); 30 | if (this.timerId) { 31 | window.clearTimeout(this.timerId); 32 | } 33 | this.timerId = window.setTimeout(() => { 34 | this.locked = false; 35 | window.clearTimeout(this.timerId); 36 | this.timerId = undefined; 37 | }, 200); 38 | } 39 | 40 | componentWillUnmount() { 41 | if (this.timerId) { 42 | window.clearTimeout(this.timerId); 43 | } 44 | } 45 | 46 | render() { 47 | return ( 48 | 63 | ); 64 | } 65 | } 66 | export default InputFile; 67 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - dev 8 | pull_request: 9 | branches: 10 | - master 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | node-version: [12.x] 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | # Install node 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v1 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | # Install Yarn 27 | - name: Install Yarn 28 | run: npm i -g yarn 29 | # Yarn caches 30 | - name: Get yarn cache directory path 31 | id: yarn-cache-dir-path 32 | run: echo "::set-output name=dir::$(yarn cache dir)" 33 | - uses: actions/cache@v1 34 | id: yarn-cache 35 | with: 36 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 37 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 38 | restore-keys: | 39 | ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 40 | # Install dependencies 41 | - name: Install dependencies 42 | run: yarn install --frozen-lockfile 43 | - name: Build 44 | run: yarn run build 45 | - name: Test 46 | run: yarn run test 47 | - name: Coverage 48 | run: yarn run coverage 49 | - name: Upload bundles 50 | uses: actions/upload-artifact@v2 51 | with: 52 | name: lib 53 | path: | 54 | esm 55 | cjs 56 | lib 57 | preview 58 | - name: Upload coverage 59 | uses: actions/upload-artifact@v2 60 | with: 61 | name: coverage 62 | path: coverage 63 | 64 | -------------------------------------------------------------------------------- /src/plugins/table/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import DropList from '../../components/DropList'; 3 | import Icon from '../../components/Icon'; 4 | import i18n from '../../i18n'; 5 | import { PluginComponent, PluginProps } from '../Plugin'; 6 | import TableList from './table'; 7 | 8 | interface State { 9 | show: boolean; 10 | } 11 | 12 | interface Props extends PluginProps { 13 | config: { 14 | maxRow?: number; 15 | maxCol?: number; 16 | }; 17 | } 18 | 19 | export default class Table extends PluginComponent { 20 | static pluginName = 'table'; 21 | 22 | static defaultConfig = { 23 | maxRow: 6, 24 | maxCol: 6, 25 | }; 26 | 27 | constructor(props: any) { 28 | super(props); 29 | 30 | this.show = this.show.bind(this); 31 | this.hide = this.hide.bind(this); 32 | 33 | this.state = { 34 | show: false, 35 | }; 36 | } 37 | 38 | private show() { 39 | this.setState({ 40 | show: true, 41 | }); 42 | } 43 | 44 | private hide() { 45 | this.setState({ 46 | show: false, 47 | }); 48 | } 49 | 50 | render() { 51 | const config = this.editorConfig.table || this.props.config; 52 | return ( 53 | 59 | 60 | 61 | this.editor.insertMarkdown('table', option)} 66 | /> 67 | 68 | 69 | ); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/share/var.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export type UploadFunc = ((file: File) => Promise) | ((file: File, callback: (url: string) => void) => void); 4 | 5 | export type EditorEvent = 'change' | 'fullscreen' | 'viewchange' | 'keydown' | 'focus' | 'blur' | 'scroll' | 'editor_keydown'; 6 | 7 | export interface EditorConfig { 8 | theme?: string; 9 | name?: string; 10 | view?: { 11 | menu: boolean; 12 | md: boolean; 13 | html: boolean; 14 | }; 15 | canView?: { 16 | menu: boolean; 17 | md: boolean; 18 | html: boolean; 19 | both: boolean; 20 | fullScreen: boolean; 21 | hideMenu: boolean; 22 | }; 23 | htmlClass?: string; 24 | markdownClass?: string; 25 | imageUrl?: string; 26 | imageAccept?: string; 27 | linkUrl?: string; 28 | loggerMaxSize?: number; 29 | loggerInterval?: number; 30 | table?: { 31 | maxRow: number; 32 | maxCol: number; 33 | }; 34 | syncScrollMode?: string[]; 35 | allowPasteImage?: boolean; 36 | onImageUpload?: UploadFunc; 37 | onChangeTrigger?: 'both' | 'beforeRender' | 'afterRender'; 38 | onCustomImageUpload?: (event: any) => Promise<{ url: string; text?: string }>; 39 | shortcuts?: boolean; 40 | } 41 | 42 | export interface Selection { 43 | start: number; 44 | end: number; 45 | text: string; 46 | } 47 | 48 | export const initialSelection: Selection = { 49 | start: 0, 50 | end: 0, 51 | text: '', 52 | }; 53 | 54 | export type KeyboardEventCallback = (e: React.KeyboardEvent) => void; 55 | export interface KeyboardEventCondition { 56 | key?: string; 57 | keyCode: number; 58 | aliasCommand?: boolean; 59 | withKey?: ('ctrlKey' | 'shiftKey' | 'altKey' | 'metaKey')[]; 60 | } 61 | export interface KeyboardEventListener extends KeyboardEventCondition { 62 | callback: KeyboardEventCallback; 63 | } 64 | -------------------------------------------------------------------------------- /webpack.plugin.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const fse = require('fs-extra'); 3 | const path = require('path'); 4 | 5 | const folderMap = { 6 | es: 'esm', 7 | lib: 'cjs', 8 | dist: 'lib', 9 | build: 'preview', 10 | }; 11 | 12 | module.exports = ({ onGetWebpackConfig, onHook }) => { 13 | onGetWebpackConfig(config => { 14 | // 启用静态文件支持 15 | config.module.rules 16 | .delete('woff2') 17 | .delete('ttf') 18 | .delete('eot') 19 | .delete('svg'); 20 | config.module 21 | .rule('url-loader') 22 | .test(/\.(png|svg|jpg|gif|eot|woff|ttf)$/) 23 | .use('url-loader') 24 | .loader('url-loader') 25 | .options({ 26 | limit: 20000, 27 | }); 28 | 29 | // UMD 输出,将 output 改为 index 30 | if (config.output.get('libraryTarget') === 'umd') { 31 | const entries = config.entryPoints.entries(); 32 | for (const it in entries) { 33 | config.entryPoints.set('index', entries[it]); 34 | config.entryPoints.delete(it); 35 | } 36 | } 37 | }); 38 | 39 | onHook('before.build.run', () => { 40 | const folders = [...Object.keys(folderMap), ...Object.values(folderMap)]; 41 | for (const it of folders) { 42 | fse.rmdirSync(path.join(__dirname, it), { recursive: true }); 43 | console.log('Remove directory ' + it); 44 | } 45 | }); 46 | 47 | onHook('after.build.compile', () => { 48 | const toRename = Object.keys(folderMap); 49 | for (const it of toRename) { 50 | if (fs.existsSync(path.join(__dirname, it))) { 51 | fs.renameSync(path.join(__dirname, it), path.join(__dirname, folderMap[it])); 52 | console.log('Rename ' + it + ' to ' + folderMap[it]); 53 | } 54 | } 55 | const dirs = fs.readdirSync(__dirname); 56 | console.log('Current files: ', dirs.join(' ')); 57 | }); 58 | }; 59 | -------------------------------------------------------------------------------- /test/utils/decorate.spec.ts: -------------------------------------------------------------------------------- 1 | import getDecorated from '../../src/utils/decorate'; 2 | import { expect } from 'chai'; 3 | 4 | describe('Test getDecorated', function() { 5 | // H1 6 | it('Header', function() { 7 | expect(getDecorated('text', 'h1')).to.deep.equal({ 8 | text: '\n# text\n', 9 | selection: { 10 | start: 3, 11 | end: 7, 12 | }, 13 | }); 14 | }); 15 | // 加粗 16 | it('Bold', function() { 17 | expect(getDecorated('text', 'bold')).to.deep.equal({ 18 | text: '**text**', 19 | selection: { 20 | start: 2, 21 | end: 6, 22 | }, 23 | }); 24 | }); 25 | // 有序列表 26 | it('Order List', function() { 27 | expect(getDecorated('a\nb\nc', 'order').text).to.equal('1. a\n2. b\n3. c'); 28 | }); 29 | // 无序列表 30 | it('Unorder List', function() { 31 | expect(getDecorated('a\nb\nc', 'unordered').text).to.equal('* a\n* b\n* c'); 32 | }); 33 | // 图片 34 | it('Image', function() { 35 | expect( 36 | getDecorated('text', 'image', { 37 | imageUrl: 'https://example.com/img.jpg', 38 | }), 39 | ).to.deep.equal({ 40 | text: '![text](https://example.com/img.jpg)', 41 | selection: { 42 | start: 2, 43 | end: 6, 44 | }, 45 | }); 46 | }); 47 | // 链接 48 | it('Link', function() { 49 | expect( 50 | getDecorated('text', 'link', { 51 | linkUrl: 'https://example.com', 52 | }), 53 | ).to.deep.equal({ 54 | text: '[text](https://example.com)', 55 | selection: { 56 | start: 1, 57 | end: 5, 58 | }, 59 | }); 60 | }); 61 | // 表格 62 | it('Table', function() { 63 | expect( 64 | getDecorated('', 'table', { 65 | row: 4, 66 | col: 2, 67 | }).text, 68 | ).to.equal('| Head | Head |\n| --- | --- |\n| Data | Data |\n| Data | Data |\n| Data | Data |\n| Data | Data |'); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /test/editor.spec.tsx: -------------------------------------------------------------------------------- 1 | import { cleanup, fireEvent, render, screen } from '@testing-library/react'; 2 | import { expect } from 'chai'; 3 | import * as React from 'react'; 4 | import Editor from '../src'; 5 | 6 | describe('Test Editor', function() { 7 | // render 8 | it('render', function() { 9 | const value = Math.random().toString(); 10 | const { container, rerender } = render( text} value={value} />); 11 | 12 | expect(container.querySelector('.rc-md-editor')).not.to.be.null; 13 | 14 | const textarea = container.querySelector('textarea'); 15 | expect(textarea).not.to.be.null; 16 | if (textarea !== null) { 17 | expect(textarea.value).to.equals(value); 18 | // Update value 19 | const newValue = value + Math.random().toString(); 20 | rerender( text} value={newValue} />); 21 | 22 | expect(textarea.value).to.equals(newValue); 23 | } 24 | }); 25 | 26 | // render with label 27 | it('render with label', function() { 28 | const { queryByLabelText } = render(
29 | 30 | text} value="123456" /> 31 |
); 32 | 33 | const textarea = queryByLabelText('My Editor'); 34 | expect(textarea).not.to.be.null; 35 | if (textarea !== null) { 36 | expect((textarea as HTMLTextAreaElement).value).to.equals('123456'); 37 | } 38 | }); 39 | 40 | // render with default value produces a preview 41 | it('render with default value', function() { 42 | const text = "Hello World!"; 43 | const { getByText } = render( text} defaultValue={text} />); 44 | 45 | // Attempt to fetch the preview pane by using the CSS selector 46 | const element = getByText(text, { selector: ".custom-html-style"}); 47 | expect(element.innerHTML).to.equals(text); 48 | }); 49 | 50 | afterEach(cleanup); 51 | }); -------------------------------------------------------------------------------- /src/plugins/logger/logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * logger: undo redo 3 | */ 4 | 5 | const MAX_LOG_SIZE = 100; 6 | 7 | interface LoggerProps { 8 | maxSize?: number; 9 | } 10 | 11 | class Logger { 12 | private record: string[] = []; 13 | 14 | private recycle: string[] = []; 15 | 16 | private maxSize: number; 17 | 18 | initValue: string = ''; 19 | 20 | constructor(props: LoggerProps = {}) { 21 | const { maxSize = MAX_LOG_SIZE } = props; 22 | this.maxSize = maxSize; 23 | } 24 | 25 | push(val: string) { 26 | const result = this.record.push(val); 27 | // 如果超过了最长限制,把之前的清理掉,避免造成内存浪费 28 | while (this.record.length > this.maxSize) { 29 | this.record.shift(); 30 | } 31 | return result; 32 | } 33 | 34 | get() { 35 | return this.record; 36 | } 37 | 38 | getLast(): string { 39 | const { length } = this.record; 40 | return this.record[length - 1]; 41 | } 42 | 43 | undo(skipText?: string) { 44 | const current = this.record.pop(); 45 | if (typeof current === 'undefined') { 46 | return this.initValue; 47 | } 48 | // 如果最上面的和现在的不一样,那就不需要再pop一次 49 | if (current !== skipText) { 50 | this.recycle.push(current); 51 | return current; 52 | } 53 | // 否则的话,最顶上的一个是当前状态,所以要pop两次才能得到之前的结果 54 | const last = this.record.pop(); 55 | if (typeof last === 'undefined') { 56 | // 已经没有更老的记录了,把初始值给出去吧 57 | this.recycle.push(current); 58 | return this.initValue; 59 | } 60 | // last 才是真正的上一步 61 | this.recycle.push(current); 62 | return last; 63 | } 64 | 65 | redo() { 66 | const history = this.recycle.pop(); 67 | if (typeof history !== 'undefined') { 68 | this.push(history); 69 | return history; 70 | } 71 | return undefined; 72 | } 73 | 74 | cleanRedo() { 75 | this.recycle = []; 76 | } 77 | 78 | getUndoCount() { 79 | return this.undo.length; 80 | } 81 | 82 | getRedoCount() { 83 | return this.recycle.length; 84 | } 85 | } 86 | 87 | export default Logger; 88 | -------------------------------------------------------------------------------- /src/components/NavigationBar/index.less: -------------------------------------------------------------------------------- 1 | .rc-md-navigation { 2 | min-height: 38px; 3 | padding: 0px 8px; 4 | box-sizing: border-box; 5 | border-bottom: 1px solid #e0e0e0; // grey lighten-2 6 | font-size: 16px; 7 | background: #f5f5f5; // grey lighten-4 8 | user-select: none; 9 | display: flex; 10 | flex-direction: row; 11 | justify-content: space-between; 12 | 13 | &.in-visible { 14 | display: none; 15 | } 16 | 17 | .navigation-nav { 18 | display: flex; 19 | flex-direction: row; 20 | align-items: center; 21 | justify-content: center; 22 | font-size: 14px; 23 | color: #757575; // grey darken-1 24 | } 25 | .button-wrap { 26 | display: flex; 27 | flex-direction: row; 28 | flex-wrap: wrap; 29 | .button { 30 | position: relative; 31 | min-width: 24px; 32 | height: 28px; 33 | margin-left: 3px; 34 | margin-right: 3px; 35 | display: inline-block; 36 | cursor: pointer; 37 | line-height: 28px; 38 | text-align: center; 39 | color: #757575; // grey darken-1 40 | &:hover { 41 | color: #212121; // grey darken-4 42 | } 43 | &.disabled { 44 | color: #bdbdbd; // grey lighten-1 45 | cursor: not-allowed; 46 | } 47 | 48 | &:first-child { 49 | margin-left: 0; 50 | } 51 | &:last-child { 52 | margin-right: 0; 53 | } 54 | } 55 | 56 | .rmel-iconfont { 57 | font-size: 18px; 58 | } 59 | } 60 | ul, 61 | li { 62 | list-style: none; 63 | margin: 0; 64 | padding: 0; 65 | } 66 | h1, 67 | h2, 68 | h3, 69 | h4, 70 | h5, 71 | h6, 72 | .h1, 73 | .h2, 74 | .h3, 75 | .h4, 76 | .h5, 77 | .h6 { 78 | font-family: inherit; 79 | font-weight: 500; 80 | color: inherit; 81 | padding: 0; 82 | margin: 0; 83 | line-height: 1.1 84 | } 85 | h1 { 86 | font-size: 34px; 87 | } 88 | h2 { 89 | font-size: 30px; 90 | } 91 | h3 { 92 | font-size: 24px; 93 | } 94 | h4 { 95 | font-size: 18px; 96 | } 97 | h5 { 98 | font-size: 14px; 99 | } 100 | h6 { 101 | font-size: 12px; 102 | } 103 | } -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb-typescript"], 3 | "env": { 4 | "browser": true, 5 | "jest": true 6 | }, 7 | "rules": { 8 | "jsx-a11y/href-no-hash": [0], 9 | "jsx-a11y/click-events-have-key-events": [0], 10 | "jsx-a11y/anchor-is-valid": [ 11 | "error", 12 | { 13 | "components": ["Link"], 14 | "specialLink": ["to"] 15 | } 16 | ], 17 | "jsx-a11y/no-noninteractive-element-interactions": "off", 18 | "jsx-a11y/mouse-events-have-key-events": "off", 19 | "jsx-a11y/no-static-element-interactions": [0], 20 | "jsx-a11y/no-autofocus": "off", 21 | "react/sort-comp": "off", 22 | "react/no-array-index-key": "off", 23 | "react/no-did-update-set-state": "off", 24 | "react/no-access-state-in-setstate": "off", 25 | "react/react-in-jsx-scope": [0], 26 | "react/forbid-prop-types": [0], 27 | "react/require-default-props": [0], 28 | "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx", ".ts", ".tsx"] }], 29 | "react/destructuring-assignment": [0], 30 | "import/extensions": [0], 31 | "import/no-unresolved": [0], 32 | "arrow-body-style": ["error", "as-needed", { "requireReturnForObjectLiteral": true }], 33 | "arrow-parens": ["error", "always"], 34 | "space-before-function-paren": ["error", { "anonymous": "always", "named": "never", "asyncArrow": "always" }], 35 | "object-curly-newline": ["error", { "consistent": true }], 36 | "function-paren-newline": ["error", "consistent"], 37 | "class-methods-use-this": [0], 38 | "no-use-before-define": "off", 39 | "@typescript-eslint/no-use-before-define": ["error"], 40 | "@typescript-eslint/no-explicit-any": "off", 41 | "@typescript-eslint/explicit-module-boundary-types": "off", 42 | "@typescript-eslint/naming-convention": "off", 43 | "@typescript-eslint/no-unused-vars": "off", 44 | "max-len": ["error", { "code": 200 }], 45 | "no-alert": "off", 46 | "max-classes-per-file": "off", 47 | "no-plusplus": "off", 48 | "no-restricted-syntax": "off", 49 | "no-console": "off", 50 | "default-case": "off", 51 | "consistent-return": "off", 52 | "no-return-assign": "off" 53 | }, 54 | "parserOptions": { 55 | "project": "./tsconfig.json" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/components/Icon/fonts/iconfont.css: -------------------------------------------------------------------------------- 1 | @font-face {font-family: "rmel-iconfont"; 2 | src: url('./iconfont.eot?t=1609742889962'); /* IE9 */ 3 | src: url('./iconfont.ttf?t=1609742889962') format('truetype'); /* chrome, firefox, opera, Safari, Android, iOS 4.2+ */ 4 | } 5 | 6 | .rmel-iconfont { 7 | font-family: "rmel-iconfont" !important; 8 | font-size: 16px; 9 | font-style: normal; 10 | -webkit-font-smoothing: antialiased; 11 | -moz-osx-font-smoothing: grayscale; 12 | } 13 | 14 | .rmel-icon-tab:before { 15 | content: "\e76d"; 16 | } 17 | 18 | .rmel-icon-keyboard:before { 19 | content: "\ed80"; 20 | } 21 | 22 | .rmel-icon-delete:before { 23 | content: "\ed3c"; 24 | } 25 | 26 | .rmel-icon-code-block:before { 27 | content: "\e941"; 28 | } 29 | 30 | .rmel-icon-code:before { 31 | content: "\ed3b"; 32 | } 33 | 34 | .rmel-icon-visibility:before { 35 | content: "\ed44"; 36 | } 37 | 38 | .rmel-icon-view-split:before { 39 | content: "\ed45"; 40 | } 41 | 42 | .rmel-icon-link:before { 43 | content: "\ed5f"; 44 | } 45 | 46 | .rmel-icon-redo:before { 47 | content: "\ed60"; 48 | } 49 | 50 | .rmel-icon-undo:before { 51 | content: "\ed61"; 52 | } 53 | 54 | .rmel-icon-bold:before { 55 | content: "\ed6f"; 56 | } 57 | 58 | .rmel-icon-italic:before { 59 | content: "\ed70"; 60 | } 61 | 62 | .rmel-icon-list-ordered:before { 63 | content: "\ed71"; 64 | } 65 | 66 | .rmel-icon-list-unordered:before { 67 | content: "\ed72"; 68 | } 69 | 70 | .rmel-icon-quote:before { 71 | content: "\ed73"; 72 | } 73 | 74 | .rmel-icon-strikethrough:before { 75 | content: "\ed74"; 76 | } 77 | 78 | .rmel-icon-underline:before { 79 | content: "\ed75"; 80 | } 81 | 82 | .rmel-icon-wrap:before { 83 | content: "\ed77"; 84 | } 85 | 86 | .rmel-icon-font-size:before { 87 | content: "\ed78"; 88 | } 89 | 90 | .rmel-icon-grid:before { 91 | content: "\ed8c"; 92 | } 93 | 94 | .rmel-icon-image:before { 95 | content: "\ed8d"; 96 | } 97 | 98 | .rmel-icon-expand-less:before { 99 | content: "\ed9f"; 100 | } 101 | 102 | .rmel-icon-expand-more:before { 103 | content: "\eda0"; 104 | } 105 | 106 | .rmel-icon-fullscreen-exit:before { 107 | content: "\eda1"; 108 | } 109 | 110 | .rmel-icon-fullscreen:before { 111 | content: "\eda2"; 112 | } 113 | 114 | -------------------------------------------------------------------------------- /src/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import Emitter, { globalEmitter } from '../share/emitter'; 2 | import enUS from './lang/en-US'; 3 | import zhCN from './lang/zh-CN'; 4 | 5 | type LangItem = { [x: string]: string }; 6 | type Langs = { [x: string]: LangItem }; 7 | 8 | class I18n { 9 | private langs: Langs = { enUS, zhCN }; 10 | private current: string = 'enUS'; 11 | 12 | constructor() { 13 | this.setUp(); 14 | } 15 | 16 | setUp() { 17 | if (typeof window === 'undefined') { 18 | // 不在浏览器环境中,取消检测 19 | return; 20 | } 21 | let locale = 'enUS'; 22 | // 检测语言 23 | if (navigator.language) { 24 | const it = navigator.language.split('-'); 25 | locale = it[0]; 26 | if (it.length !== 1) { 27 | locale += it[it.length - 1].toUpperCase(); 28 | } 29 | } 30 | 31 | // IE10及更低版本使用browserLanguage 32 | // @ts-ignore 33 | if (navigator.browserLanguage) { 34 | // @ts-ignore 35 | const it = navigator.browserLanguage.split('-'); 36 | locale = it[0]; 37 | if (it[1]) { 38 | locale += it[1].toUpperCase(); 39 | } 40 | } 41 | 42 | if (this.current !== locale && this.isAvailable(locale)) { 43 | this.current = locale; 44 | globalEmitter.emit(globalEmitter.EVENT_LANG_CHANGE, this, locale, this.langs[locale]); 45 | } 46 | } 47 | 48 | isAvailable(langName: string) { 49 | return typeof this.langs[langName] !== 'undefined'; 50 | } 51 | 52 | add(langName: string, lang: LangItem) { 53 | this.langs[langName] = lang; 54 | } 55 | 56 | setCurrent(langName: string) { 57 | if (!this.isAvailable(langName)) { 58 | throw new Error(`Language ${langName} is not exists`); 59 | } 60 | if (this.current !== langName) { 61 | this.current = langName; 62 | globalEmitter.emit(globalEmitter.EVENT_LANG_CHANGE, this, langName, this.langs[langName]); 63 | } 64 | } 65 | 66 | get(key: string, placeholders?: { [x: string]: string }) { 67 | let str = this.langs[this.current][key] || ''; 68 | if (placeholders) { 69 | Object.keys(placeholders).forEach(k => { 70 | str = str.replace(new RegExp(`\\{${k}\\}`, 'g'), placeholders[k]); 71 | }); 72 | } 73 | return str; 74 | } 75 | 76 | getCurrent() { 77 | return this.current; 78 | } 79 | } 80 | 81 | const i18n = new I18n(); 82 | export default i18n; 83 | -------------------------------------------------------------------------------- /src/plugins/autoResize.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { PluginComponent } from './Plugin'; 3 | 4 | export default class AutoResize extends PluginComponent { 5 | static pluginName = 'auto-resize'; 6 | 7 | static align = 'left'; 8 | 9 | static defaultConfig = { 10 | min: 200, 11 | max: Infinity, 12 | useTimer: false, 13 | }; 14 | 15 | private timer: number | null = null; 16 | 17 | private useTimer: boolean; 18 | 19 | constructor(props: any) { 20 | super(props); 21 | 22 | this.useTimer = this.getConfig('useTimer') || typeof requestAnimationFrame === 'undefined'; 23 | 24 | this.handleChange = this.handleChange.bind(this); 25 | this.doResize = this.doResize.bind(this); 26 | } 27 | 28 | doResize() { 29 | const resizeElement = (e: HTMLElement) => { 30 | e.style.height = 'auto'; 31 | const height = Math.min(Math.max(this.getConfig('min'), e.scrollHeight), this.getConfig('max')); 32 | e.style.height = `${height}px`; 33 | return height; 34 | }; 35 | 36 | this.timer = null; 37 | // 如果渲染了编辑器,就以编辑器为准 38 | const view = this.editor.getView(); 39 | const el = this.editor.getMdElement(); 40 | const previewer = this.editor.getHtmlElement(); 41 | if (el && view.md) { 42 | const height = resizeElement(el); 43 | if (previewer) { 44 | previewer.style.height = `${height}px`; 45 | } 46 | return; 47 | } 48 | // 否则,以预览区域为准 49 | if (previewer && view.html) { 50 | resizeElement(previewer); 51 | } 52 | } 53 | 54 | handleChange() { 55 | if (this.timer !== null) { 56 | return; 57 | } 58 | 59 | if (this.useTimer) { 60 | this.timer = window.setTimeout(this.doResize); 61 | return; 62 | } 63 | 64 | this.timer = requestAnimationFrame(this.doResize); 65 | } 66 | 67 | componentDidMount() { 68 | this.editor.on('change', this.handleChange); 69 | this.editor.on('viewchange', this.handleChange); 70 | this.handleChange(); 71 | } 72 | 73 | componentWillUnmount() { 74 | this.editor.off('change', this.handleChange); 75 | this.editor.off('viewchange', this.handleChange); 76 | if (this.timer !== null && this.useTimer) { 77 | window.clearTimeout(this.timer); 78 | this.timer = null; 79 | } 80 | } 81 | 82 | render() { 83 | return ; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import Editor from './editor'; 2 | import AutoResize from './plugins/autoResize'; 3 | import BlockCodeBlock from './plugins/block/code-block'; 4 | import BlockCodeInline from './plugins/block/code-inline'; 5 | import BlockQuote from './plugins/block/quote'; 6 | import BlockWrap from './plugins/block/wrap'; 7 | import Clear from './plugins/clear'; 8 | import FontBold from './plugins/font/bold'; 9 | import FontItalic from './plugins/font/italic'; 10 | import FontStrikethrough from './plugins/font/strikethrough'; 11 | import FontUnderline from './plugins/font/underline'; 12 | import FullScreen from './plugins/fullScreen'; 13 | import Header from './plugins/header'; 14 | import Image from './plugins/Image'; 15 | import Link from './plugins/link'; 16 | import ListOrdered from './plugins/list/ordered'; 17 | import ListUnordered from './plugins/list/unordered'; 18 | import Logger from './plugins/logger'; 19 | import ModeToggle from './plugins/modeToggle'; 20 | import Table from './plugins/table'; 21 | import TabInsert from './plugins/tabInsert'; 22 | import { PluginComponent } from './plugins/Plugin'; 23 | import type { PluginProps } from './plugins/Plugin'; 24 | import DropList from './components/DropList/index'; 25 | 26 | // 注册默认插件 27 | Editor.use(Header); 28 | Editor.use(FontBold); 29 | Editor.use(FontItalic); 30 | Editor.use(FontUnderline); 31 | Editor.use(FontStrikethrough); 32 | Editor.use(ListUnordered); 33 | Editor.use(ListOrdered); 34 | Editor.use(BlockQuote); 35 | Editor.use(BlockWrap); 36 | Editor.use(BlockCodeInline); 37 | Editor.use(BlockCodeBlock); 38 | Editor.use(Table); 39 | Editor.use(Image); 40 | Editor.use(Link); 41 | Editor.use(Clear); 42 | Editor.use(Logger); 43 | Editor.use(ModeToggle); 44 | Editor.use(FullScreen); 45 | 46 | // 导出声明 47 | // 导出工具组件 48 | export { DropList }; 49 | export { PluginComponent }; 50 | export type { PluginProps }; 51 | // 导出实用工具 52 | export { default as getDecorated } from './utils/decorate'; 53 | // 导出内置插件 54 | export const Plugins = { 55 | Header, 56 | FontBold, 57 | FontItalic, 58 | FontUnderline, 59 | FontStrikethrough, 60 | ListUnordered, 61 | ListOrdered, 62 | BlockQuote, 63 | BlockWrap, 64 | BlockCodeInline, 65 | BlockCodeBlock, 66 | Table, 67 | Image, 68 | Link, 69 | Clear, 70 | Logger, 71 | ModeToggle, 72 | FullScreen, 73 | AutoResize, 74 | TabInsert, 75 | }; 76 | 77 | // 导出编辑器 78 | export default Editor; 79 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:latest", "tslint-react", "tslint-config-prettier"], 3 | "linterOptions": { 4 | "exclude": ["**/*.js", "**/*.jsx"] 5 | }, 6 | "rules": { 7 | "jsx-no-multiline-js": false, 8 | "jsx-boolean-value": false, 9 | "jsx-no-bind": true, 10 | "jsx-no-lambda": false, 11 | "jsx-no-string-ref": true, 12 | "no-implicit-dependencies": false, 13 | "no-submodule-imports": false, 14 | "prefer-conditional-expression": false, 15 | "object-literal-sort-keys": false, 16 | "member-access": [true, "no-public"], 17 | "variable-name": [true, "ban-keywords", "check-format", "allow-leading-underscore", "allow-pascal-case"], 18 | "member-ordering": [false, { "order": "statics-first" }], 19 | "prefer-for-of": false, 20 | "no-parameter-reassignment": true, 21 | "ban-comma-operator": true, 22 | "function-constructor": true, 23 | "label-position": true, 24 | "no-arg": true, 25 | "no-conditional-assignment": true, 26 | "no-construct": true, 27 | "no-console": false, 28 | "no-debugger": false, 29 | "no-duplicate-super": true, 30 | "no-duplicate-switch-case": true, 31 | "no-empty": [true, "allow-empty-functions"], 32 | "no-object-literal-type-assertion": true, 33 | "no-return-await": true, 34 | "no-shadowed-variable": true, 35 | "no-sparse-arrays": true, 36 | "no-this-assignment": [true, { "allow-destructuring": true }], 37 | "no-unsafe-finally": true, 38 | "no-var-keyword": true, 39 | "prefer-object-spread": true, 40 | "radix": false, 41 | "triple-equals": [true, "allow-null-check", "allow-undefined-check"], 42 | "unnecessary-constructor": true, 43 | "use-isnan": true, 44 | "max-classes-per-file": false, 45 | "no-duplicate-imports": true, 46 | "no-require-imports": true, 47 | "prefer-const": true, 48 | "class-name": true, 49 | "encoding": true, 50 | "interface-name": false, 51 | "interface-over-type-literal": false, 52 | "no-angle-bracket-type-assertion": true, 53 | "no-reference-import": true, 54 | "no-unnecessary-initializer": true, 55 | "one-variable-per-declaration": [true, "ignore-for-loop"], 56 | "ordered-imports": false, 57 | "prefer-template": [true, "allow-single-concat"], 58 | "return-undefined": true, 59 | "unnecessary-bind": true, 60 | "prettier": true 61 | }, 62 | "rulesDirectory": ["tslint-plugin-prettier"] 63 | } 64 | -------------------------------------------------------------------------------- /src/plugins/tabInsert/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Since the Markdown Editor will lose input focus when user tpye a Tab key, 3 | * this is a built-in plugin to enable user to input Tab character. 4 | * see src/demo/index.tsx. 5 | */ 6 | 7 | import * as React from 'react'; 8 | import { KeyboardEventListener } from '../../share/var'; 9 | import { PluginComponent } from '../Plugin'; 10 | import DropList from '../../components/DropList'; 11 | import i18n from '../../i18n'; 12 | import TabMapList from './TabMapList'; 13 | import Icon from '../../components/Icon'; 14 | 15 | /** 16 | * @field tabMapValue: Number of spaces will be inputted. Especially, note that 1 means a '\t' instead of ' '. 17 | * @field show: Whether to show TabMapList. 18 | */ 19 | interface TabInsertState { 20 | tabMapValue: number; 21 | show: boolean; 22 | } 23 | 24 | export default class TabInsert extends PluginComponent { 25 | static pluginName = 'tab-insert'; 26 | 27 | static defaultConfig = { 28 | tabMapValue: 1, 29 | }; 30 | 31 | private handleKeyboard: KeyboardEventListener; 32 | 33 | constructor(props: any) { 34 | super(props); 35 | 36 | this.show = this.show.bind(this); 37 | this.hide = this.hide.bind(this); 38 | this.handleChangeMapValue = this.handleChangeMapValue.bind(this); 39 | 40 | this.state = { 41 | tabMapValue: this.getConfig('tabMapValue'), 42 | show: false, 43 | }; 44 | this.handleKeyboard = { 45 | key: 'Tab', 46 | keyCode: 9, 47 | aliasCommand: true, 48 | withKey: [], 49 | callback: () => this.editor.insertMarkdown('tab', { tabMapValue: this.state.tabMapValue }), 50 | }; 51 | } 52 | 53 | private show() { 54 | this.setState({ 55 | show: true, 56 | }); 57 | } 58 | 59 | private hide() { 60 | this.setState({ 61 | show: false, 62 | }); 63 | } 64 | 65 | private handleChangeMapValue(mapValue: number) { 66 | this.setState({ 67 | tabMapValue: mapValue, 68 | }); 69 | } 70 | 71 | componentDidMount() { 72 | if (this.editorConfig.shortcuts) { 73 | this.editor.onKeyboard(this.handleKeyboard); 74 | } 75 | } 76 | 77 | componentWillUnmount() { 78 | this.editor.offKeyboard(this.handleKeyboard); 79 | } 80 | 81 | render() { 82 | return ( 83 | 89 | 90 | 91 | 92 | 93 | 94 | ); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/utils/tool.ts: -------------------------------------------------------------------------------- 1 | import { KeyboardEventCondition } from '../share/var'; 2 | 3 | export function deepClone(obj: any) { 4 | if (!obj || typeof obj !== 'object') { 5 | return obj; 6 | } 7 | const objArray: any = Array.isArray(obj) ? [] : {}; 8 | if (obj && typeof obj === 'object') { 9 | for (const key in obj) { 10 | if (Object.prototype.hasOwnProperty.call(obj, key)) { 11 | // 如果obj的属性是对象,递归操作 12 | if (obj[key] && typeof obj[key] === 'object') { 13 | objArray[key] = deepClone(obj[key]); 14 | } else { 15 | objArray[key] = obj[key]; 16 | } 17 | } 18 | } 19 | } 20 | return objArray; 21 | } 22 | 23 | export function isEmpty(obj: any) { 24 | // 判断字符是否为空的方法 25 | return typeof obj === 'undefined' || obj === null || obj === ''; 26 | } 27 | 28 | export function isPromise(obj: any): obj is Promise { 29 | return ( 30 | obj && 31 | (obj instanceof Promise || 32 | ((typeof obj === 'object' || typeof obj === 'function') && typeof obj.then === 'function')) 33 | ); 34 | } 35 | 36 | export function repeat(str: string, num: number) { 37 | let result = ''; 38 | let n = num; 39 | while (n--) { 40 | result += str; 41 | } 42 | return result; 43 | } 44 | 45 | export function isKeyMatch(event: React.KeyboardEvent, cond: KeyboardEventCondition) { 46 | const { withKey, keyCode, key, aliasCommand } = cond; 47 | const e = { 48 | ctrlKey: event.ctrlKey, 49 | metaKey: event.metaKey, 50 | altKey: event.altKey, 51 | shiftKey: event.shiftKey, 52 | keyCode: event.keyCode, 53 | key: event.key, 54 | }; 55 | if (aliasCommand) { 56 | e.ctrlKey = e.ctrlKey || e.metaKey; 57 | } 58 | if (withKey && withKey.length > 0) { 59 | for (const it of withKey) { 60 | if (typeof e[it] !== 'undefined' && !e[it]) { 61 | return false; 62 | } 63 | } 64 | } else { 65 | if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) { 66 | return false; 67 | } 68 | } 69 | if (e.key) { 70 | return e.key === key; 71 | } else { 72 | return e.keyCode === keyCode; 73 | } 74 | } 75 | 76 | export function getLineAndCol(text: string, pos: number) { 77 | const lines = text.split('\n'); 78 | const beforeLines = text.substr(0, pos).split('\n'); 79 | const line = beforeLines.length; 80 | const col = beforeLines[beforeLines.length - 1].length; 81 | 82 | const curLine = lines[beforeLines.length - 1]; 83 | const prevLine = beforeLines.length > 1 ? beforeLines[beforeLines.length - 2] : null; 84 | const nextLine = lines.length > beforeLines.length ? lines[beforeLines.length] : null; 85 | 86 | return { 87 | line, 88 | col, 89 | beforeText: text.substr(0, pos), 90 | afterText: text.substr(pos), 91 | curLine, 92 | prevLine, 93 | nextLine, 94 | }; 95 | } 96 | -------------------------------------------------------------------------------- /test/utils/tool.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as Tools from '../../src/utils/tool'; 3 | 4 | function createKeyboardEvent(data: any): React.KeyboardEvent { 5 | return { 6 | ctrlKey: false, 7 | shiftKey: false, 8 | altKey: false, 9 | metaKey: false, 10 | ...data 11 | }; 12 | } 13 | 14 | const zWithCommand = createKeyboardEvent({ 15 | key: 'z', 16 | keyCode: 90, 17 | metaKey: true 18 | }); 19 | 20 | describe('Test tools', function() { 21 | // deepClone 22 | it('Test deepClone', function() { 23 | const obj = { 24 | a: 1, 25 | b: [1, 2, 3], 26 | c: "string", 27 | d: { 28 | da: 123, 29 | db: ['a', 'b'] 30 | } 31 | }; 32 | expect(Tools.deepClone(obj)).to.deep.equal(obj); 33 | }); 34 | // isEmpty 35 | it('Test isEmpty', function() { 36 | expect(Tools.isEmpty('')).to.be.true; 37 | expect(Tools.isEmpty(null)).to.be.true; 38 | expect(Tools.isEmpty(undefined)).to.be.true; 39 | expect(Tools.isEmpty(0)).to.be.false; 40 | expect(Tools.isEmpty('str')).to.be.false; 41 | }); 42 | it('Test isPromise', function() { 43 | expect(Tools.isPromise(Promise.resolve())).to.be.true; 44 | expect(Tools.isPromise(Promise.all([]))).to.be.true; 45 | expect(Tools.isPromise('123')).to.be.false; 46 | expect(Tools.isPromise(function() { })).to.be.false; 47 | }); 48 | it('Test getLineAndCol', function() { 49 | const text = "123\n456\n789"; 50 | expect(Tools.getLineAndCol(text, 5)).to.deep.equal({ 51 | line: 2, 52 | col: 1, 53 | beforeText: "123\n4", 54 | afterText: "56\n789", 55 | curLine: "456", 56 | prevLine: "123", 57 | nextLine: "789" 58 | }); 59 | expect(Tools.getLineAndCol(text, 2).prevLine).to.be.null; 60 | expect(Tools.getLineAndCol(text, 8).nextLine).to.be.null; 61 | }); 62 | // KeyMatch 63 | it('Test isKeyMatch (Match)', function() { 64 | expect(Tools.isKeyMatch(zWithCommand, { 65 | key: 'z', 66 | keyCode: 90, 67 | withKey: ['metaKey'] 68 | })).to.be.true; 69 | expect(Tools.isKeyMatch(zWithCommand, { 70 | key: 'z', 71 | keyCode: 90, 72 | aliasCommand: true, 73 | withKey: ['ctrlKey'] 74 | })).to.be.true; 75 | }); 76 | it('Test isKeyMatch (Not match)', function() { 77 | expect(Tools.isKeyMatch(zWithCommand, { 78 | key: 'z', 79 | keyCode: 90, 80 | withKey: ['ctrlKey'] 81 | })).to.be.false; 82 | expect(Tools.isKeyMatch(zWithCommand, { 83 | key: 'z', 84 | keyCode: 90, 85 | withKey: ['metaKey', 'altKey'] 86 | })).to.be.false; 87 | expect(Tools.isKeyMatch(zWithCommand, { 88 | key: 'z', 89 | keyCode: 90, 90 | withKey: ['altKey'] 91 | })).to.be.false; 92 | expect(Tools.isKeyMatch(zWithCommand, { 93 | key: 'z', 94 | keyCode: 90 95 | })).to.be.false; 96 | }); 97 | }); -------------------------------------------------------------------------------- /src/plugins/Image/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Icon from '../../components/Icon'; 3 | import i18n from '../../i18n'; 4 | import { PluginComponent } from '../Plugin'; 5 | import { isPromise } from '../../utils/tool'; 6 | import getUploadPlaceholder from '../../utils/uploadPlaceholder'; 7 | import InputFile from './inputFile'; 8 | 9 | interface State { 10 | show: boolean; 11 | } 12 | 13 | export default class Image extends PluginComponent { 14 | static pluginName = 'image'; 15 | 16 | private inputFile: React.RefObject; 17 | 18 | constructor(props: any) { 19 | super(props); 20 | 21 | this.inputFile = React.createRef(); 22 | this.onImageChanged = this.onImageChanged.bind(this); 23 | this.handleCustomImageUpload = this.handleCustomImageUpload.bind(this); 24 | this.handleImageUpload = this.handleImageUpload.bind(this); 25 | 26 | this.state = { 27 | show: false, 28 | }; 29 | } 30 | 31 | private handleImageUpload() { 32 | const { onImageUpload } = this.editorConfig; 33 | if (typeof onImageUpload === 'function') { 34 | if (this.inputFile.current) { 35 | this.inputFile.current.click(); 36 | } 37 | } else { 38 | this.editor.insertMarkdown('image'); 39 | } 40 | } 41 | 42 | private onImageChanged(file: File) { 43 | const { onImageUpload } = this.editorConfig; 44 | if (onImageUpload) { 45 | const placeholder = getUploadPlaceholder(file, onImageUpload); 46 | this.editor.insertPlaceholder(placeholder.placeholder, placeholder.uploaded); 47 | } 48 | } 49 | 50 | private handleCustomImageUpload(e: any) { 51 | const { onCustomImageUpload } = this.editorConfig; 52 | if (onCustomImageUpload) { 53 | const res = onCustomImageUpload.call(this, e); 54 | if (isPromise(res)) { 55 | res.then((result) => { 56 | if (result && result.url) { 57 | this.editor.insertMarkdown('image', { 58 | target: result.text, 59 | imageUrl: result.url, 60 | }); 61 | } 62 | }); 63 | } 64 | } 65 | } 66 | 67 | render() { 68 | const isCustom = !!this.editorConfig.onCustomImageUpload; 69 | return isCustom ? ( 70 | 71 | 72 | 73 | ) : ( 74 | 80 | 81 | ) => { 85 | e.persist(); 86 | if (e.target.files && e.target.files.length > 0) { 87 | this.onImageChanged(e.target.files[0]); 88 | } 89 | }} 90 | /> 91 | 92 | ); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/plugins/table/table.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | interface TableListProps { 4 | maxRow?: number; 5 | maxCol?: number; 6 | visibility: boolean; 7 | onSetTable?: (table: { row: number; col: number }) => void; 8 | } 9 | 10 | interface TableListState { 11 | maxRow: number; 12 | maxCol: number; 13 | list: number[][]; 14 | } 15 | 16 | class TableList extends React.Component { 17 | config = { 18 | padding: 3, 19 | width: 20, 20 | height: 20, 21 | }; 22 | 23 | constructor(props: any) { 24 | super(props); 25 | const { maxRow = 5, maxCol = 6 } = props; 26 | this.state = { 27 | maxRow, 28 | maxCol, 29 | list: this.formatTableModel(maxRow, maxCol), 30 | }; 31 | } 32 | 33 | formatTableModel(maxRow = 0, maxCol = 0) { 34 | const result = new Array(maxRow).fill(undefined); 35 | return result.map((_) => new Array(maxCol).fill(0)); 36 | } 37 | 38 | calcWrapStyle() { 39 | const { maxRow, maxCol } = this.state; 40 | const { width, height, padding } = this.config; 41 | const wrapWidth = (width + padding) * maxCol - padding; 42 | const wrapHeight = (height + padding) * maxRow - padding; 43 | return { 44 | width: `${wrapWidth}px`, 45 | height: `${wrapHeight}px`, 46 | }; 47 | } 48 | 49 | calcItemStyle(row = 0, col = 0) { 50 | const { width, height, padding } = this.config; 51 | const top = (height + padding) * row; 52 | const left = (width + padding) * col; 53 | return { 54 | top: `${top}px`, 55 | left: `${left}px`, 56 | }; 57 | } 58 | 59 | private getList(i: number, j: number) { 60 | const { list } = this.state; 61 | return list.map((v, row) => v.map((_, col) => (row <= i && col <= j ? 1 : 0))); 62 | } 63 | 64 | handleHover(i: number, j: number) { 65 | this.setState({ 66 | list: this.getList(i, j), 67 | }); 68 | } 69 | 70 | handleSetTable(i: number, j: number) { 71 | const { onSetTable } = this.props; 72 | if (typeof onSetTable === 'function') { 73 | onSetTable({ 74 | row: i + 1, 75 | col: j + 1, 76 | }); 77 | } 78 | } 79 | 80 | componentDidUpdate(prevProps: TableListProps) { 81 | if (this.props.visibility === false && prevProps.visibility !== this.props.visibility) { 82 | this.setState({ 83 | list: this.getList(-1, -1), 84 | }); 85 | } 86 | } 87 | 88 | render() { 89 | return ( 90 |
    91 | {this.state.list.map((row, i) => row.map((col, j) => ( 92 |
  • 99 | )))} 100 |
101 | ); 102 | } 103 | } 104 | export default TableList; 105 | -------------------------------------------------------------------------------- /docs/configure.zh-CN.md: -------------------------------------------------------------------------------- 1 | # Props 2 | [English documentation see here](./configure.md) 3 | ## Props列表 4 | | 名称 | 描述 | 类型 | 默认 | 备注 | 5 | | --- | --- | --- | --- | --- | 6 | | id | 元素ID | String | `undefined` | 若不为空,则编辑器、文本区域、预览区域ID分别是`{id}`、`{id}_md`、`{id}_html` | 7 | | value | 内容 | String | `''` | | 8 | | name | textarea的名称 | String | 'textarea' | | 9 | | renderHTML | 将Markdown渲染为HTML或ReactElement | `(text: string) => string | ReactElement | Promise | Promise` | none | **必填** | 10 | | placeholder | 默认提示内容 | String | undefined | | 11 | | readOnly | 是否只读状态 | Boolean | false | | 12 | | plugins | 插件列表 | string[] | undefined | | 13 | | shortcuts | 启用markdown快捷键 | boolean | false | | 14 | | view | 配置哪些项目默认被显示,包括:menu(菜单栏),md(编辑器),html(预览区) | Object | `{ menu: true, md: true, html: true }` | | 15 | | canView | 配置哪些项目可以被显示,包括:menu(菜单栏),md(编辑器),html(预览区),fullScreen(全屏),hideMenu(隐藏菜单按钮) | Object | `{ menu: true, md: true, html: true, fullScreen: true, hideMenu: true }` | | 16 | | htmlClass | 预览区域的className。如果需要默认样式,请保留`custom-html-style`。例如`your-style custom-html-style` | String | `'custom-html-style'` | | 17 | | markdownClass | 编辑区域的className | String | `''` | | 18 | | imageUrl | 当没有定义上传函数时,默认插入的图片 | String | `''` | | 19 | | linkUrl | 默认插入的链接日志 | String | `''` | | 20 | | loggerMaxSize | 历史记录最大容量(条) | number | 100 | | 21 | | loggerInterval | 历史记录触发间隔(ms) | number | 600 | | 22 | | table | 通过菜单栏创建表格的最大行、列 | Object | `{maxRow: 4, maxCol: 6}` | | 23 | | syncScrollMode | 同步滚动预览区域与编辑区域 | Array | `['rightFollowLeft', 'leftFollowRight']` | | 24 | | imageAccept | 接受上传的图片类型,例如`.jpg,.png` | String | `''` | | 25 | | onChange | 编辑器内容改变时回调 | Function | `({text, html}, event) => {}` | | 26 | | onChangeTrigger | 配置改变回调触发的时机,可选:both、beforeRender(渲染HTML前)、afterRender(渲染HTML后) | Enum | `'both` | | 27 | | onImageUpload | 上传图片时调用,需要返回一个Promise,完成时返回图片地址 | `(file: File) => Promise;` | undefined | | 28 | | onCustomImageUpload | 自定义图片按钮点击事件,返回一个Promise,完成时返回图片地址。若定义了此函数,则onImageUpload不起作用 | `() => Promise` | undefined | | 29 | 30 | ## renderHTML 31 | renderHTML支持返回HTML文本或ReactElement,例如,markdown-it返回的是HTML文本,而react-markdown返回的是ReactElement。 32 | 请注意:onChange回调获取到的是当前状态的属性。如果renderHTML是异步进行,则text和html不一定完全对应。 33 | 34 | ```js 35 | import React from 'react'; 36 | import MdEditor from 'react-markdown-editor-lite'; 37 | // 导入编辑器的样式 38 | import 'react-markdown-editor-lite/lib/index.css'; 39 | // 两种不同的解析器 40 | import MarkdownIt from 'markdown-it'; 41 | import * as ReactMarkdown from 'react-markdown'; 42 | 43 | const mdParser = new MarkdownIt(/* Markdown-it options */); 44 | 45 | function renderHTML(text: string) { 46 | // 使用 markdown-it 47 | return mdParser.render(text); 48 | // 使用 react-markdown 49 | return React.createElement(ReactMarkdown, { 50 | source: text, 51 | }); 52 | } 53 | 54 | export default (props) => { 55 | return () 56 | } 57 | ``` 58 | 59 | ## onImageUpload 60 | 61 | 上传图片回调 62 | 63 | ```js 64 | // 这个函数可以把File转为datauri字符串,作为演示 65 | function onImageUpload(file) { 66 | return new Promise(resolve => { 67 | const reader = new FileReader(); 68 | reader.onload = data => { 69 | resolve(data.target.result); 70 | }; 71 | reader.readAsDataURL(file); 72 | }); 73 | } 74 | export default (props) => { 75 | return () 76 | } 77 | ``` 78 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-markdown-editor-lite", 3 | "version": "1.2.5-9", 4 | "description": "a light-weight Markdown editor based on React", 5 | "main": "./cjs/index.js", 6 | "module": "./esm/index.js", 7 | "unpkg": "lib/index.js", 8 | "jsdelivr": "lib/index.js", 9 | "files": [ 10 | "cjs", 11 | "esm", 12 | "lib", 13 | "preview", 14 | "package.json", 15 | "README.md" 16 | ], 17 | "scripts": { 18 | "dev": "build-scripts start", 19 | "build": "build-scripts build", 20 | "prod": "build-scripts build", 21 | "test": "mocha", 22 | "coverage": "nyc mocha", 23 | "precommit": "lint-staged" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/HarryChen0506/react-markdown-editor-lite.git" 28 | }, 29 | "keywords": [ 30 | "markdown", 31 | "html", 32 | "editor", 33 | "parser", 34 | "react", 35 | "component", 36 | "plugins", 37 | "pluggable" 38 | ], 39 | "author": "HarryChen && ShuangYa", 40 | "license": "MIT", 41 | "bugs": { 42 | "url": "https://github.com/HarryChen0506/react-markdown-editor-lite/issues" 43 | }, 44 | "homepage": "https://unpkg.com/react-markdown-editor-lite@1.2.5-8/build/index.html", 45 | "devDependencies": { 46 | "@alib/build-scripts": "^0.1.3", 47 | "@iceworks/spec": "^1.0.0", 48 | "@testing-library/react": "^10.2.1", 49 | "@types/chai": "^4.2.7", 50 | "@types/classnames": "^2.2.11", 51 | "@types/markdown-it": "^0.0.8", 52 | "@types/mocha": "^5.2.7", 53 | "@types/node": "^13.5.1", 54 | "@types/react": "^16.8.22", 55 | "@types/react-dom": "^16.8.4", 56 | "@types/uuid": "^8.3.1", 57 | "@typescript-eslint/eslint-plugin": "^4.4.1", 58 | "build-plugin-component": "^1.0.0", 59 | "chai": "^4.2.0", 60 | "eslint": "^6.8.0", 61 | "eslint-config-airbnb-typescript": "^12.3.1", 62 | "eslint-plugin-import": "^2.22.0", 63 | "eslint-plugin-jsx-a11y": "^6.3.1", 64 | "eslint-plugin-react": "^7.20.3", 65 | "eslint-plugin-react-hooks": "^4.0.8", 66 | "eslint-plugin-standard": "^4.0.1", 67 | "fs-extra": "^10.0.0", 68 | "husky": "^3.1.0", 69 | "ignore-styles": "^5.0.1", 70 | "jsdom": "^16.2.2", 71 | "jsdom-global": "^3.0.2", 72 | "lint-staged": "^10.0.2", 73 | "markdown-it": "^8.4.2", 74 | "mocha": "^5.2.0", 75 | "mochawesome": "^4.1.0", 76 | "nyc": "^15.0.0", 77 | "prettier": "^1.19.1", 78 | "react": "^16.9.0", 79 | "react-dom": "^16.9.0", 80 | "react-markdown": "^4.3.1", 81 | "source-map-support": "^0.5.16", 82 | "stylelint": "^13.7.2", 83 | "ts-node": "^8.6.2", 84 | "tsconfig-paths": "^3.9.0", 85 | "typescript": "^3.5.2", 86 | "url-loader": "^2.1.0" 87 | }, 88 | "peerDependencies": { 89 | "react": "^16.9.0 || ^17.0.0 || ^18.0.0" 90 | }, 91 | "lint-staged": { 92 | "./src/**/*.{ts,tsx}": [ 93 | "eslint --fix", 94 | "git add" 95 | ] 96 | }, 97 | "nyc": { 98 | "include": [ 99 | "src/**/*.ts", 100 | "src/**/*.tsx" 101 | ], 102 | "exclude": [ 103 | "**/*.d.ts" 104 | ], 105 | "reporter": [ 106 | "html" 107 | ], 108 | "all": true 109 | }, 110 | "dependencies": { 111 | "@babel/runtime": "^7.6.2", 112 | "classnames": "^2.2.6", 113 | "eventemitter3": "^4.0.0", 114 | "uuid": "^8.3.2" 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /docs/configure.md: -------------------------------------------------------------------------------- 1 | # Props 2 | 3 | [中文文档见此](./configure.zh-CN.md) 4 | 5 | ## Props list 6 | 7 | | Property | Description | Type | default | Notes | 8 | | --- | --- | --- | --- | --- | 9 | | id | Element ID | String | `undefined` | If not empty, the id attributes of editor, text area and preview area are `{id}`, `{id}_md`, `{id}_html` | 10 | | value | Markdown content | String | `''` | | 11 | | name | the name prop of textarea | String | 'textarea' | | 12 | | renderHTML | Render markdown text to HTML. You can return either string, function or Promise | `(text: string) => string | ReactElement | Promise | Promise` | none | **required** | 13 | | placeholder | Default hint | String | undefined | | 14 | | readOnly | Is readonly | Boolean | false | | 15 | | plugins | Plugin list | string[] | undefined | | 16 | | shortcuts | Enable markdown shortcuts | boolean | false | | 17 | | view | Controls which items will be displayd by default, includes: menu(Menu bar), md(Editor), html(Preview) | Object | `{ menu: true, md: true, html: true }` | | 18 | | canView | Controls which items can be displayd, includes: menu(Menu bar), md(Editor), html(Preview), fullScreen(Full screen),hideMenu(Hide button to toggle menu bar) | Object | `{ menu: true, md: true, html: true, fullScreen: true, hideMenu: true }` | | 19 | | htmlClass | className of preview pane. If you require default html, please do not remove `custom-html-style`, like `your-style custom-html-style` | String | `'custom-html-style'` | | 20 | | markdownClass | className of editor panel | String | `''` | | 21 | | imageUrl | default image url | String | `''` | | 22 | | linkUrl | default link url | String | `''` | | 23 | | loggerMaxSize | max history logger size | number | 100 | | 24 | | loggerInterval | history logger interval (ms) | number | 600 | | 25 | | table | Max amount of rows and columns that a table created through the toolbar can have | Object | `{ maxRow: 4, maxCol: 6 }` | | 26 | | syncScrollMode | Scroll sync mode between editor and preview | Array | `['rightFollowLeft', 'leftFollowRight']` | | 27 | | imageAccept | Accepted file extensions for images, list of comma separated values i.e `.jpg,.png` | String | `''` | | 28 | | onChange | Callback called on editor change | Function | `({html, text}, event) => {}` | | 29 | | onChangeTrigger | Configure when the onChange will be triggered, allow: both, beforeRender (before render html), afterRender (after render html) | Enum | `'both` | | 30 | | onImageUpload | Called on image upload, return a Promise that resolved with image url | `(file: File) => Promise;` | undefined | | 31 | | onCustomImageUpload | custom image upload here, needs return Promise | `() => Promise` | See detail in src/editor/index.jsx | | 32 | 33 | ## renderHTML 34 | renderHTML support both HTML or ReactElement, for example, markdown-it returns HTML and react-markdown returns ReactElement. 35 | Please note: what the onChange callback gets is the properties of the current state. If renderHTML is performed asynchronously, text and html may not correspond exactly. 36 | 37 | ```js 38 | import React from 'react'; 39 | import MdEditor from 'react-markdown-editor-lite'; 40 | // Import styles 41 | import 'react-markdown-editor-lite/lib/index.css'; 42 | // Two different markdown parser 43 | import MarkdownIt from 'markdown-it'; 44 | import * as ReactMarkdown from 'react-markdown'; 45 | 46 | const mdParser = new MarkdownIt(/* Markdown-it options */); 47 | 48 | function renderHTML(text: string) { 49 | // Using markdown-it 50 | return mdParser.render(text); 51 | // Using react-markdown 52 | return React.createElement(ReactMarkdown, { 53 | source: text, 54 | }); 55 | } 56 | 57 | export default (props) => { 58 | return () 59 | } 60 | ``` 61 | 62 | ## onImageUpload 63 | 64 | Called on image upload 65 | 66 | ```js 67 | // This function can convert File object to a datauri string 68 | function onImageUpload(file) { 69 | return new Promise(resolve => { 70 | const reader = new FileReader(); 71 | reader.onload = data => { 72 | resolve(data.target.result); 73 | }; 74 | reader.readAsDataURL(file); 75 | }); 76 | } 77 | export default (props) => { 78 | return () 79 | } 80 | ``` 81 | -------------------------------------------------------------------------------- /src/utils/decorate.ts: -------------------------------------------------------------------------------- 1 | import { repeat } from './tool'; 2 | 3 | interface Decorated { 4 | text: string; 5 | newBlock?: boolean; 6 | selection?: { 7 | start: number; 8 | end: number; 9 | }; 10 | } 11 | 12 | // 最简单的Decorator,即在现有文字的基础上加上前缀、后缀即可 13 | const SIMPLE_DECORATOR: { [x: string]: [string, string] } = { 14 | bold: ['**', '**'], 15 | italic: ['*', '*'], 16 | underline: ['++', '++'], 17 | strikethrough: ['~~', '~~'], 18 | quote: ['\n> ', '\n'], 19 | inlinecode: ['`', '`'], 20 | code: ['\n```\n', '\n```\n'], 21 | }; 22 | // 插入H1-H6 23 | for (let i = 1; i <= 6; i++) { 24 | SIMPLE_DECORATOR[`h${i}`] = [`\n${repeat('#', i)} `, '\n']; 25 | } 26 | 27 | function decorateTableText(option: any) { 28 | const { row = 2, col = 2 } = option; 29 | const rowHeader = ['|']; 30 | const rowData = ['|']; 31 | const rowDivision = ['|']; 32 | let colStr = ''; 33 | for (let i = 1; i <= col; i++) { 34 | rowHeader.push(' Head |'); 35 | rowDivision.push(' --- |'); 36 | rowData.push(' Data |'); 37 | } 38 | for (let j = 1; j <= row; j++) { 39 | colStr += '\n' + rowData.join(''); 40 | } 41 | return `${rowHeader.join('')}\n${rowDivision.join('')}${colStr}`; 42 | } 43 | 44 | function decorateList(type: 'order' | 'unordered', target: string) { 45 | let text = target; 46 | if (text.substr(0, 1) !== '\n') { 47 | text = '\n' + text; 48 | } 49 | if (type === 'unordered') { 50 | return text.length > 1 ? text.replace(/\n/g, '\n* ').trim() : '* '; 51 | } else { 52 | let count = 1; 53 | if (text.length > 1) { 54 | return text 55 | .replace(/\n/g, () => { 56 | return `\n${count++}. `; 57 | }) 58 | .trim(); 59 | } else { 60 | return '1. '; 61 | } 62 | } 63 | } 64 | 65 | function createTextDecorated(text: string, newBlock?: boolean): Decorated { 66 | return { 67 | text, 68 | newBlock, 69 | selection: { 70 | start: text.length, 71 | end: text.length, 72 | }, 73 | }; 74 | } 75 | 76 | /** 77 | * 获取装饰后的Markdown文本 78 | * @param target 原文字 79 | * @param type 装饰类型 80 | * @param option 附加参数 81 | * @returns {Decorated} 82 | */ 83 | function getDecorated(target: string, type: string, option?: any): Decorated { 84 | if (typeof SIMPLE_DECORATOR[type] !== 'undefined') { 85 | return { 86 | text: `${SIMPLE_DECORATOR[type][0]}${target}${SIMPLE_DECORATOR[type][1]}`, 87 | selection: { 88 | start: SIMPLE_DECORATOR[type][0].length, 89 | end: SIMPLE_DECORATOR[type][0].length + target.length, 90 | }, 91 | }; 92 | } 93 | switch (type) { 94 | case 'tab': 95 | const inputValue = option.tabMapValue === 1 ? '\t' : ' '.repeat(option.tabMapValue); 96 | const newSelectedText = inputValue + target.replace(/\n/g, `\n${inputValue}`); 97 | const lineBreakCount = target.includes('\n') ? target.match(/\n/g)!.length : 0; 98 | return { 99 | text: newSelectedText, 100 | selection: { 101 | start: option.tabMapValue, 102 | end: option.tabMapValue * (lineBreakCount + 1) + target.length, 103 | }, 104 | }; 105 | case 'unordered': 106 | return createTextDecorated(decorateList('unordered', target), true); 107 | case 'order': 108 | return createTextDecorated(decorateList('order', target), true); 109 | case 'hr': 110 | return createTextDecorated('---', true); 111 | case 'table': 112 | return { 113 | text: decorateTableText(option), 114 | newBlock: true, 115 | }; 116 | case 'image': 117 | return { 118 | text: `![${target || option.target}](${option.imageUrl || ''})`, 119 | selection: { 120 | start: 2, 121 | end: target.length + 2, 122 | }, 123 | }; 124 | case 'link': 125 | return { 126 | text: `[${target}](${option.linkUrl || ''})`, 127 | selection: { 128 | start: 1, 129 | end: target.length + 1, 130 | }, 131 | }; 132 | } 133 | return { 134 | text: target, 135 | selection: { 136 | start: 0, 137 | end: target.length, 138 | }, 139 | }; 140 | } 141 | 142 | export default getDecorated; 143 | -------------------------------------------------------------------------------- /src/plugins/modeToggle.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Icon from '../components/Icon'; 3 | import i18n from '../i18n'; 4 | import { PluginComponent } from './Plugin'; 5 | 6 | interface ModeToggleState { 7 | view: { 8 | html: boolean; 9 | md: boolean; 10 | }; 11 | } 12 | 13 | enum NEXT_ACTION { 14 | SHOW_ALL, 15 | SHOW_MD, 16 | SHOW_HTML, 17 | } 18 | 19 | class ModeToggle extends PluginComponent { 20 | static pluginName = 'mode-toggle'; 21 | 22 | static align = 'right'; 23 | 24 | private get isDisplay() { 25 | const { canView } = this.editorConfig; 26 | if (canView) { 27 | // 至少有两种情况可以显示的时候,才会显示切换按钮 28 | return [canView.html, canView.md, canView.both].filter((it) => it).length >= 2; 29 | } 30 | return false; 31 | } 32 | 33 | /** 34 | * 显示标准: 35 | * 两个都显示的时候,点击显示MD,隐藏HTML 36 | * 只显示HTML的时候,点击全部显示 37 | * 只显示MD的时候,点击显示HTML,隐藏MD 38 | * 如果当前标准因canView不可用,则顺延至下一个 39 | * 如果都不可用,则返回当前状态 40 | */ 41 | private get next(): NEXT_ACTION { 42 | const { canView } = this.editorConfig; 43 | const { view } = this.state; 44 | 45 | const actions = [NEXT_ACTION.SHOW_ALL, NEXT_ACTION.SHOW_MD, NEXT_ACTION.SHOW_HTML]; 46 | 47 | if (canView) { 48 | if (!canView.both) { 49 | actions.splice(actions.indexOf(NEXT_ACTION.SHOW_ALL), 1); 50 | } 51 | if (!canView.md) { 52 | actions.splice(actions.indexOf(NEXT_ACTION.SHOW_MD), 1); 53 | } 54 | if (!canView.html) { 55 | actions.splice(actions.indexOf(NEXT_ACTION.SHOW_HTML), 1); 56 | } 57 | } 58 | 59 | let current = NEXT_ACTION.SHOW_MD; 60 | if (view.html) { 61 | current = NEXT_ACTION.SHOW_HTML; 62 | } 63 | if (view.html && view.md) { 64 | current = NEXT_ACTION.SHOW_ALL; 65 | } 66 | 67 | if (actions.length === 0) return current; 68 | if (actions.length === 1) return actions[0]; 69 | 70 | const index = actions.indexOf(current); 71 | return index < actions.length - 1 ? actions[index + 1] : actions[0]; 72 | } 73 | 74 | constructor(props: any) { 75 | super(props); 76 | 77 | this.handleClick = this.handleClick.bind(this); 78 | this.handleChange = this.handleChange.bind(this); 79 | 80 | this.state = { 81 | view: this.editor.getView(), 82 | }; 83 | } 84 | 85 | private handleClick() { 86 | switch (this.next) { 87 | case NEXT_ACTION.SHOW_ALL: 88 | this.editor.setView({ 89 | html: true, 90 | md: true, 91 | }); 92 | break; 93 | case NEXT_ACTION.SHOW_HTML: 94 | this.editor.setView({ 95 | html: true, 96 | md: false, 97 | }); 98 | break; 99 | case NEXT_ACTION.SHOW_MD: 100 | this.editor.setView({ 101 | html: false, 102 | md: true, 103 | }); 104 | break; 105 | } 106 | } 107 | 108 | private handleChange(view: { html: boolean; md: boolean }) { 109 | this.setState({ view }); 110 | } 111 | 112 | componentDidMount() { 113 | this.editor.on('viewchange', this.handleChange); 114 | } 115 | 116 | componentWillUnmount() { 117 | this.editor.off('viewchange', this.handleChange); 118 | } 119 | 120 | getDisplayInfo() { 121 | const { next } = this; 122 | switch (next) { 123 | case NEXT_ACTION.SHOW_ALL: 124 | return { 125 | icon: 'view-split', 126 | title: 'All', 127 | }; 128 | case NEXT_ACTION.SHOW_HTML: 129 | return { 130 | icon: 'visibility', 131 | title: 'Preview', 132 | }; 133 | default: 134 | return { 135 | icon: 'keyboard', 136 | title: 'Editor', 137 | }; 138 | } 139 | } 140 | 141 | render() { 142 | if (this.isDisplay) { 143 | const display = this.getDisplayInfo(); 144 | return ( 145 | 150 | 151 | 152 | ); 153 | } 154 | return null; 155 | } 156 | } 157 | 158 | export default ModeToggle; 159 | -------------------------------------------------------------------------------- /src/plugins/logger/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Icon from '../../components/Icon'; 3 | import i18n from '../../i18n'; 4 | import { KeyboardEventListener } from '../../share/var'; 5 | import { PluginComponent } from '../Plugin'; 6 | import LoggerPlugin from './logger'; 7 | 8 | export default class Logger extends PluginComponent { 9 | static pluginName = 'logger'; 10 | 11 | private logger: LoggerPlugin; 12 | 13 | private timerId?: number; 14 | 15 | private handleKeyboards: KeyboardEventListener[] = []; 16 | 17 | private lastPop: string | null = null; 18 | 19 | constructor(props: any) { 20 | super(props); 21 | 22 | this.handleChange = this.handleChange.bind(this); 23 | this.handleRedo = this.handleRedo.bind(this); 24 | this.handleUndo = this.handleUndo.bind(this); 25 | // Mac的Redo比较特殊,是Command+Shift+Z,优先处理 26 | this.handleKeyboards = [ 27 | { key: 'y', keyCode: 89, withKey: ['ctrlKey'], callback: this.handleRedo }, 28 | { key: 'z', keyCode: 90, withKey: ['metaKey', 'shiftKey'], callback: this.handleRedo }, 29 | { key: 'z', keyCode: 90, aliasCommand: true, withKey: ['ctrlKey'], callback: this.handleUndo }, 30 | ]; 31 | 32 | this.logger = new LoggerPlugin({ 33 | maxSize: this.editorConfig.loggerMaxSize, 34 | }); 35 | // 注册API 36 | this.editor.registerPluginApi('undo', this.handleUndo); 37 | this.editor.registerPluginApi('redo', this.handleRedo); 38 | } 39 | 40 | private handleUndo() { 41 | const last = this.logger.undo(this.editor.getMdValue()); 42 | if (typeof last !== 'undefined') { 43 | this.pause(); 44 | this.lastPop = last; 45 | this.editor.setText(last); 46 | this.forceUpdate(); 47 | } 48 | } 49 | 50 | private handleRedo() { 51 | const last = this.logger.redo(); 52 | if (typeof last !== 'undefined') { 53 | this.lastPop = last; 54 | this.editor.setText(last); 55 | this.forceUpdate(); 56 | } 57 | } 58 | 59 | handleChange(value: string, e: any, isNotInput: boolean) { 60 | if (this.logger.getLast() === value || (this.lastPop !== null && this.lastPop === value)) { 61 | return; 62 | } 63 | this.logger.cleanRedo(); 64 | if (isNotInput) { 65 | // from setText API call, not a input 66 | this.logger.push(value); 67 | this.lastPop = null; 68 | this.forceUpdate(); 69 | return; 70 | } 71 | if (this.timerId) { 72 | window.clearTimeout(this.timerId); 73 | this.timerId = 0; 74 | } 75 | this.timerId = window.setTimeout(() => { 76 | if (this.logger.getLast() !== value) { 77 | this.logger.push(value); 78 | this.lastPop = null; 79 | this.forceUpdate(); 80 | } 81 | window.clearTimeout(this.timerId); 82 | this.timerId = 0; 83 | }, this.editorConfig.loggerInterval); 84 | } 85 | 86 | componentDidMount() { 87 | // 监听变化事件 88 | this.editor.on('change', this.handleChange); 89 | // 监听键盘事件 90 | this.handleKeyboards.forEach((it) => this.editor.onKeyboard(it)); 91 | // 初始化时,把已有值填充进logger 92 | this.logger.initValue = this.editor.getMdValue(); 93 | this.forceUpdate(); 94 | } 95 | 96 | componentWillUnmount() { 97 | if (this.timerId) { 98 | window.clearTimeout(this.timerId); 99 | } 100 | this.editor.off('change', this.handleChange); 101 | this.editor.unregisterPluginApi('undo'); 102 | this.editor.unregisterPluginApi('redo'); 103 | this.handleKeyboards.forEach((it) => this.editor.offKeyboard(it)); 104 | } 105 | 106 | pause() { 107 | if (this.timerId) { 108 | window.clearTimeout(this.timerId); 109 | this.timerId = undefined; 110 | } 111 | } 112 | 113 | render() { 114 | const hasUndo = this.logger.getUndoCount() > 1 || this.logger.initValue !== this.editor.getMdValue(); 115 | const hasRedo = this.logger.getRedoCount() > 0; 116 | return ( 117 | <> 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | ); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/editor/index.less: -------------------------------------------------------------------------------- 1 | @import '../variable.less'; 2 | 3 | @{prefix} { 4 | padding-bottom: 1px; 5 | position: relative; 6 | border: 1px solid #e0e0e0; 7 | background: #fff; 8 | box-sizing: border-box; 9 | display: flex; 10 | flex-direction: column; 11 | &.full { 12 | width: 100%; 13 | height: 100% !important; 14 | position: fixed; 15 | left: 0px; 16 | top: 0px; 17 | z-index: 1000; 18 | } 19 | .editor-container { 20 | flex: 1; 21 | display: flex; 22 | width: 100%; 23 | min-height: 0; 24 | position: relative; 25 | > .section { 26 | flex-grow: 1; 27 | flex-shrink: 1; 28 | flex-basis: 1px; 29 | border-right: 1px solid #e0e0e0; 30 | 31 | &.in-visible { 32 | display: none; 33 | } 34 | 35 | > .section-container { 36 | padding: 15px; 37 | padding-top: 10px; 38 | } 39 | 40 | &:last-child { 41 | border-radius: none; 42 | } 43 | } 44 | .sec-md { 45 | min-height: 0; 46 | min-width: 0; 47 | .input { 48 | display: block; 49 | box-sizing: border-box; 50 | width: 100%; 51 | height: 100%; 52 | overflow-y: scroll; 53 | border: none; 54 | resize: none; 55 | outline: none; 56 | min-height: 0; // background: rgb(248, 248, 248); 57 | background: #fff; 58 | color: #333; 59 | font-size: 14px; 60 | line-height: 1.7; 61 | } 62 | } 63 | .sec-html { 64 | min-height: 0; 65 | min-width: 0; 66 | .html-wrap { 67 | height: 100%; 68 | box-sizing: border-box; 69 | overflow: auto; 70 | } 71 | } 72 | } 73 | } 74 | 75 | // 自定义htmL样式 76 | .custom-html-style { 77 | color: #333; 78 | h1 { 79 | font-size: 32px; 80 | padding: 0px; 81 | border: none; 82 | font-weight: 700; 83 | margin: 32px 0; 84 | line-height: 1.2; 85 | } 86 | h2 { 87 | font-size: 24px; 88 | padding: 0px 0; 89 | border: none; 90 | font-weight: 700; 91 | margin: 24px 0; 92 | line-height: 1.7; 93 | } 94 | h3 { 95 | font-size: 18px; 96 | margin: 18px 0; 97 | padding: 0px 0; 98 | line-height: 1.7; 99 | border: none; 100 | } 101 | p { 102 | font-size: 14px; 103 | line-height: 1.7; 104 | margin: 8px 0; 105 | } 106 | a { 107 | color: #0052d9 108 | } 109 | a:hover { 110 | text-decoration: none 111 | } 112 | strong { 113 | font-weight: 700 114 | } 115 | ol, 116 | ul { 117 | font-size: 14px; 118 | line-height: 28px; 119 | padding-left: 36px 120 | } 121 | li { 122 | margin-bottom: 8px; 123 | line-height: 1.7; 124 | } 125 | hr { 126 | margin-top: 20px; 127 | margin-bottom: 20px; 128 | border: 0; 129 | border-top: 1px solid #eee; 130 | } 131 | pre { 132 | display: block; 133 | background-color: #f5f5f5; 134 | padding: 20px; 135 | font-size: 14px; 136 | line-height: 28px; 137 | border-radius: 0; 138 | overflow-x: auto; 139 | word-break: break-word; 140 | } 141 | code { 142 | background-color: #f5f5f5; 143 | border-radius: 0; 144 | padding: 3px 0; 145 | margin: 0; 146 | font-size: 14px; 147 | overflow-x: auto; 148 | word-break: normal; 149 | } 150 | code:after, 151 | code:before { 152 | letter-spacing: 0 153 | } 154 | blockquote { 155 | position: relative; 156 | margin: 16px 0; 157 | padding: 5px 8px 5px 30px; 158 | background: none repeat scroll 0 0 rgba(102, 128, 153, .05); 159 | border: none; 160 | color: #333; 161 | border-left: 10px solid #D6DBDF; 162 | } 163 | img, 164 | video { 165 | max-width: 100%; // max-height: 668px; 166 | } 167 | table { 168 | font-size: 14px; 169 | line-height: 1.7; 170 | max-width: 100%; 171 | overflow: auto; 172 | border: 1px solid #f6f6f6; 173 | border-collapse: collapse; 174 | border-spacing: 0; 175 | box-sizing: border-box; 176 | } 177 | table td, 178 | table th { 179 | word-break: break-all; 180 | word-wrap: break-word; 181 | white-space: normal 182 | } 183 | table tr { 184 | border: 1px solid #efefef 185 | } 186 | table tr:nth-child(2n) { 187 | background-color: transparent 188 | } // table td, table th { 189 | // min-width: 80px; 190 | // max-width: 430px 191 | // } 192 | table th { 193 | text-align: center; 194 | font-weight: 700; 195 | border: 1px solid #efefef; 196 | padding: 10px 6px; 197 | background-color: #f5f7fa; 198 | word-break: break-word; 199 | } 200 | table td { 201 | border: 1px solid #efefef; 202 | text-align: left; 203 | padding: 10px 15px; 204 | word-break: break-word; 205 | min-width: 60px; 206 | } 207 | } -------------------------------------------------------------------------------- /demo/basic.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Simple Usage 3 | order: 1 4 | --- 5 | 6 | 本 Demo 演示基本用法。 7 | 8 | ```jsx 9 | import React, { Component } from 'react'; 10 | import ReactDOM from 'react-dom'; 11 | import ReactMarkdown from 'react-markdown'; 12 | import MdEditor, { Plugins } from 'react-markdown-editor-lite'; 13 | 14 | const PLUGINS = undefined; 15 | // const PLUGINS = ['header', 'divider', 'image', 'divider', 'font-bold', 'full-screen']; 16 | 17 | // MdEditor.use(Plugins.AutoResize, { 18 | // min: 200, 19 | // max: 800 20 | // }); 21 | 22 | MdEditor.use(Plugins.TabInsert, { 23 | tabMapValue: 1, // note that 1 means a '\t' instead of ' '. 24 | }); 25 | 26 | class Demo extends React.Component { 27 | mdEditor = undefined; 28 | 29 | constructor(props) { 30 | super(props); 31 | this.renderHTML = this.renderHTML.bind(this); 32 | this.state = { 33 | value: "# Hello", 34 | }; 35 | } 36 | 37 | handleEditorChange = (it, event) => { 38 | // console.log('handleEditorChange', it.text, it.html, event); 39 | this.setState({ 40 | value: it.text, 41 | }); 42 | }; 43 | 44 | handleImageUpload = (file) => { 45 | return new Promise(resolve => { 46 | const reader = new FileReader(); 47 | reader.onload = data => { 48 | resolve(data.target.result); 49 | }; 50 | reader.readAsDataURL(file); 51 | }); 52 | }; 53 | 54 | onCustomImageUpload = (event) => { 55 | console.log('onCustomImageUpload', event); 56 | return new Promise((resolve, reject) => { 57 | const result = window.prompt('Please enter image url here...'); 58 | resolve({ url: result }); 59 | // custom confirm message pseudo code 60 | // YourCustomDialog.open(() => { 61 | // setTimeout(() => { 62 | // // setTimeout 模拟oss异步上传图片 63 | // // 当oss异步上传获取图片地址后,执行calback回调(参数为imageUrl字符串),即可将图片地址写入markdown 64 | // const url = 'https://avatars0.githubusercontent.com/u/21263805?s=80&v=4' 65 | // resolve({url: url, name: 'pic'}) 66 | // }, 1000) 67 | // }) 68 | }); 69 | }; 70 | 71 | handleGetMdValue = () => { 72 | if (this.mdEditor) { 73 | alert(this.mdEditor.getMdValue()); 74 | } 75 | }; 76 | 77 | handleGetHtmlValue = () => { 78 | if (this.mdEditor) { 79 | alert(this.mdEditor.getHtmlValue()); 80 | } 81 | }; 82 | 83 | handleSetValue = () => { 84 | const text = window.prompt('Content'); 85 | this.setState({ 86 | value: text, 87 | }); 88 | }; 89 | 90 | renderHTML(text) { 91 | return React.createElement(ReactMarkdown, { 92 | source: text, 93 | }); 94 | } 95 | 96 | render() { 97 | return ( 98 |
99 |

react-markdown-editor-lite demo

100 | 105 |
106 | (this.mdEditor = node || undefined)} 108 | value={this.state.value} 109 | style={{ height: '500px', width: '100%' }} 110 | renderHTML={this.renderHTML} 111 | plugins={PLUGINS} 112 | config={{ 113 | view: { 114 | menu: true, 115 | md: true, 116 | html: true, 117 | fullScreen: true, 118 | hideMenu: true, 119 | }, 120 | table: { 121 | maxRow: 5, 122 | maxCol: 6, 123 | }, 124 | imageUrl: 'https://octodex.github.com/images/minion.png', 125 | syncScrollMode: ['leftFollowRight', 'rightFollowLeft'], 126 | }} 127 | onChange={this.handleEditorChange} 128 | onImageUpload={this.handleImageUpload} 129 | onFocus={e => console.log('focus', e)} 130 | onBlur={e => console.log('blur', e)} 131 | // onCustomImageUpload={this.onCustomImageUpload} 132 | /> 133 | 137 |
138 | {/*
139 | 152 |
*/} 153 |
154 | ); 155 | } 156 | } 157 | 158 | ReactDOM.render(( 159 | 160 | ), mountNode); 161 | ``` 162 | -------------------------------------------------------------------------------- /docs/plugin.zh-CN.md: -------------------------------------------------------------------------------- 1 | # 插件 2 | [English documentation see here](./plugin.md) 3 | ## 插件可以干什么? 4 | 插件可以往工具栏添加按钮,并操作编辑器的内容。 5 | ## 使用和卸载插件 6 | 参见[API文档](./api.zh-CN.md) 7 | ## 内置插件 8 | ### 插件列表 9 | 内置以下插件: 10 | * header:标题 11 | * font-bold:加粗 12 | * font-italic:斜体 13 | * font-underline:下划线 14 | * font-strikethrough:删除线 15 | * list-unordered:无序列表 16 | * list-ordered:有序列表 17 | * block-quote:引用 18 | * block-wrap:换行 19 | * block-code-inline:行内代码 20 | * block-code-block:块状代码 21 | * table:表格 22 | * image:图片上传 23 | * link:超链接 24 | * clear:清空内容 25 | * logger:历史记录(撤销、重做) 26 | * mode-toggle:显示模式切换 27 | * full-screen:全屏模式切换 28 | * auto-resize:编辑器自动调整尺寸插件(默认不启用) 29 | * tab-insert:插入制表符或空格(默认不启用) 30 | ```js 31 | [ 32 | 'header', 33 | 'font-bold', 34 | 'font-italic', 35 | 'font-underline', 36 | 'font-strikethrough', 37 | 'list-unordered', 38 | 'list-ordered', 39 | 'block-quote', 40 | 'block-wrap', 41 | 'block-code-inline', 42 | 'block-code-block', 43 | 'table', 44 | 'image', 45 | 'link', 46 | 'clear', 47 | 'logger', 48 | 'mode-toggle', 49 | 'full-screen', 50 | 'tab-insert' 51 | ] 52 | ``` 53 | 54 | * 如果启用了`logger`插件,则会自动注册`undo`和`redo`两个API,可通过`callPluginApi`调用。 55 | 56 | ### 卸载内置插件 57 | ```js 58 | import Editor, { Plugins } from 'react-markdown-editor-lite'; 59 | 60 | Editor.unuse(Plugins.Header); // header 61 | Editor.unuse(Plugins.FontBold); // font-bold 62 | ``` 63 | ### 使用自动调整尺寸插件 64 | ```js 65 | import Editor, { Plugins } from 'react-markdown-editor-lite'; 66 | 67 | Editor.use(Plugins.AutoResize, { 68 | min: 200, // 最小高度 69 | max: 600, // 最大高度 70 | }); 71 | ``` 72 | ### 使用 tab 输入插件 73 | 在默认情况下,用户在 Markdown 编辑区按下 Tab 键时会失去输入焦点,可以使用内置的 Tab 输入插件来解决这个问题。 74 | ```js 75 | import Editor, { Plugins } from 'react-markdown-editor-lite'; 76 | 77 | Editor.use(Plugins.TabInsert, { 78 | /** 79 | * 用户按下 Tab 键时输入的空格的数目 80 | * 特别地,1 代表输入一个'\t',而不是一个空格 81 | * 默认值是 1 82 | */ 83 | tabMapValue: 1, 84 | }); 85 | ``` 86 | ### 插入分隔线 87 | 88 | `divider` 是一个特殊的插件,你不能卸载它,但你也不需要手动添加它。如果你想在工具栏上插入一个分隔符,将 `divider` 添加到 `plugins` 数组中即可。 89 | 90 | ```js 91 | import Editor, { Plugins } from 'react-markdown-editor-lite'; 92 | 93 | const plugins = ['header', 'table', 'divider', 'link', 'clear', 'divider', 'font-bold']; 94 | 95 | 96 | ``` 97 | ## Demo 98 | ```js 99 | import Editor, { Plugins } from 'react-markdown-editor-lite'; 100 | import MyPlugin from './MyPlugin'; 101 | 102 | Editor.use(MyPlugin); 103 | 104 | // 卸载掉所有编辑器的Header插件 105 | Editor.unuse(Plugins.Header); 106 | 107 | // 这里去掉了内置的image插件,仅单个编辑器生效 108 | const plugins = ['header', 'table', 'my-plugins', 'link', 'clear', 'logger', 'mode-toggle', 'full-screen']; 109 | 110 | ``` 111 | ## 带自定义插件的NextJS Demo 112 | ```js 113 | import dynamic from "next/dynamic"; 114 | import ReactMarkdown from "react-markdown"; 115 | import "react-markdown-editor-lite/lib/index.css"; 116 | 117 | const MdEditor = dynamic( 118 | () => { 119 | return new Promise((resolve) => { 120 | Promise.all([ 121 | import("react-markdown-editor-lite"), 122 | import("./plugin") 123 | ]).then((res) => { 124 | res[0].default.use(res[1].default); 125 | resolve(res[0].default); 126 | }); 127 | }); 128 | }, 129 | { 130 | ssr: false 131 | } 132 | ); 133 | ``` 134 | ## 编写插件 135 | ### Demo 136 | [在线查看](https://codesandbox.io/s/rmel-demo-write-plugin-p82fc) 137 | ### 普通方式 138 | 插件本身是一个React Component,需要继承自PluginComponent。 139 | 140 | 在PluginComponent中,可以: 141 | * 通过`this.editor`获取编辑器实例,调用所有编辑器API。 142 | * 通过`this.editorConfig`获取编辑器的设置。 143 | * 通过`this.getConfig`或`this.props.config`获取use时传入的数据。 144 | 145 | 下面,我们编写一个计数器,每次点击均往编辑器中插入一个递增的数字。起始数字从use时传入的选项读取。 146 | ```js 147 | import { PluginComponent } from 'react-markdown-editor-lite'; 148 | 149 | interface CounterState { 150 | num: number; 151 | } 152 | 153 | class Counter extends PluginComponent { 154 | // 这里定义插件名称,注意不能重复 155 | static pluginName = 'counter'; 156 | // 定义按钮被放置在哪个位置,默认为左侧,还可以放置在右侧(right) 157 | static align = 'left'; 158 | // 如果需要的话,可以在这里定义默认选项 159 | static defaultConfig = { 160 | start: 0 161 | } 162 | 163 | constructor(props: any) { 164 | super(props); 165 | 166 | this.handleClick = this.handleClick.bind(this); 167 | 168 | this.state = { 169 | num: this.getConfig('start') 170 | }; 171 | } 172 | 173 | handleClick() { 174 | // 调用API,往编辑器中插入一个数字 175 | this.editor.insertText(this.state.num); 176 | // 更新一下自身的state 177 | this.setState({ 178 | num: this.state.num + 1 179 | }); 180 | } 181 | 182 | render() { 183 | return ( 184 | 189 | {this.state.num} 190 | 191 | ); 192 | } 193 | } 194 | 195 | 196 | // 使用: 197 | Editor.use(Counter, { 198 | start: 10 199 | }); 200 | ``` 201 | ### 函数组件 202 | 同样可以使用函数组件来编写插件 203 | ```js 204 | import React from 'react'; 205 | import { PluginProps } from 'react-markdown-editor-lite'; 206 | 207 | interface CounterState { 208 | num: number; 209 | } 210 | 211 | const Counter = (props: PluginProps) => { 212 | const [num, setNum] = React.useState(props.config.start); 213 | 214 | const handleClick = () => { 215 | // 调用API,往编辑器中插入一个数字 216 | props.editor.insertText(num); 217 | // 更新一下自身的state 218 | setNum(num + 1); 219 | } 220 | 221 | return ( 222 | 227 | {num} 228 | 229 | ); 230 | } 231 | // 如果需要的话,可以在这里定义默认选项 232 | Counter.defaultConfig = { 233 | start: 0 234 | } 235 | Counter.align = 'left'; 236 | Counter.pluginName = 'counter'; 237 | 238 | // 使用: 239 | Editor.use(Counter, { 240 | start: 10 241 | }); 242 | ``` 243 | 244 | ## 是否可以不渲染任何UI? 245 | 可以,`render`函数返回一个空元素即可,例如返回`` 246 | -------------------------------------------------------------------------------- /test/api.spec.tsx: -------------------------------------------------------------------------------- 1 | import { cleanup, fireEvent, render, screen } from '@testing-library/react'; 2 | import { expect } from 'chai'; 3 | import * as React from 'react'; 4 | import Editor from '../src'; 5 | 6 | const TextComponent = (props: { onClick: (ref: Editor) => void; value?: string }) => { 7 | const { value, onClick } = props; 8 | const ref = React.useRef(null); 9 | 10 | return ( 11 |
12 | 15 | 16 | text} defaultValue={value || '123456'} /> 17 |
18 | ); 19 | }; 20 | 21 | const doClick = ( 22 | onClick: (ref: Editor) => void, 23 | options: { 24 | value?: string; 25 | start?: number; 26 | end?: number; 27 | } = {}, 28 | ) => { 29 | const handler = render(); 30 | 31 | const textarea = handler.queryByLabelText('My Editor') as HTMLTextAreaElement; 32 | 33 | if (!textarea) { 34 | throw new Error('Not found textarea'); 35 | } 36 | 37 | textarea.setSelectionRange( 38 | typeof options.start === 'undefined' ? 1 : options.start, 39 | typeof options.end === 'undefined' ? 3 : options.end, 40 | 'forward', 41 | ); 42 | 43 | const btn = handler.container.querySelector('#click_handler'); 44 | if (btn) { 45 | const event = new MouseEvent('click', { 46 | bubbles: true, 47 | cancelable: true, 48 | }); 49 | fireEvent(btn, event); 50 | } 51 | 52 | return { 53 | ...handler, 54 | textarea, 55 | }; 56 | }; 57 | 58 | const next = (cb: any, time = 10) => { 59 | return new Promise(resolve => { 60 | setTimeout(() => { 61 | cb(); 62 | resolve(); 63 | }, time); 64 | }); 65 | }; 66 | 67 | describe('Test API', function() { 68 | // getSelection 69 | it('getSelection', function() { 70 | let selected = ''; 71 | const handleClick = (editor: Editor) => { 72 | selected = editor.getSelection().text; 73 | }; 74 | doClick(handleClick); 75 | expect(selected).to.equals('23'); 76 | }); 77 | 78 | // setText with newSelection 79 | it('setText', function() { 80 | let selected = ''; 81 | const handleClick = (editor: Editor) => { 82 | editor.setText('abcdefg', undefined, { 83 | start: 0, 84 | end: 2, 85 | }); 86 | 87 | setTimeout(() => (selected = editor.getSelection().text)); 88 | }; 89 | const { textarea } = doClick(handleClick); 90 | expect(textarea.value).to.equals('abcdefg'); 91 | return next(() => expect(selected).to.equals('ab')); 92 | }); 93 | 94 | // insertText 95 | it('insertText 1', function() { 96 | let selected = ''; 97 | const handleClick = (editor: Editor) => { 98 | editor.insertText('xx', true); 99 | setTimeout(() => (selected = editor.getSelection().text)); 100 | }; 101 | const { textarea } = doClick(handleClick); 102 | expect(textarea.value).to.equals('1xx456'); 103 | return next(() => expect(selected).to.equals('')); 104 | }); 105 | // insertText 106 | it('insertText 2', function() { 107 | let selected = ''; 108 | const handleClick = (editor: Editor) => { 109 | editor.insertText('xx', false, { 110 | start: 0, 111 | end: 1, 112 | }); 113 | setTimeout(() => (selected = editor.getSelection().text)); 114 | }; 115 | const { textarea } = doClick(handleClick); 116 | expect(textarea.value).to.equals('1xx23456'); 117 | return next(() => expect(selected).to.equals('x')); 118 | }); 119 | 120 | // insertMarkdown 121 | it('insertMarkdown bold', function() { 122 | let selected = ''; 123 | const handleClick = (editor: Editor) => { 124 | editor.insertMarkdown('bold'); 125 | setTimeout(() => (selected = editor.getSelection().text)); 126 | }; 127 | const { textarea } = doClick(handleClick); 128 | expect(textarea.value).to.equals('1**23**456'); 129 | return next(() => expect(selected).to.equals('23')); 130 | }); 131 | 132 | // insertMarkdown 133 | it('insertMarkdown unordered', function() { 134 | let selected = ''; 135 | const handleClick = (editor: Editor) => { 136 | editor.insertMarkdown('unordered'); 137 | setTimeout(() => (selected = editor.getSelection().text)); 138 | }; 139 | const { textarea } = doClick(handleClick, { 140 | value: '123\n234\n345\n456', 141 | start: 2, 142 | end: 10, 143 | }); 144 | expect(textarea.value).to.equals('12\n* 3\n* 234\n* 34\n\n5\n456'); 145 | return next(() => expect(selected).to.equals('')); 146 | }); 147 | 148 | // insertMarkdown 149 | it('insertMarkdown table', function() { 150 | let selected = ''; 151 | const handleClick = (editor: Editor) => { 152 | editor.insertMarkdown('table', { 153 | row: 2, 154 | col: 4, 155 | }); 156 | setTimeout(() => (selected = editor.getSelection().text)); 157 | }; 158 | const { textarea } = doClick(handleClick); 159 | const expectTable = 160 | '| Head | Head | Head | Head |\n| --- | --- | --- | --- |\n| Data | Data | Data | Data |\n| Data | Data | Data | Data |'; 161 | expect(textarea.value).to.equals('1\n' + expectTable + '\n\n456'); 162 | return next(() => expect(selected).to.equals('')); 163 | }); 164 | 165 | // insertPlaceholder 166 | it('insertPlaceholder', function() { 167 | const handleClick = (editor: Editor) => { 168 | editor.insertPlaceholder( 169 | '_placeholder_', 170 | new Promise(resolve => { 171 | setTimeout(() => { 172 | resolve('_resolved_'); 173 | }, 5); 174 | }), 175 | ); 176 | }; 177 | const { textarea } = doClick(handleClick); 178 | expect(textarea.value).to.equals('1_placeholder_456'); 179 | return next(() => expect(textarea.value).to.equals('1_resolved_456')); 180 | }); 181 | 182 | afterEach(cleanup); 183 | }); 184 | -------------------------------------------------------------------------------- /README_CN.md: -------------------------------------------------------------------------------- 1 | # react-markdown-editor-lite 2 | 3 | [![NPM package][npm-version-image]][npm-url] 4 | [![NPM downloads][npm-downloads-image]][npm-url] 5 | [![MIT License][license-image]][license-url] 6 | [![Workflow][workflow-image]][workflow-url] 7 | 8 | [English Docs](README.md) 9 | 10 | - A light-weight(20KB zipped) Markdown editor of React component 11 | - Supports TypeScript 12 | - Supports custom markdown parser 13 | - Full markdown support 14 | - Supports pluggable function bars 15 | - Full control over UI 16 | - Supports image uploading and dragging 17 | - Supports synced scrolling between editor and preview 18 | - 一款轻量的基于 React 的 Markdown 编辑器, 压缩后代码只有 20KB 19 | - 支持 TypeScript 20 | - 支持自定义 Markdown 解析器 21 | - 支持常用的 Markdown 编辑功能,如加粗,斜体等等... 22 | - 支持插件化的功能键 23 | - 界面可配置, 如只显示编辑区或预览区 24 | - 支持图片上传或拖拽 25 | - 支持编辑区和预览区同步滚动 26 | 27 | ## 案例 28 | 29 | 在线案例
[https://harrychen0506.github.io/react-markdown-editor-lite/](https://harrychen0506.github.io/react-markdown-editor-lite/) 30 | 31 | 默认配置 32 | 33 | ![image](https://github.com//HarryChen0506/react-markdown-editor-lite/blob/master/image/react-markdown-editor-lite-v1.0.0.PNG?raw=true) 34 | 35 | 可插拔的功能键 36 | 37 | ![image](https://github.com//HarryChen0506/react-markdown-editor-lite/blob/master/image/react-markdown-editor-lite-v1.0.0-plugins.PNG?raw=true) 38 | 39 | ## 安装 40 | 41 | ```shell 42 | npm install react-markdown-editor-lite --save 43 | # or 44 | yarn add react-markdown-editor-lite 45 | ``` 46 | 47 | ## 基本使用 48 | 49 | 基本使用分为以下几步: 50 | 51 | - 导入 react-markdown-editor-lite 52 | - 注册插件(如果需要) 53 | - 初始化任意 Markdown 解析器,例如 markdown-it 54 | - 开始使用 55 | 56 | ```js 57 | // 导入React、react-markdown-editor-lite,以及一个你喜欢的Markdown渲染器 58 | import React from 'react'; 59 | import MarkdownIt from 'markdown-it'; 60 | import MdEditor from 'react-markdown-editor-lite'; 61 | // 导入编辑器的样式 62 | import 'react-markdown-editor-lite/lib/index.css'; 63 | 64 | // 注册插件(如果有的话) 65 | // MdEditor.use(YOUR_PLUGINS_HERE); 66 | 67 | // 初始化Markdown解析器 68 | const mdParser = new MarkdownIt(/* Markdown-it options */); 69 | 70 | // 完成! 71 | function handleEditorChange({ html, text }) { 72 | console.log('handleEditorChange', html, text); 73 | } 74 | export default props => { 75 | return ( 76 | mdParser.render(text)} onChange={handleEditorChange} /> 77 | ); 78 | }; 79 | ``` 80 | 81 | - 更多参数和配置:点击[这里](./docs/configure.zh-CN.md)查看 82 | - API:点击[这里](./docs/api.zh-CN.md)查看 83 | - 插件开发:点击[这里](./docs/plugin.zh-CN.md)查看 84 | - 完整 Demo 见[src/demo/index.tsx](https://github.com/HarryChen0506/react-markdown-editor-lite/blob/master/src/demo/index.tsx) 85 | 86 | ## 在 SSR(服务端渲染)中使用 87 | 88 | 如果你在使用一个服务端渲染框架,例如 Next.js、Gatsby 等,请对编辑器使用客户端渲染。 89 | 90 | 例如,Next.js 有[next/dynamic](https://nextjs.org/docs/advanced-features/dynamic-import),Gatsby 有[loadable-components](https://www.gatsbyjs.org/docs/using-client-side-only-packages/#workaround-3-load-client-side-dependent-components-with-loadable-components) 91 | 92 | 下面是 Next.js 的使用范例: 93 | 94 | ```js 95 | import dynamic from 'next/dynamic'; 96 | import 'react-markdown-editor-lite/lib/index.css'; 97 | 98 | const MdEditor = dynamic(() => import('react-markdown-editor-lite'), { 99 | ssr: false, 100 | }); 101 | 102 | export default function() { 103 | return ; 104 | } 105 | ``` 106 | 107 | 与插件一起使用: 108 | 109 | ```js 110 | import dynamic from 'next/dynamic'; 111 | import 'react-markdown-editor-lite/lib/index.css'; 112 | 113 | const MdEditor = dynamic( 114 | () => { 115 | return new Promise(resolve => { 116 | Promise.all([ 117 | import('react-markdown-editor-lite'), 118 | import('./my-plugin'), 119 | /** 按照这样加载更多插件,并在下方 use */ 120 | ]).then(res => { 121 | res[0].default.use(res[1].default); 122 | resolve(res[0].default); 123 | }); 124 | }); 125 | }, 126 | { 127 | ssr: false, 128 | }, 129 | ); 130 | 131 | export default function() { 132 | return ; 133 | } 134 | ``` 135 | 136 | 完整示例[见此](https://codesandbox.io/s/next-js-80bne) 137 | 138 | ## 浏览器引入 139 | 140 | 自 1.1.0 起,你可以在浏览器中使用`script`和`link`标签直接引入文件,并使用全局变量`ReactMarkdownEditorLite`。 141 | 142 | 你可以通过 [![cdnjs][cdnjs-image]][cdnjs-url] [![jsdelivr][jsdelivr-image]][jsdelivr-url] [![unpkg][unpkg-image]][unpkg-url] 进行下载。 143 | 144 | 注意:ReactMarkdownEditorLite(RMEL) 依赖 react,请确保其在 RMEL 之前引入。 145 | 146 | 例如,使用 webpack 时,你可以在页面中通过`script`引入 ReactMarkdownEditorLite 的 JS 文件,并在 webpack 配置中写: 147 | 148 | ```js 149 | externals: { 150 | react: 'React', 151 | 'react-markdown-editor-lite': 'ReactMarkdownEditorLite' 152 | } 153 | ``` 154 | 155 | ## 更多示例 156 | * [基本使用](https://codesandbox.io/s/rmel-demo-ref-in-function-component-u04gb) 157 | * [在unform中使用](https://codesandbox.io/s/rmel-demo-with-unform-qx34y) 158 | * [编写插件](https://codesandbox.io/s/rmel-demo-write-plugin-p82fc) 159 | * [替换默认图标](https://codesandbox.io/s/rmel-demo-replace-icon-pl1n3) 160 | * [在Next.js中使用](https://codesandbox.io/s/next-js-80bne) 161 | 162 | ## 主要作者 163 | 164 | - ShuangYa [github/sylingd](https://github.com/sylingd) 165 | - HarryChen0506 [github/HarryChen0506](https://github.com/HarryChen0506) 166 | 167 | ## License 168 | 169 | [MIT](https://github.com/HarryChen0506/react-markdown-editor-lite/blob/master/LICENSE) 170 | 171 | [npm-version-image]: https://img.shields.io/npm/v/react-markdown-editor-lite.svg 172 | [npm-downloads-image]: https://img.shields.io/npm/dm/react-markdown-editor-lite.svg?style=flat 173 | [npm-url]: https://www.npmjs.com/package/react-markdown-editor-lite 174 | [workflow-image]: https://img.shields.io/github/workflow/status/HarryChen0506/react-markdown-editor-lite/main 175 | [workflow-url]: https://github.com/HarryChen0506/react-markdown-editor-lite/actions?query=workflow%3Amain 176 | [license-image]: https://img.shields.io/badge/license-MIT-blue.svg?style=flat 177 | [license-url]: LICENSE 178 | [jsdelivr-image]: https://img.shields.io/jsdelivr/npm/hm/react-markdown-editor-lite 179 | [jsdelivr-url]: https://www.jsdelivr.com/package/npm/react-markdown-editor-lite?path=lib 180 | [cdnjs-image]: https://img.shields.io/cdnjs/v/react-markdown-editor-lite?style=flat 181 | [cdnjs-url]: https://cdnjs.com/libraries/react-markdown-editor-lite 182 | [unpkg-image]: https://img.shields.io/npm/v/react-markdown-editor-lite?label=unpkg&style=flat 183 | [unpkg-url]: https://unpkg.com/browse/react-markdown-editor-lite/lib/ 184 | -------------------------------------------------------------------------------- /docs/plugin.md: -------------------------------------------------------------------------------- 1 | # Plugins 2 | [中文文档见此](./plugin.zh-CN.md) 3 | ## What can plugins do? 4 | Plugins can insert buttons into menu bar, and control editor's content. 5 | ## Use or un-use a plugin 6 | See [API documentation](./api.md) 7 | ## Built-in plugins 8 | ### Plugins list 9 | Those plugins are built-in plugin: 10 | * header: title 11 | * font-bold: bold 12 | * font-italic: italic 13 | * font-underline: underline 14 | * font-strikethrough: strikethrough 15 | * list-unordered: unordered 16 | * list-ordered: ordered 17 | * block-quote: quote 18 | * block-wrap: wrap new line 19 | * block-code-inline: inline code 20 | * block-code-block: block code 21 | * table: table 22 | * image: image upload 23 | * link: hyperlinks 24 | * clear: clear texts 25 | * logger: history (undo/redo) 26 | * mode-toggle: toggle view mode 27 | * full-screen: toggle full screen 28 | * auto-resize: auto-resize plugin (disabled by default) 29 | * tab-insert: insert tab or spaces (disabled by default) 30 | ```js 31 | [ 32 | 'header', 33 | 'font-bold', 34 | 'font-italic', 35 | 'font-underline', 36 | 'font-strikethrough', 37 | 'list-unordered', 38 | 'list-ordered', 39 | 'block-quote', 40 | 'block-wrap', 41 | 'block-code-inline', 42 | 'block-code-block', 43 | 'table', 44 | 'image', 45 | 'link', 46 | 'clear', 47 | 'logger', 48 | 'mode-toggle', 49 | 'full-screen', 50 | 'tab-insert' 51 | ] 52 | ``` 53 | 54 | * If you enabled `logger` plugin, it will auto register `undo` and `redo` API, you can use them with `callPluginApi`. 55 | 56 | ### Un-use a built-in plugin 57 | ```js 58 | import Editor, { Plugins } from 'react-markdown-editor-lite'; 59 | 60 | Editor.unuse(Plugins.Header); // header 61 | Editor.unuse(Plugins.FontBold); // font-bold 62 | ``` 63 | ### Use auto-resize plugin 64 | ```js 65 | import Editor, { Plugins } from 'react-markdown-editor-lite'; 66 | 67 | Editor.use(Plugins.AutoResize, { 68 | min: 200, // min height 69 | max: 600, // max height 70 | }); 71 | ``` 72 | ### Use tab-insert plugin 73 | By default, Markdown Editor will lose input focus when user type a Tab key. You can use the built-in tab-insert plugin to solve this problem. 74 | ```js 75 | import Editor, { Plugins } from 'react-markdown-editor-lite'; 76 | 77 | Editor.use(Plugins.TabInsert, { 78 | /** 79 | * Number of spaces will be inputted when user type a Tab key. 80 | * Especially, note that 1 means a '\t' instead of ' '. 81 | * Default value is 1. 82 | */ 83 | tabMapValue: 1, 84 | }); 85 | ``` 86 | ### Insert dividers 87 | 88 | `divider` is a special plugin, you can not un-use it, and you also shouldn't use it. If you want to insert a divider into toolbar, just put `divider` into the `plugins` array. 89 | 90 | ```js 91 | import Editor, { Plugins } from 'react-markdown-editor-lite'; 92 | 93 | const plugins = ['header', 'table', 'divider', 'link', 'clear', 'divider', 'font-bold']; 94 | 95 | 96 | ``` 97 | 98 | ## Demo 99 | ```js 100 | import Editor, { Plugins } from 'react-markdown-editor-lite'; 101 | import MyPlugin from './MyPlugin'; 102 | 103 | Editor.use(MyPlugin); 104 | 105 | // Remove built-in header plugin here, in all editors 106 | Editor.unuse(Plugins.Header); 107 | 108 | // Remove built-in image plugin here, only this editor 109 | const plugins = ['header', 'table', 'my-plugins', 'link', 'clear', 'logger', 'mode-toggle', 'full-screen']; 110 | 111 | ``` 112 | 113 | ## Written a plugin 114 | ### Demos 115 | * [Demo](https://codesandbox.io/s/rmel-demo-write-plugin-p82fc) 116 | * [SSR Demo](https://codesandbox.io/s/next-js-80bne) 117 | ### Normal 118 | Plugin is a React Component, and must extend PluginComponent. 119 | 120 | In PluginComponent, you can: 121 | * Get editor instance by `this.editor`, and call all editor's APIs. 122 | * Get editor's config by `this.editorConfig`. 123 | * Get the options passed in use by `this.getConfig` or `this.props.config`. 124 | 125 | In following, we written a counter, insert an increasing number into the editor with each click. The starting number is read from the options passed in use. 126 | ```js 127 | import { PluginComponent } from 'react-markdown-editor-lite'; 128 | 129 | interface CounterState { 130 | num: number; 131 | } 132 | 133 | class Counter extends PluginComponent { 134 | // Define plugin name here, must be unique 135 | static pluginName = 'counter'; 136 | // Define which place to be render, default is left, you can also use 'right' 137 | static align = 'left'; 138 | // Define default config if required 139 | static defaultConfig = { 140 | start: 0 141 | } 142 | 143 | constructor(props: any) { 144 | super(props); 145 | 146 | this.handleClick = this.handleClick.bind(this); 147 | 148 | this.state = { 149 | num: this.getConfig('start') 150 | }; 151 | } 152 | 153 | handleClick() { 154 | // Call API, insert number to editor 155 | this.editor.insertText(this.state.num); 156 | // Update itself's state 157 | this.setState({ 158 | num: this.state.num++ 159 | }); 160 | } 161 | 162 | render() { 163 | return ( 164 | 169 | {this.state.num} 170 | 171 | ); 172 | } 173 | } 174 | 175 | 176 | // Usage: 177 | Editor.use(Counter, { 178 | start: 10 179 | }); 180 | ``` 181 | 182 | ### Function component 183 | You can also use function component to write a plugin 184 | ```js 185 | import React from 'react'; 186 | import { PluginProps } from 'react-markdown-editor-lite'; 187 | 188 | interface CounterState { 189 | num: number; 190 | } 191 | 192 | const Counter = (props: PluginProps) => { 193 | const [num, setNum] = React.useState(props.config.start); 194 | 195 | const handleClick = () => { 196 | // Call API, insert number to editor 197 | props.editor.insertText(num); 198 | // Update itself's state 199 | setNum(num + 1); 200 | } 201 | 202 | return ( 203 | 208 | {num} 209 | 210 | ); 211 | } 212 | // Define default config if required 213 | Counter.defaultConfig = { 214 | start: 0 215 | } 216 | Counter.align = 'left'; 217 | Counter.pluginName = 'counter'; 218 | 219 | 220 | // Usage: 221 | Editor.use(Counter, { 222 | start: 10 223 | }); 224 | ``` 225 | ## Is it possible not to render any UI ? 226 | Yes, just return a empty element (such as ``, etc) in `render` method. 227 | -------------------------------------------------------------------------------- /docs/api.zh-CN.md: -------------------------------------------------------------------------------- 1 | # API 2 | [English documention see here](./api.md) 3 | ## 插件 4 | ### 编写插件 5 | 见[plugin.md](./plugin.md) 6 | ### Editor.use 7 | 注册插件 8 | ```js 9 | /** 10 | * 注册插件 11 | * @param comp 插件 12 | * @param config 其他配置 13 | */ 14 | static use(comp: any, config?: any): void; 15 | ``` 16 | ## 多语言 17 | Editor.addLocale / useLocale / getLocale,分别为添加语言包、设置当前语言、获取当前语言 18 | ```js 19 | /** 20 | * 设置所使用的语言文案 21 | */ 22 | static addLocale: (langName: string, lang: { 23 | [x: string]: string; 24 | }) => void; 25 | static useLocale: (langName: string) => void; 26 | static getLocale: () => string; 27 | ``` 28 | 例如,添加繁体中文并使用: 29 | ```js 30 | Editor.addLocale('zh-TW', { 31 | btnHeader: '標頭', 32 | btnClear: '清除', 33 | btnBold: '粗體', 34 | }); 35 | Editor.useLocale('zh-TW'); 36 | 37 | const MyEditor = () => { 38 | return ( 39 | 40 | ) 41 | } 42 | ``` 43 | 44 | ### 插件注册/反注册API及调用 45 | 用于插件本身对外暴露一些API,供用户调用 46 | ```js 47 | /** 48 | * 注册插件API 49 | * @param {string} name API名称 50 | * @param {any} cb 回调 51 | */ 52 | registerPluginApi(name: string, cb: any): void; 53 | unregisterPluginApi(name: string): void; 54 | 55 | /** 56 | * 调用插件API 57 | * @param {string} name API名称 58 | * @param {any} others 参数 59 | * @returns {any} 60 | */ 61 | callPluginApi(name: string, ...others: any): T; 62 | ``` 63 | 64 | 例如: 65 | ```js 66 | // 在你的插件中注册API 67 | this.editor.registerPluginApi("my-api", (number1, number2) => { 68 | console.log(number1 + number2); 69 | }); 70 | 71 | // 通过编辑器的ref调用API 72 | editorRef.current.callPluginApi("my-api", 1, 2); 73 | ``` 74 | 75 | ## 操作选中区域 76 | ### 数据结构 77 | ```js 78 | interface Selection { 79 | start: number; // 开始位置,从0开始 80 | end: number; // 结束位置 81 | text: string; // 选中的文字 82 | } 83 | ``` 84 | ### clearSelection 85 | 清除已选择区域,注意此函数会把光标移动到开头,如果只是想清除选择,而不移动光标位置,请使用setSelection 86 | ```js 87 | /** 88 | * 清除已选择区域 89 | */ 90 | clearSelection(): void; 91 | ``` 92 | ### getSelection 93 | 获取已选择区域 94 | ```js 95 | /** 96 | * 获取已选择区域 97 | * @return {Selection} 98 | */ 99 | getSelection(): Selection; 100 | ``` 101 | ## setSelection 102 | 设置已选择区域,当`to.start`与`to.end`相等时,光标位置将会被移动到`to.start`处。 103 | 104 | 另外,本函数中,Selection的text无实际意义 105 | ```js 106 | /** 107 | * 设置已选择区域 108 | * @param {Selection} to 109 | */ 110 | setSelection(to: Selection): void; 111 | ``` 112 | ## 内容 113 | ### insertMarkdown 114 | 插入Markdown语法,支持常见Markdown语法。完整示例见下方。 115 | ```js 116 | /** 117 | * 插入Markdown语法 118 | * @param type 119 | * @param option 120 | */ 121 | insertMarkdown(type: string, option?: any): void; 122 | ``` 123 | ### insertPlaceholder 124 | 插入占位符,并在Promise结束后自动覆盖,例如上传图片时,可以先插入一个占位符,在上传完成后自动将占位符替换为真实图片。 125 | ```js 126 | /** 127 | * 插入占位符,并在Promise结束后自动覆盖 128 | * @param placeholder 129 | * @param wait 130 | */ 131 | insertPlaceholder(placeholder: string, wait: Promise): void; 132 | ``` 133 | ### insertText 134 | 插入文本 135 | ```js 136 | /** 137 | * 插入文本 138 | * @param {string} value 要插入的文本 139 | * @param {boolean} replaceSelected 是否替换掉当前选择的文本 140 | * @param {Selection} newSelection 新的选择区域 141 | */ 142 | insertText(value?: string, replaceSelected?: boolean, newSelection?: { 143 | start: number; 144 | end: number; 145 | }): void; 146 | ``` 147 | ### setText 148 | 设置文本,同时触发onChange。注意避免在onChange里面调用此方法,以免造成死循环 149 | ```js 150 | /** 151 | * 设置文本,同时触发onChange 152 | * @param {string} value 153 | * @param {any} event 154 | */ 155 | setText(value?: string, event?: React.ChangeEvent, newSelection?: Selection): void; 156 | ``` 157 | ### getMdValue 158 | 获取文本值 159 | ```js 160 | /** 161 | * 获取文本值 162 | * @return {string} 163 | */ 164 | getMdValue(): string; 165 | ``` 166 | ### getHtmlValue 167 | 获取渲染后的HTML 168 | ```js 169 | /** 170 | * 获取渲染后的HTML 171 | * @returns {string} 172 | */ 173 | getHtmlValue(): string; 174 | ``` 175 | ## 事件 176 | ### on / off 177 | 监听常规事件和取消监听事件。支持事件: 178 | * change:编辑器内容变化 179 | * fullscreen:全屏状态改变 180 | * viewchange:视图区域改变(例如预览区域、菜单栏被隐藏/显示) 181 | * keydown:按下键盘按键 182 | ```js 183 | on(event: EditorEvent, cb: any): void; 184 | off(event: EditorEvent, cb: any): void; 185 | ``` 186 | ### onKeyboard / offKeyboard 187 | 监听键盘事件或取消监听 188 | ```js 189 | interface KeyboardEventListener { 190 | key?: string; // 按键名称,优先使用此属性,例如“z” 191 | keyCode: number; // 按键代码,如果没有key的时候则使用此属性,例如90 192 | withKey?: ('ctrlKey' | 'shiftKey' | 'altKey' | 'metaKey')[]; // 是否同时按下其他按键,包括ctrl、shift、alt、meta(即Mac上的Command按键) 193 | callback: (e: React.KeyboardEvent) => void; // 回调 194 | } 195 | onKeyboard(data: KeyboardEventListener): void; 196 | offKeyboard(data: KeyboardEventListener): void; 197 | ``` 198 | ## 界面相关 199 | ### setView 200 | ```js 201 | /** 202 | * 设置视图属性 203 | * 可显示或隐藏:编辑器,预览区域,菜单栏 204 | * @param enable 205 | */ 206 | setView({ 207 | md?: boolean; 208 | menu?: boolean; 209 | html?: boolean; 210 | }): void; 211 | ``` 212 | ### getView 213 | 获取视图属性 214 | ```js 215 | getView(): { 216 | menu: boolean; 217 | md: boolean; 218 | html: boolean; 219 | }; 220 | ``` 221 | ### fullScreen 222 | 进入或退出全屏模式 223 | ```js 224 | /** 225 | * 进入或退出全屏模式 226 | * @param {boolean} enable 是否开启全屏模式 227 | */ 228 | fullScreen(enable: boolean): void; 229 | ``` 230 | ### isFullScreen 231 | 是否处于全屏状态 232 | ```js 233 | isFullScreen(): boolean; 234 | ``` 235 | ## 元素 236 | 可以通过以下API获取编辑器实际元素。请注意:你必须明白自己在做什么,否则不要轻易操作编辑器实际元素。 237 | ### getMdElement 238 | 获取编辑区域元素 239 | ```js 240 | getMdElement(): HTMLTextAreaElement | null; 241 | ``` 242 | ### getHtmlElement 243 | 获取预览区域元素 244 | ```js 245 | getHtmlElement(): HTMLDivElement | null; 246 | ``` 247 | 248 | ## insertMarkdown 示例 249 | ```js 250 | insertMarkdown('bold'); // **text** 251 | insertMarkdown('italic'); // *text* 252 | insertMarkdown('underline'); // ++text++ 253 | insertMarkdown('strikethrough'); // ~~text~~ 254 | insertMarkdown('quote'); // > text 255 | insertMarkdown('inlinecode'); // `text` 256 | insertMarkdown('hr'); // --- 257 | 258 | /* 259 | \``` 260 | text 261 | \``` 262 | */ 263 | insertMarkdown('code'); 264 | /* 265 | * text 266 | * text 267 | * text 268 | */ 269 | insertMarkdown('unordered'); 270 | /* 271 | 1. text 272 | 2. text 273 | 3. text 274 | */ 275 | insertMarkdown('order'); 276 | /* 277 | | Head | Head | Head | Head | 278 | | --- | --- | --- | --- | 279 | | Data | Data | Data | Data | 280 | | Data | Data | Data | Data | 281 | */ 282 | insertMarkdown('table', { 283 | row: 2, 284 | col: 4 285 | }); 286 | /* 287 | ![text](http://example.com/image.jpg) 288 | */ 289 | insertMarkdown('image', { 290 | imageUrl: "http://example.com/image.jpg" 291 | }); 292 | /* 293 | [text](http://example.com/) 294 | */ 295 | insertMarkdown('link', { 296 | linkUrl: "http://example.com/" 297 | }); 298 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-markdown-editor-lite 2 | 3 | [![NPM package][npm-version-image]][npm-url] 4 | [![NPM downloads][npm-downloads-image]][npm-url] 5 | [![MIT License][license-image]][license-url] 6 | [![Workflow][workflow-image]][workflow-url] 7 | 8 | [中文说明](README_CN.md) 9 | 10 | - A light-weight(20KB zipped) Markdown editor of React component 11 | - Supports TypeScript 12 | - Supports custom markdown parser 13 | - Full markdown support 14 | - Supports pluggable function bars 15 | - Full control over UI 16 | - Supports image uploading and dragging 17 | - Supports synced scrolling between editor and preview 18 | - 一款轻量的基于 React 的 Markdown 编辑器, 压缩后代码只有 20KB 19 | - 支持 TypeScript 20 | - 支持自定义 Markdown 解析器 21 | - 支持常用的 Markdown 编辑功能,如加粗,斜体等等... 22 | - 支持插件化的功能键 23 | - 界面可配置, 如只显示编辑区或预览区 24 | - 支持图片上传或拖拽 25 | - 支持编辑区和预览区同步滚动 26 | 27 | ## Demo 28 | 29 | Online demo
[https://harrychen0506.github.io/react-markdown-editor-lite/](https://harrychen0506.github.io/react-markdown-editor-lite/) 30 | 31 | Default configuration 32 | 33 | ![image](https://github.com//HarryChen0506/react-markdown-editor-lite/blob/master/image/react-markdown-editor-lite-v1.0.0.PNG?raw=true) 34 | 35 | Pluggable bars 36 | 37 | ![image](https://github.com//HarryChen0506/react-markdown-editor-lite/blob/master/image/react-markdown-editor-lite-v1.0.0-plugins.PNG?raw=true) 38 | 39 | ## Install 40 | 41 | ```shell 42 | npm install react-markdown-editor-lite --save 43 | # or 44 | yarn add react-markdown-editor-lite 45 | ``` 46 | 47 | ## Basic usage 48 | 49 | Following steps: 50 | 51 | - Import react-markdown-editor-lite 52 | - Register plugins if required 53 | - Initialize a markdown parser, such as markdown-it 54 | - Start usage 55 | 56 | ```js 57 | // import react, react-markdown-editor-lite, and a markdown parser you like 58 | import React from 'react'; 59 | import * as ReactDOM from 'react-dom'; 60 | import MarkdownIt from 'markdown-it'; 61 | import MdEditor from 'react-markdown-editor-lite'; 62 | // import style manually 63 | import 'react-markdown-editor-lite/lib/index.css'; 64 | 65 | // Register plugins if required 66 | // MdEditor.use(YOUR_PLUGINS_HERE); 67 | 68 | // Initialize a markdown parser 69 | const mdParser = new MarkdownIt(/* Markdown-it options */); 70 | 71 | // Finish! 72 | function handleEditorChange({ html, text }) { 73 | console.log('handleEditorChange', html, text); 74 | } 75 | export default props => { 76 | return ( 77 | mdParser.render(text)} onChange={handleEditorChange} /> 78 | ); 79 | }; 80 | ``` 81 | 82 | - Props and configurations see [here](./docs/configure.md) 83 | - APIs see [here](./docs/api.md) 84 | - Plugins developer see [here](./docs/plugin.md) 85 | - Full demo see [src/demo/index.tsx](https://github.com/HarryChen0506/react-markdown-editor-lite/blob/master/src/demo/index.tsx) 86 | 87 | ## Usage in server-side render 88 | 89 | If you are using a server-side render framework, like Next.js, Gatsby, please use client-side render for this editor. 90 | 91 | For example, Next.js has [next/dynamic](https://nextjs.org/docs/advanced-features/dynamic-import), Gatsby has [loadable-components](https://www.gatsbyjs.org/docs/using-client-side-only-packages/#workaround-3-load-client-side-dependent-components-with-loadable-components) 92 | 93 | Following is a example for Next.js: 94 | 95 | ```js 96 | import dynamic from 'next/dynamic'; 97 | import 'react-markdown-editor-lite/lib/index.css'; 98 | 99 | const MdEditor = dynamic(() => import('react-markdown-editor-lite'), { 100 | ssr: false, 101 | }); 102 | 103 | export default function() { 104 | return ; 105 | } 106 | ``` 107 | 108 | With plugins: 109 | 110 | ```js 111 | import dynamic from 'next/dynamic'; 112 | import 'react-markdown-editor-lite/lib/index.css'; 113 | 114 | const MdEditor = dynamic( 115 | () => { 116 | return new Promise(resolve => { 117 | Promise.all([ 118 | import('react-markdown-editor-lite'), 119 | import('./my-plugin'), 120 | /** Add more plugins, and use below */ 121 | ]).then(res => { 122 | res[0].default.use(res[1].default); 123 | resolve(res[0].default); 124 | }); 125 | }); 126 | }, 127 | { 128 | ssr: false, 129 | }, 130 | ); 131 | 132 | export default function() { 133 | return ; 134 | } 135 | ``` 136 | 137 | Full example see [here](https://codesandbox.io/s/next-js-80bne) 138 | 139 | ## Import in Browser 140 | 141 | Since 1.1.0, You can add `script` and `link` tags in your browser and use the global variable `ReactMarkdownEditorLite`. 142 | 143 | You can download these files directly from [![cdnjs][cdnjs-image]][cdnjs-url] [![jsdelivr][jsdelivr-image]][jsdelivr-url] [![unpkg][unpkg-image]][unpkg-url] 144 | 145 | Note: you should import react before `ReactMarkdownEditorLite`. 146 | 147 | For example, in webpack, you import ReactMarkdownEditorLite by `script` tag in your page, and write webpack config like this: 148 | 149 | ```js 150 | externals: { 151 | react: 'React', 152 | 'react-markdown-editor-lite': 'ReactMarkdownEditorLite' 153 | } 154 | ``` 155 | 156 | ## More demos 157 | * [Basic usage](https://codesandbox.io/s/rmel-demo-ref-in-function-component-u04gb) 158 | * [With unform](https://codesandbox.io/s/rmel-demo-with-unform-qx34y) 159 | * [Write a plugin](https://codesandbox.io/s/rmel-demo-write-plugin-p82fc) 160 | * [Replace default icons](https://codesandbox.io/s/rmel-demo-replace-icon-pl1n3) 161 | * [In Next.js](https://codesandbox.io/s/next-js-80bne) 162 | 163 | ## Authors 164 | 165 | - ShuangYa [github/sylingd](https://github.com/sylingd) 166 | - HarryChen0506 [github/HarryChen0506](https://github.com/HarryChen0506) 167 | 168 | ## License 169 | 170 | [MIT](LICENSE) 171 | 172 | [npm-version-image]: https://img.shields.io/npm/v/react-markdown-editor-lite.svg 173 | [npm-downloads-image]: https://img.shields.io/npm/dm/react-markdown-editor-lite.svg?style=flat 174 | [npm-url]: https://www.npmjs.com/package/react-markdown-editor-lite 175 | [workflow-image]: https://img.shields.io/github/workflow/status/HarryChen0506/react-markdown-editor-lite/main 176 | [workflow-url]: https://github.com/HarryChen0506/react-markdown-editor-lite/actions?query=workflow%3Amain 177 | [license-image]: https://img.shields.io/badge/license-MIT-blue.svg?style=flat 178 | [license-url]: LICENSE 179 | [jsdelivr-image]: https://img.shields.io/jsdelivr/npm/hm/react-markdown-editor-lite 180 | [jsdelivr-url]: https://www.jsdelivr.com/package/npm/react-markdown-editor-lite?path=lib 181 | [cdnjs-image]: https://img.shields.io/cdnjs/v/react-markdown-editor-lite?style=flat 182 | [cdnjs-url]: https://cdnjs.com/libraries/react-markdown-editor-lite 183 | [unpkg-image]: https://img.shields.io/npm/v/react-markdown-editor-lite?label=unpkg&style=flat 184 | [unpkg-url]: https://unpkg.com/browse/react-markdown-editor-lite/lib/ 185 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API 2 | [中文文档见此](./api.zh-CN.md) 3 | ## Plugins 4 | ### Written a plugin 5 | See [plugin.md](./plugin.md) 6 | ### Editor.use 7 | Register plugin 8 | ```js 9 | /** 10 | * Register plugin 11 | * @param comp Plugin 12 | * @param config Other configurations 13 | */ 14 | static use(comp: any, config?: any): void; 15 | ``` 16 | ## Locales 17 | * addLocale: Add language pack 18 | * useLocale: Set current language pack 19 | * getLocale: Get current language pack's name 20 | ```js 21 | static addLocale: (langName: string, lang: { 22 | [x: string]: string; 23 | }) => void; 24 | static useLocale: (langName: string) => void; 25 | static getLocale: () => string; 26 | ``` 27 | For example, add traditional Chinese, and use it: 28 | ```js 29 | Editor.addLocale('zh-TW', { 30 | btnHeader: '標頭', 31 | btnClear: '清除', 32 | btnBold: '粗體', 33 | }); 34 | Editor.useLocale('zh-TW'); 35 | 36 | const MyEditor = () => { 37 | return ( 38 | 39 | ) 40 | } 41 | ``` 42 | 43 | ### Plugin register/unregister API and use it 44 | Plugin can export some methods to users. 45 | ```js 46 | /** 47 | * Register a plugin API 48 | * @param {string} name API name 49 | * @param {any} cb callback 50 | */ 51 | registerPluginApi(name: string, cb: any): void; 52 | unregisterPluginApi(name: string): void; 53 | 54 | /** 55 | * Call a plugin API 56 | * @param {string} name API name 57 | * @param {any} others arguments 58 | * @returns {any} 59 | */ 60 | callPluginApi(name: string, ...others: any): T; 61 | ``` 62 | 63 | Example: 64 | ```js 65 | // Register API in your plugin 66 | this.editor.registerPluginApi("my-api", (number1, number2) => { 67 | console.log(number1 + number2); 68 | }); 69 | 70 | // Call API with editor's ref 71 | editorRef.current.callPluginApi("my-api", 1, 2); 72 | ``` 73 | 74 | ## Selected 75 | ### Data struct 76 | ```js 77 | interface Selection { 78 | start: number; // Start position, start at 0 79 | end: number; // End position 80 | text: string; // Selected text 81 | } 82 | ``` 83 | ### clearSelection 84 | Clear selection, note that this method will move cursor to start, if you only want to clear selections but do not want to move cursor, please use setSelection 85 | ```js 86 | /** 87 | * Clear selection 88 | */ 89 | clearSelection(): void; 90 | ``` 91 | ### getSelection 92 | Get selection 93 | ```js 94 | /** 95 | * Get selection 96 | * @return {Selection} 97 | */ 98 | getSelection(): Selection; 99 | ``` 100 | ## setSelection 101 | Set current selection, if `to.start` is same as `to.end`, cursor will move to `to.start` 102 | 103 | BTW, in this method, "text" in Selection take no effect. 104 | ```js 105 | /** 106 | * Set current selection 107 | * @param {Selection} to 108 | */ 109 | setSelection(to: Selection): void; 110 | ``` 111 | ## Contents 112 | ### insertMarkdown 113 | Insert markdown text, see below for a complete example. 114 | ```js 115 | /** 116 | * Insert markdown text 117 | * @param type 118 | * @param option 119 | */ 120 | insertMarkdown(type: string, option?: any): void; 121 | ``` 122 | ### insertPlaceholder 123 | Insert a placeholder, and replace it after the Promise resolved, for example, when uploading a image, you can insert a placeholder, and replace the placeholder to image's url after upload. 124 | ```js 125 | /** 126 | * @param placeholder 127 | * @param wait 128 | */ 129 | insertPlaceholder(placeholder: string, wait: Promise): void; 130 | ``` 131 | ### insertText 132 | Insert text 133 | ```js 134 | /** 135 | * Insert text 136 | * @param {string} value The text you want to insert 137 | * @param {boolean} replaceSelected Replace selected text or not 138 | * @param {Selection} newSelection New selection 139 | */ 140 | insertText(value?: string, replaceSelected?: boolean, newSelection?: { 141 | start: number; 142 | end: number; 143 | }): void; 144 | ``` 145 | ### setText 146 | Set text and trigger onChange event. Note that you should't call this method in onChange callback. 147 | ```js 148 | /** 149 | * @param {string} value 150 | * @param {any} event 151 | */ 152 | setText(value?: string, event?: React.ChangeEvent, newSelection?: Selection): void; 153 | ``` 154 | ### getMdValue 155 | Get text value 156 | ```js 157 | /** 158 | * Get text value 159 | * @return {string} 160 | */ 161 | getMdValue(): string; 162 | ``` 163 | ### getHtmlValue 164 | Get rendered html source code 165 | ```js 166 | /** 167 | * Get rendered html source code 168 | * @returns {string} 169 | */ 170 | getHtmlValue(): string; 171 | ``` 172 | ## Event 173 | ### on / off 174 | Listen or unlisten events, events: 175 | * change: Editor's content has changed 176 | * fullscreen: Full screen status changed 177 | * viewchange: View status changed, such as show / hide preview area, or menu bars 178 | * keydown: Press the keyboard key 179 | ```js 180 | on(event: EditorEvent, cb: any): void; 181 | off(event: EditorEvent, cb: any): void; 182 | ``` 183 | ### onKeyboard / offKeyboard 184 | Listen or unlisten keyboard events 185 | ```js 186 | interface KeyboardEventListener { 187 | key?: string; // Key name, use this property at first, such as "z" 188 | keyCode: number; // Key code, if key name not exists, use this, such as 90 189 | withKey?: ('ctrlKey' | 'shiftKey' | 'altKey' | 'metaKey')[]; // Press other keys at same time? 190 | callback: (e: React.KeyboardEvent) => void; // Callback 191 | } 192 | onKeyboard(data: KeyboardEventListener): void; 193 | offKeyboard(data: KeyboardEventListener): void; 194 | ``` 195 | ## UI 196 | ### setView 197 | ```js 198 | /** 199 | * Set view status 200 | * You can hide or show: editor(md), preview(html), menu bar(menu) 201 | * @param enable 202 | */ 203 | setView({ 204 | md?: boolean; 205 | menu?: boolean; 206 | html?: boolean; 207 | }): void; 208 | ``` 209 | ### getView 210 | Get view status 211 | ```js 212 | getView(): { 213 | menu: boolean; 214 | md: boolean; 215 | html: boolean; 216 | }; 217 | ``` 218 | ### fullScreen 219 | Enter or exit full screen 220 | ```js 221 | /** 222 | * Enter or exit full screen 223 | * @param {boolean} enable Enable full screen? 224 | */ 225 | fullScreen(enable: boolean): void; 226 | ``` 227 | ### isFullScreen 228 | Is full screen enable or not 229 | ```js 230 | isFullScreen(): boolean; 231 | ``` 232 | ## Element 233 | The actual elements of the editor can be reached by the following APIs. Please note: you MUST understand what you are doing, otherwise do not manipulate the actual elements of the editor. 234 | ### getMdElement 235 | Get edit area elements 236 | ```js 237 | getMdElement(): HTMLTextAreaElement | null; 238 | ``` 239 | ### getHtmlElement 240 | Get preview area element 241 | ```js 242 | getHtmlElement(): HTMLDivElement | null; 243 | ``` 244 | 245 | ## insertMarkdown Demo 246 | ```js 247 | insertMarkdown('bold'); // **text** 248 | insertMarkdown('italic'); // *text* 249 | insertMarkdown('underline'); // ++text++ 250 | insertMarkdown('strikethrough'); // ~~text~~ 251 | insertMarkdown('quote'); // > text 252 | insertMarkdown('inlinecode'); // `text` 253 | insertMarkdown('hr'); // --- 254 | 255 | /* 256 | \``` 257 | text 258 | \``` 259 | */ 260 | insertMarkdown('code'); 261 | /* 262 | * text 263 | * text 264 | * text 265 | */ 266 | insertMarkdown('unordered'); 267 | /* 268 | 1. text 269 | 2. text 270 | 3. text 271 | */ 272 | insertMarkdown('order'); 273 | /* 274 | | Head | Head | Head | Head | 275 | | --- | --- | --- | --- | 276 | | Data | Data | Data | Data | 277 | | Data | Data | Data | Data | 278 | */ 279 | insertMarkdown('table', { 280 | row: 2, 281 | col: 4 282 | }); 283 | /* 284 | ![text](http://example.com/image.jpg) 285 | */ 286 | insertMarkdown('image', { 287 | imageUrl: "http://example.com/image.jpg" 288 | }); 289 | /* 290 | [text](http://example.com/) 291 | */ 292 | insertMarkdown('link', { 293 | linkUrl: "http://example.com/" 294 | }); 295 | ``` -------------------------------------------------------------------------------- /src/editor/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { v4 as uuid } from 'uuid'; 3 | import Icon from '../components/Icon'; 4 | import NavigationBar from '../components/NavigationBar'; 5 | import ToolBar from '../components/ToolBar'; 6 | import i18n from '../i18n'; 7 | import DividerPlugin from '../plugins/divider'; 8 | import Emitter, { globalEmitter } from '../share/emitter'; 9 | import { EditorConfig, EditorEvent, initialSelection, KeyboardEventListener, Selection } from '../share/var'; 10 | import getDecorated from '../utils/decorate'; 11 | import mergeConfig from '../utils/mergeConfig'; 12 | import { getLineAndCol, isKeyMatch, isPromise } from '../utils/tool'; 13 | import getUploadPlaceholder from '../utils/uploadPlaceholder'; 14 | import defaultConfig from './defaultConfig'; 15 | import { HtmlRender, HtmlType } from './preview'; 16 | 17 | type Plugin = { comp: any; config: any }; 18 | 19 | interface EditorProps extends EditorConfig { 20 | id?: string; 21 | defaultValue?: string; 22 | value?: string; 23 | renderHTML: (text: string) => HtmlType | Promise | (() => HtmlType); 24 | style?: React.CSSProperties; 25 | autoFocus?: boolean; 26 | placeholder?: string; 27 | readOnly?: boolean; 28 | className?: string; 29 | config?: any; 30 | plugins?: string[]; 31 | // Configs 32 | onChange?: ( 33 | data: { 34 | text: string; 35 | html: string; 36 | }, 37 | event?: React.ChangeEvent, 38 | ) => void; 39 | onFocus?: (e: React.FocusEvent) => void; 40 | onBlur?: (e: React.FocusEvent) => void; 41 | onScroll?: (e: React.UIEvent, type: 'md' | 'html') => void; 42 | } 43 | 44 | interface EditorState { 45 | text: string; 46 | html: HtmlType; 47 | fullScreen: boolean; 48 | plugins: { [x: string]: React.ReactElement[] }; 49 | view: { 50 | menu: boolean; 51 | md: boolean; 52 | html: boolean; 53 | }; 54 | } 55 | 56 | class Editor extends React.Component { 57 | private static plugins: Plugin[] = []; 58 | 59 | /** 60 | * Register plugin 61 | * @param {any} comp Plugin component 62 | * @param {any} config Other configs 63 | */ 64 | static use(comp: any, config: any = {}) { 65 | // Check for duplicate plugins 66 | for (let i = 0; i < Editor.plugins.length; i++) { 67 | if (Editor.plugins[i].comp === comp) { 68 | Editor.plugins.splice(i, 1, { comp, config }); 69 | return; 70 | } 71 | } 72 | Editor.plugins.push({ comp, config }); 73 | } 74 | 75 | /** 76 | * Unregister plugin 77 | * @param {any} comp Plugin component 78 | */ 79 | static unuse(comp: any) { 80 | for (let i = 0; i < Editor.plugins.length; i++) { 81 | if (Editor.plugins[i].comp === comp) { 82 | Editor.plugins.splice(i, 1); 83 | return; 84 | } 85 | } 86 | } 87 | 88 | /** 89 | * Unregister all plugins 90 | * @param {any} comp Plugin component 91 | */ 92 | static unuseAll() { 93 | Editor.plugins = []; 94 | } 95 | 96 | /** 97 | * Locales 98 | */ 99 | static addLocale = i18n.add.bind(i18n); 100 | 101 | static useLocale = i18n.setCurrent.bind(i18n); 102 | 103 | static getLocale = i18n.getCurrent.bind(i18n); 104 | 105 | private config: EditorConfig; 106 | 107 | private emitter: Emitter; 108 | 109 | private nodeMdText = React.createRef(); 110 | 111 | private nodeMdPreview = React.createRef(); 112 | 113 | private nodeMdPreviewWrapper = React.createRef(); 114 | 115 | private hasContentChanged = true; 116 | 117 | private composing = false; 118 | 119 | private pluginApis = new Map(); 120 | 121 | private handleInputScroll: (e: React.UIEvent) => void; 122 | 123 | private handlePreviewScroll: (e: React.UIEvent) => void; 124 | 125 | constructor(props: any) { 126 | super(props); 127 | 128 | this.emitter = new Emitter(); 129 | this.config = mergeConfig(defaultConfig, this.props.config, this.props); 130 | 131 | this.state = { 132 | text: (this.props.value || this.props.defaultValue || '').replace(/↵/g, '\n'), 133 | html: '', 134 | view: this.config.view || defaultConfig.view!, 135 | fullScreen: false, 136 | plugins: this.getPlugins(), 137 | }; 138 | 139 | if (this.config.canView && !this.config.canView.menu) { 140 | this.state.view.menu = false; 141 | } 142 | 143 | this.nodeMdText = React.createRef(); 144 | this.nodeMdPreviewWrapper = React.createRef(); 145 | 146 | this.handleChange = this.handleChange.bind(this); 147 | this.handlePaste = this.handlePaste.bind(this); 148 | this.handleDrop = this.handleDrop.bind(this); 149 | this.handleToggleMenu = this.handleToggleMenu.bind(this); 150 | this.handleKeyDown = this.handleKeyDown.bind(this); 151 | this.handleEditorKeyDown = this.handleEditorKeyDown.bind(this); 152 | this.handleLocaleUpdate = this.handleLocaleUpdate.bind(this); 153 | 154 | this.handleFocus = this.handleFocus.bind(this); 155 | this.handleBlur = this.handleBlur.bind(this); 156 | 157 | this.handleInputScroll = this.handleSyncScroll.bind(this, 'md'); 158 | this.handlePreviewScroll = this.handleSyncScroll.bind(this, 'html'); 159 | } 160 | 161 | componentDidMount() { 162 | const { text } = this.state; 163 | this.renderHTML(text); 164 | globalEmitter.on(globalEmitter.EVENT_LANG_CHANGE, this.handleLocaleUpdate); 165 | // init i18n 166 | i18n.setUp(); 167 | } 168 | 169 | componentWillUnmount() { 170 | globalEmitter.off(globalEmitter.EVENT_LANG_CHANGE, this.handleLocaleUpdate); 171 | } 172 | 173 | componentDidUpdate(prevProps: EditorProps) { 174 | if (typeof this.props.value !== 'undefined' && this.props.value !== this.state.text) { 175 | let { value } = this.props; 176 | if (typeof value !== 'string') { 177 | value = String(value).toString(); 178 | } 179 | value = value.replace(/↵/g, '\n'); 180 | if (this.state.text !== value) { 181 | this.setState({ 182 | text: value, 183 | }); 184 | this.renderHTML(value); 185 | } 186 | } 187 | if (prevProps.plugins !== this.props.plugins) { 188 | this.setState({ 189 | plugins: this.getPlugins(), 190 | }); 191 | } 192 | } 193 | 194 | isComposing() { 195 | return this.composing; 196 | } 197 | 198 | private getPlugins() { 199 | let plugins: Plugin[] = []; 200 | if (this.props.plugins) { 201 | // If plugins option is configured, use only specified plugins 202 | const addToPlugins = (name: string) => { 203 | if (name === DividerPlugin.pluginName) { 204 | plugins.push({ 205 | comp: DividerPlugin, 206 | config: {}, 207 | }); 208 | return; 209 | } 210 | for (const it of Editor.plugins) { 211 | if (it.comp.pluginName === name) { 212 | plugins.push(it); 213 | return; 214 | } 215 | } 216 | }; 217 | for (const name of this.props.plugins) { 218 | // Special handling of fonts to ensure backward compatibility 219 | if (name === 'fonts') { 220 | addToPlugins('font-bold'); 221 | addToPlugins('font-italic'); 222 | addToPlugins('font-underline'); 223 | addToPlugins('font-strikethrough'); 224 | addToPlugins('list-unordered'); 225 | addToPlugins('list-ordered'); 226 | addToPlugins('block-quote'); 227 | addToPlugins('block-wrap'); 228 | addToPlugins('block-code-inline'); 229 | addToPlugins('block-code-block'); 230 | } else { 231 | addToPlugins(name); 232 | } 233 | } 234 | } else { 235 | // Use all registered plugins 236 | plugins = [...Editor.plugins]; 237 | } 238 | const result: { [x: string]: React.ReactElement[] } = {}; 239 | plugins.forEach((it) => { 240 | if (typeof result[it.comp.align] === 'undefined') { 241 | result[it.comp.align] = []; 242 | } 243 | const key = it.comp.pluginName === 'divider' ? uuid() : it.comp.pluginName; 244 | result[it.comp.align].push( 245 | React.createElement(it.comp, { 246 | editor: this, 247 | editorConfig: this.config, 248 | config: { 249 | ...(it.comp.defaultConfig || {}), 250 | ...(it.config || {}), 251 | }, 252 | key, 253 | }), 254 | ); 255 | }); 256 | return result; 257 | } 258 | 259 | // sync left and right section's scroll 260 | private scrollScale = 1; 261 | 262 | private isSyncingScroll = false; 263 | 264 | private shouldSyncScroll: 'md' | 'html' = 'md'; 265 | 266 | private handleSyncScroll(type: 'md' | 'html', e: React.UIEvent) { 267 | // prevent loop 268 | if (type !== this.shouldSyncScroll) { 269 | return; 270 | } 271 | // trigger events 272 | if (this.props.onScroll) { 273 | this.props.onScroll(e, type); 274 | } 275 | this.emitter.emit(this.emitter.EVENT_SCROLL, e, type); 276 | // should sync scroll? 277 | const { syncScrollMode = [] } = this.config; 278 | if (!syncScrollMode.includes(type === 'md' ? 'rightFollowLeft' : 'leftFollowRight')) { 279 | return; 280 | } 281 | if (this.hasContentChanged && this.nodeMdText.current && this.nodeMdPreviewWrapper.current) { 282 | // 计算出左右的比例 283 | this.scrollScale = this.nodeMdText.current.scrollHeight / this.nodeMdPreviewWrapper.current.scrollHeight; 284 | this.hasContentChanged = false; 285 | } 286 | if (!this.isSyncingScroll) { 287 | this.isSyncingScroll = true; 288 | requestAnimationFrame(() => { 289 | if (this.nodeMdText.current && this.nodeMdPreviewWrapper.current) { 290 | if (type === 'md') { 291 | // left to right 292 | this.nodeMdPreviewWrapper.current.scrollTop = this.nodeMdText.current.scrollTop / this.scrollScale; 293 | } else { 294 | // right to left 295 | this.nodeMdText.current.scrollTop = this.nodeMdPreviewWrapper.current.scrollTop * this.scrollScale; 296 | } 297 | } 298 | this.isSyncingScroll = false; 299 | }); 300 | } 301 | } 302 | 303 | private renderHTML(markdownText: string): Promise { 304 | if (!this.props.renderHTML) { 305 | console.error('renderHTML props is required!'); 306 | return Promise.resolve(); 307 | } 308 | const res = this.props.renderHTML(markdownText); 309 | if (isPromise(res)) { 310 | // @ts-ignore 311 | return res.then((r: HtmlType) => this.setHtml(r)); 312 | } 313 | if (typeof res === 'function') { 314 | return this.setHtml(res()); 315 | } 316 | return this.setHtml(res); 317 | } 318 | 319 | private setHtml(html: HtmlType): Promise { 320 | return new Promise((resolve) => { 321 | this.setState({ html }, resolve); 322 | }); 323 | } 324 | 325 | private handleToggleMenu() { 326 | this.setView({ 327 | menu: !this.state.view.menu, 328 | }); 329 | } 330 | 331 | private handleFocus(e: React.FocusEvent) { 332 | const { onFocus } = this.props; 333 | if (onFocus) { 334 | onFocus(e); 335 | } 336 | this.emitter.emit(this.emitter.EVENT_FOCUS, e); 337 | } 338 | 339 | private handleBlur(e: React.FocusEvent) { 340 | const { onBlur } = this.props; 341 | if (onBlur) { 342 | onBlur(e); 343 | } 344 | this.emitter.emit(this.emitter.EVENT_BLUR, e); 345 | } 346 | 347 | /** 348 | * Text area change event 349 | * @param {React.ChangeEvent} e 350 | */ 351 | private handleChange(e: React.ChangeEvent) { 352 | e.persist(); 353 | const { value } = e.target; 354 | // 触发内部事件 355 | this.setText(value, e); 356 | } 357 | 358 | /** 359 | * Listen paste event to support paste images 360 | */ 361 | private handlePaste(e: React.SyntheticEvent) { 362 | if (!this.config.allowPasteImage || !this.config.onImageUpload) { 363 | return; 364 | } 365 | const event = e.nativeEvent as ClipboardEvent; 366 | // @ts-ignore 367 | const items = (event.clipboardData || window.clipboardData).items as DataTransferItemList; 368 | 369 | if (items) { 370 | e.preventDefault(); 371 | this.uploadWithDataTransfer(items); 372 | } 373 | } 374 | 375 | // Drag images to upload 376 | private handleDrop(e: React.SyntheticEvent) { 377 | if (!this.config.onImageUpload) { 378 | return; 379 | } 380 | const event = e.nativeEvent as DragEvent; 381 | if (!event.dataTransfer) { 382 | return; 383 | } 384 | const { items } = event.dataTransfer; 385 | if (items) { 386 | e.preventDefault(); 387 | this.uploadWithDataTransfer(items); 388 | } 389 | } 390 | 391 | private handleEditorKeyDown(e: React.KeyboardEvent) { 392 | const { keyCode, key, currentTarget } = e; 393 | if ((keyCode === 13 || key === 'Enter') && this.composing === false) { 394 | const text = currentTarget.value; 395 | const curPos = currentTarget.selectionStart; 396 | const lineInfo = getLineAndCol(text, curPos); 397 | 398 | const emptyCurrentLine = () => { 399 | const newValue = currentTarget.value.substr(0, curPos - lineInfo.curLine.length) + currentTarget.value.substr(curPos); 400 | this.setText(newValue, undefined, { 401 | start: curPos - lineInfo.curLine.length, 402 | end: curPos - lineInfo.curLine.length, 403 | }); 404 | e.preventDefault(); 405 | }; 406 | 407 | const addSymbol = (symbol: string) => { 408 | this.insertText(`\n${symbol}`, false, { 409 | start: symbol.length + 1, 410 | end: symbol.length + 1, 411 | }); 412 | e.preventDefault(); 413 | }; 414 | 415 | // Enter key, check previous line 416 | const isSymbol = lineInfo.curLine.match(/^(\s*?)\* /); 417 | if (isSymbol) { 418 | if (/^(\s*?)\* $/.test(lineInfo.curLine)) { 419 | emptyCurrentLine(); 420 | return; 421 | } 422 | addSymbol(isSymbol[0]); 423 | return; 424 | } 425 | const isOrderList = lineInfo.curLine.match(/^(\s*?)(\d+)\. /); 426 | if (isOrderList) { 427 | if (/^(\s*?)(\d+)\. $/.test(lineInfo.curLine)) { 428 | emptyCurrentLine(); 429 | return; 430 | } 431 | const toInsert = `${isOrderList[1]}${parseInt(isOrderList[2], 10) + 1}. `; 432 | addSymbol(toInsert); 433 | return; 434 | } 435 | } 436 | // 触发默认事件 437 | this.emitter.emit(this.emitter.EVENT_EDITOR_KEY_DOWN, e); 438 | } 439 | 440 | // Handle language change 441 | private handleLocaleUpdate() { 442 | this.forceUpdate(); 443 | } 444 | 445 | /** 446 | * Get elements 447 | */ 448 | getMdElement() { 449 | return this.nodeMdText.current; 450 | } 451 | 452 | getHtmlElement() { 453 | return this.nodeMdPreviewWrapper.current; 454 | } 455 | 456 | /** 457 | * Clear selected 458 | */ 459 | clearSelection() { 460 | if (this.nodeMdText.current) { 461 | this.nodeMdText.current.setSelectionRange(0, 0, 'none'); 462 | } 463 | } 464 | 465 | /** 466 | * Get selected 467 | * @return {Selection} 468 | */ 469 | getSelection(): Selection { 470 | const source = this.nodeMdText.current; 471 | if (!source) { 472 | return { ...initialSelection }; 473 | } 474 | const start = source.selectionStart; 475 | const end = source.selectionEnd; 476 | const text = (source.value || '').slice(start, end); 477 | return { 478 | start, 479 | end, 480 | text, 481 | }; 482 | } 483 | 484 | /** 485 | * Set selected 486 | * @param {Selection} to 487 | */ 488 | setSelection(to: { start: number; end: number }) { 489 | if (this.nodeMdText.current) { 490 | this.nodeMdText.current.setSelectionRange(to.start, to.end, 'forward'); 491 | this.nodeMdText.current.focus(); 492 | } 493 | } 494 | 495 | /** 496 | * Insert markdown text 497 | * @param type 498 | * @param option 499 | */ 500 | insertMarkdown(type: string, option: any = {}) { 501 | const curSelection = this.getSelection(); 502 | let decorateOption = option ? { ...option } : {}; 503 | if (type === 'image') { 504 | decorateOption = { 505 | ...decorateOption, 506 | target: option.target || curSelection.text || '', 507 | imageUrl: option.imageUrl || this.config.imageUrl, 508 | }; 509 | } 510 | if (type === 'link') { 511 | decorateOption = { 512 | ...decorateOption, 513 | linkUrl: this.config.linkUrl, 514 | }; 515 | } 516 | if (type === 'tab' && curSelection.start !== curSelection.end) { 517 | const curLineStart = this.getMdValue() 518 | .slice(0, curSelection.start) 519 | .lastIndexOf('\n') + 1; 520 | this.setSelection({ 521 | start: curLineStart, 522 | end: curSelection.end, 523 | }); 524 | } 525 | const decorate = getDecorated(curSelection.text, type, decorateOption); 526 | let { text } = decorate; 527 | const { selection } = decorate; 528 | if (decorate.newBlock) { 529 | const startLineInfo = getLineAndCol(this.getMdValue(), curSelection.start); 530 | const { col, curLine } = startLineInfo; 531 | if (col > 0 && curLine.length > 0) { 532 | text = `\n${text}`; 533 | if (selection) { 534 | selection.start++; 535 | selection.end++; 536 | } 537 | } 538 | let { afterText } = startLineInfo; 539 | if (curSelection.start !== curSelection.end) { 540 | afterText = getLineAndCol(this.getMdValue(), curSelection.end).afterText; 541 | } 542 | if (afterText.trim() !== '' && afterText.substr(0, 2) !== '\n\n') { 543 | if (afterText.substr(0, 1) !== '\n') { 544 | text += '\n'; 545 | } 546 | text += '\n'; 547 | } 548 | } 549 | this.insertText(text, true, selection); 550 | } 551 | 552 | /** 553 | * Insert a placeholder, and replace it when the Promise resolved 554 | * @param placeholder 555 | * @param wait 556 | */ 557 | insertPlaceholder(placeholder: string, wait: Promise) { 558 | this.insertText(placeholder, true); 559 | wait.then((str) => { 560 | const text = this.getMdValue().replace(placeholder, str); 561 | this.setText(text); 562 | }); 563 | } 564 | 565 | /** 566 | * Insert text 567 | * @param {string} value The text will be insert 568 | * @param {boolean} replaceSelected Replace selected text 569 | * @param {Selection} newSelection New selection 570 | */ 571 | insertText(value: string = '', replaceSelected: boolean = false, newSelection?: { start: number; end: number }) { 572 | const { text } = this.state; 573 | const selection = this.getSelection(); 574 | const beforeContent = text.slice(0, selection.start); 575 | const afterContent = text.slice(replaceSelected ? selection.end : selection.start, text.length); 576 | 577 | this.setText( 578 | beforeContent + value + afterContent, 579 | undefined, 580 | newSelection 581 | ? { 582 | start: newSelection.start + beforeContent.length, 583 | end: newSelection.end + beforeContent.length, 584 | } 585 | : { 586 | start: selection.start, 587 | end: selection.start, 588 | }, 589 | ); 590 | } 591 | 592 | /** 593 | * Set text, and trigger onChange event 594 | * @param {string} value 595 | * @param {any} event 596 | */ 597 | setText(value: string = '', event?: React.ChangeEvent, newSelection?: { start: number; end: number }) { 598 | const { onChangeTrigger = 'both' } = this.config; 599 | const text = value.replace(/↵/g, '\n'); 600 | if (this.state.text === value) { 601 | return; 602 | } 603 | this.setState({ text }); 604 | if (this.props.onChange && (onChangeTrigger === 'both' || onChangeTrigger === 'beforeRender')) { 605 | this.props.onChange({ text, html: this.getHtmlValue() }, event); 606 | } 607 | this.emitter.emit(this.emitter.EVENT_CHANGE, value, event, typeof event === 'undefined'); 608 | if (newSelection) { 609 | setTimeout(() => this.setSelection(newSelection)); 610 | } 611 | if (!this.hasContentChanged) { 612 | this.hasContentChanged = true; 613 | } 614 | const rendering = this.renderHTML(text); 615 | if (onChangeTrigger === 'both' || onChangeTrigger === 'afterRender') { 616 | rendering.then(() => { 617 | if (this.props.onChange) { 618 | this.props.onChange( 619 | { 620 | text: this.state.text, 621 | html: this.getHtmlValue(), 622 | }, 623 | event, 624 | ); 625 | } 626 | }); 627 | } 628 | } 629 | 630 | /** 631 | * Get text value 632 | * @return {string} 633 | */ 634 | getMdValue(): string { 635 | return this.state.text; 636 | } 637 | 638 | /** 639 | * Get rendered html 640 | * @returns {string} 641 | */ 642 | getHtmlValue(): string { 643 | if (typeof this.state.html === 'string') { 644 | return this.state.html; 645 | } 646 | if (this.nodeMdPreview.current) { 647 | return this.nodeMdPreview.current.getHtml(); 648 | } 649 | return ''; 650 | } 651 | 652 | /** 653 | * Listen keyboard events 654 | */ 655 | private keyboardListeners: KeyboardEventListener[] = []; 656 | 657 | /** 658 | * Listen keyboard events 659 | * @param {KeyboardEventListener} data 660 | */ 661 | onKeyboard(data: KeyboardEventListener | KeyboardEventListener[]) { 662 | if (Array.isArray(data)) { 663 | data.forEach((it) => this.onKeyboard(it)); 664 | return; 665 | } 666 | if (!this.keyboardListeners.includes(data)) { 667 | this.keyboardListeners.push(data); 668 | } 669 | } 670 | 671 | /** 672 | * Un-listen keyboard events 673 | * @param {KeyboardEventListener} data 674 | */ 675 | offKeyboard(data: KeyboardEventListener | KeyboardEventListener[]) { 676 | if (Array.isArray(data)) { 677 | data.forEach((it) => this.offKeyboard(it)); 678 | return; 679 | } 680 | const index = this.keyboardListeners.indexOf(data); 681 | if (index >= 0) { 682 | this.keyboardListeners.splice(index, 1); 683 | } 684 | } 685 | 686 | private handleKeyDown(e: React.KeyboardEvent) { 687 | // 遍历监听数组,找找有没有被监听 688 | for (const it of this.keyboardListeners) { 689 | if (isKeyMatch(e, it)) { 690 | e.preventDefault(); 691 | it.callback(e); 692 | return; 693 | } 694 | } 695 | // 如果没有,触发默认事件 696 | this.emitter.emit(this.emitter.EVENT_KEY_DOWN, e); 697 | } 698 | 699 | private getEventType(event: EditorEvent): string | undefined { 700 | switch (event) { 701 | case 'change': 702 | return this.emitter.EVENT_CHANGE; 703 | case 'fullscreen': 704 | return this.emitter.EVENT_FULL_SCREEN; 705 | case 'viewchange': 706 | return this.emitter.EVENT_VIEW_CHANGE; 707 | case 'keydown': 708 | return this.emitter.EVENT_KEY_DOWN; 709 | case 'editor_keydown': 710 | return this.emitter.EVENT_EDITOR_KEY_DOWN; 711 | case 'blur': 712 | return this.emitter.EVENT_BLUR; 713 | case 'focus': 714 | return this.emitter.EVENT_FOCUS; 715 | case 'scroll': 716 | return this.emitter.EVENT_SCROLL; 717 | } 718 | } 719 | 720 | /** 721 | * Listen events 722 | * @param {EditorEvent} event Event type 723 | * @param {any} cb Callback 724 | */ 725 | on(event: EditorEvent, cb: any) { 726 | const eventType = this.getEventType(event); 727 | if (eventType) { 728 | this.emitter.on(eventType, cb); 729 | } 730 | } 731 | 732 | /** 733 | * Un-listen events 734 | * @param {EditorEvent} event Event type 735 | * @param {any} cb Callback 736 | */ 737 | off(event: EditorEvent, cb: any) { 738 | const eventType = this.getEventType(event); 739 | if (eventType) { 740 | this.emitter.off(eventType, cb); 741 | } 742 | } 743 | 744 | /** 745 | * Set view property 746 | * Can show or hide: editor, preview, menu 747 | * @param {object} to 748 | */ 749 | setView(to: { md?: boolean; menu?: boolean; html?: boolean }) { 750 | const newView = { ...this.state.view, ...to }; 751 | this.setState( 752 | { 753 | view: newView, 754 | }, 755 | () => { 756 | this.emitter.emit(this.emitter.EVENT_VIEW_CHANGE, newView); 757 | }, 758 | ); 759 | } 760 | 761 | /** 762 | * Get view property 763 | * @return {object} 764 | */ 765 | getView() { 766 | return { ...this.state.view }; 767 | } 768 | 769 | /** 770 | * Enter or exit full screen 771 | * @param {boolean} enable 772 | */ 773 | fullScreen(enable: boolean) { 774 | if (this.state.fullScreen !== enable) { 775 | this.setState( 776 | { 777 | fullScreen: enable, 778 | }, 779 | () => { 780 | this.emitter.emit(this.emitter.EVENT_FULL_SCREEN, enable); 781 | }, 782 | ); 783 | } 784 | } 785 | 786 | /** 787 | * Register a plugin API 788 | * @param {string} name API name 789 | * @param {any} cb callback 790 | */ 791 | registerPluginApi(name: string, cb: any) { 792 | this.pluginApis.set(name, cb); 793 | } 794 | 795 | unregisterPluginApi(name: string) { 796 | this.pluginApis.delete(name); 797 | } 798 | 799 | /** 800 | * Call a plugin API 801 | * @param {string} name API name 802 | * @param {any} others arguments 803 | * @returns {any} 804 | */ 805 | callPluginApi(name: string, ...others: any): T { 806 | const handler = this.pluginApis.get(name); 807 | if (!handler) { 808 | throw new Error(`API ${name} not found`); 809 | } 810 | return handler(...others); 811 | } 812 | 813 | /** 814 | * Is full screen 815 | * @return {boolean} 816 | */ 817 | isFullScreen(): boolean { 818 | return this.state.fullScreen; 819 | } 820 | 821 | private uploadWithDataTransfer(items: DataTransferItemList) { 822 | const { onImageUpload } = this.config; 823 | if (!onImageUpload) { 824 | return; 825 | } 826 | const queue: Promise[] = []; 827 | Array.prototype.forEach.call(items, (it: DataTransferItem) => { 828 | if (it.kind === 'file' && it.type.includes('image')) { 829 | const file = it.getAsFile(); 830 | if (file) { 831 | const placeholder = getUploadPlaceholder(file, onImageUpload); 832 | queue.push(Promise.resolve(placeholder.placeholder)); 833 | placeholder.uploaded.then((str) => { 834 | const text = this.getMdValue().replace(placeholder.placeholder, str); 835 | const offset = str.length - placeholder.placeholder.length; 836 | // 计算出替换后的光标位置 837 | const selection = this.getSelection(); 838 | this.setText(text, undefined, { 839 | start: selection.start + offset, 840 | end: selection.start + offset, 841 | }); 842 | }); 843 | } 844 | } else if (it.kind === 'string' && it.type === 'text/plain') { 845 | queue.push(new Promise((resolve) => it.getAsString(resolve))); 846 | } 847 | }); 848 | Promise.all(queue).then((res) => { 849 | const text = res.join(''); 850 | const selection = this.getSelection(); 851 | this.insertText(text, true, { 852 | start: selection.start === selection.end ? text.length : 0, 853 | end: text.length, 854 | }); 855 | }); 856 | } 857 | 858 | render() { 859 | const { view, fullScreen, text, html } = this.state; 860 | const { id, className = '', style, name = 'textarea', autoFocus, placeholder, readOnly } = this.props; 861 | const showHideMenu = this.config.canView && this.config.canView.hideMenu && !this.config.canView.menu; 862 | const getPluginAt = (at: string) => this.state.plugins[at] || []; 863 | const isShowMenu = !!view.menu; 864 | const editorId = id ? `${id}_md` : undefined; 865 | const previewerId = id ? `${id}_html` : undefined; 866 | return ( 867 |
868 | 869 |
870 | {showHideMenu && ( 871 | 872 | 873 | 874 | 875 | 876 | )} 877 |
878 |