) {
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 |
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: '',
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: ``,
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 | 
34 |
35 | 可插拔的功能键
36 |
37 | 
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 | 
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 | 
34 |
35 | Pluggable bars
36 |
37 | 
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 | 
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 |
899 |
900 | (this.shouldSyncScroll = 'html')} onScroll={this.handlePreviewScroll}>
901 |
902 |
903 |
904 |
905 |
906 | );
907 | }
908 | }
909 |
910 | export default Editor;
911 |
--------------------------------------------------------------------------------