├── src ├── utils │ ├── weak-map.ts │ ├── debounce.ts │ └── browser.ts ├── plugins │ ├── undo-redo │ │ ├── index.ts │ │ ├── undo-redo-editor.ts │ │ └── undo-redo.ts │ ├── collaborative-editing │ │ ├── index.ts │ │ ├── collaborative-editing-editor.ts │ │ └── collaborative-editing.ts │ └── index.ts ├── model │ ├── index.ts │ ├── text-model.ts │ └── selection-model.ts ├── index.ts ├── operations │ ├── index.ts │ ├── operation.ts │ ├── insert-text-operation.ts │ ├── remove-text-operation.ts │ └── set-selection-operation.ts ├── view-model │ └── view-model.ts ├── view │ ├── index.ts │ ├── render-view.ts │ ├── preview-view.ts │ ├── source-view.ts │ ├── view-provider.ts │ ├── source-and-preview-view.ts │ └── base-view.ts ├── markdown-parse │ ├── markdown-block-creater │ │ ├── html-block-creater.ts │ │ ├── setext-heading-creater.ts │ │ ├── indented-code-block-creater.ts │ │ ├── atx-heading-creater.ts │ │ ├── thematic-break-creater.ts │ │ ├── factory.ts │ │ ├── creater.ts │ │ ├── fenced-code-block-creater.ts │ │ ├── block-quote-creater.ts │ │ ├── table-creater.ts │ │ └── list-item-creater.ts │ ├── node │ │ ├── code-node.ts │ │ ├── del-node.ts │ │ ├── emph-node.ts │ │ ├── underline-node.ts │ │ ├── image-node.ts │ │ ├── link-node.ts │ │ ├── strong-node.ts │ │ ├── document-node.ts │ │ ├── thematic-break-node.ts │ │ ├── head-node.ts │ │ ├── table-td-node.ts │ │ ├── table-th-node.ts │ │ ├── table-tbody-node.ts │ │ ├── table-tr-node.ts │ │ ├── checkbox-node.ts │ │ ├── text-node.ts │ │ ├── paragraph-node.ts │ │ ├── table-node.ts │ │ ├── html-block-node.ts │ │ ├── block-quote-node.ts │ │ ├── list-node.ts │ │ ├── code-block-node.ts │ │ ├── index.ts │ │ ├── table-thead-node.ts │ │ ├── item-node.ts │ │ └── node.ts │ ├── markdown-render │ │ ├── index.ts │ │ └── html-generate.ts │ ├── index.ts │ ├── markdown-parser-line │ │ ├── common.ts │ │ ├── delimiter-stack.ts │ │ └── index.ts │ ├── tree-walker.ts │ ├── funs.ts │ └── markdown-parser-block │ │ └── index.ts ├── event │ ├── index.ts │ ├── view-event.ts │ ├── mouse-event.ts │ ├── hotkeys.ts │ ├── base-event.ts │ └── keyboard-event.ts ├── assets │ └── index.less └── editor.ts ├── demo-en.gif ├── demo-zh.gif ├── markdown.jpeg ├── .gitignore ├── demo ├── index.ts ├── text.ts └── index.html ├── LICENSE ├── tsconfig.json ├── package.json ├── README.md ├── webpack.config.sdk.js └── webpack.config.js /src/utils/weak-map.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/plugins/undo-redo/index.ts: -------------------------------------------------------------------------------- 1 | export * from './undo-redo' -------------------------------------------------------------------------------- /src/plugins/collaborative-editing/index.ts: -------------------------------------------------------------------------------- 1 | export * from './collaborative-editing' -------------------------------------------------------------------------------- /src/model/index.ts: -------------------------------------------------------------------------------- 1 | export * from './selection-model'; 2 | export * from './text-model'; -------------------------------------------------------------------------------- /demo-en.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ben-love-zy/web-editor-markdown/HEAD/demo-en.gif -------------------------------------------------------------------------------- /demo-zh.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ben-love-zy/web-editor-markdown/HEAD/demo-zh.gif -------------------------------------------------------------------------------- /markdown.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ben-love-zy/web-editor-markdown/HEAD/markdown.jpeg -------------------------------------------------------------------------------- /src/plugins/index.ts: -------------------------------------------------------------------------------- 1 | export * from './collaborative-editing'; 2 | export * from './undo-redo'; -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './editor'; 2 | export * from './model'; 3 | export * from './operations'; 4 | export * from './plugins'; -------------------------------------------------------------------------------- /src/operations/index.ts: -------------------------------------------------------------------------------- 1 | export * from './insert-text-operation'; 2 | export * from './remove-text-operation'; 3 | export * from './set-selection-operation'; 4 | export * from './operation'; -------------------------------------------------------------------------------- /src/view-model/view-model.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: 视图模型 3 | * @Author: ZengYong 4 | * @CreateDate: 2021-09-23 17:09:13 5 | */ 6 | class ViewModel { 7 | 8 | } 9 | export default ViewModel; -------------------------------------------------------------------------------- /src/view/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./base-view"; 2 | export * from "./preview-view"; 3 | export * from "./render-view"; 4 | export * from "./source-and-preview-view"; 5 | export * from "./source-view"; 6 | export * from "./view-provider"; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | /demo0000 5 | /src/markdown-parse/lib 6 | 7 | .env.local 8 | .env.*.local 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarg-error.log* 12 | 13 | .idea 14 | .vscode 15 | *.suo 16 | *.ntvs* 17 | *.njsproj 18 | *.sln 19 | *.sw? -------------------------------------------------------------------------------- /src/plugins/collaborative-editing/collaborative-editing-editor.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: 协同编辑以扩展方式添加 3 | * @Author: ZengYong 4 | * @CreateDate: 2022-10-30 19:09:04 5 | */ 6 | import { Operation } from "../.."; 7 | 8 | export class CollaborativeEditor { 9 | operations: Operation[]; 10 | } 11 | export default CollaborativeEditor; -------------------------------------------------------------------------------- /src/utils/debounce.ts: -------------------------------------------------------------------------------- 1 | export function debounce(fn: Function, delay: number = 0) { 2 | let timer: number | null; 3 | // let args = arguments; 4 | return (...args: any) => { 5 | if (timer) { 6 | window.clearTimeout(timer) 7 | } 8 | timer = window.setTimeout(() => { 9 | fn(...args); 10 | // fn.apply(this, args); 11 | }, delay) 12 | } 13 | } -------------------------------------------------------------------------------- /src/markdown-parse/markdown-block-creater/html-block-creater.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: html识别 3 | * @Author: ZengYong 4 | * @CreateDate: 2021-10-21 11:21:57 5 | */ 6 | import Creater, { ICreaterProps } from "./creater"; 7 | export class HtmlBlockCreater extends Creater { 8 | canCreate (task: ICreaterProps) { 9 | return null; // 暂时不支持 10 | } 11 | } 12 | export default HtmlBlockCreater; -------------------------------------------------------------------------------- /src/operations/operation.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: OP 父类 3 | * @Author: ZengYong 4 | * @CreateDate: 2021-09-17 19:15:50 5 | */ 6 | import Editor from "../editor"; 7 | 8 | export class Operation { 9 | 10 | constructor () {} 11 | 12 | /** OP 应用。OP 子类自己实现差异化 */ 13 | apply (editor: Editor) {} 14 | 15 | /** 反转自己,撤销回退使用 */ 16 | inverse () {} 17 | 18 | } 19 | export default Operation; -------------------------------------------------------------------------------- /src/markdown-parse/markdown-block-creater/setext-heading-creater.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: setext标题识别,即横杠 3 | * @Author: ZengYong 4 | * @CreateDate: 2021-10-21 11:21:57 5 | */ 6 | 7 | import Creater, { ICreaterProps } from "./creater"; 8 | export class SetextHeadingCreater extends Creater { 9 | canCreate (task: ICreaterProps) { 10 | return null; // 暂时不支持 11 | } 12 | } 13 | export default SetextHeadingCreater; -------------------------------------------------------------------------------- /src/markdown-parse/markdown-block-creater/indented-code-block-creater.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: 通过4个空格或者缩进触发的代码块 3 | * @Author: ZengYong 4 | * @CreateDate: 2021-10-21 11:21:57 5 | */ 6 | 7 | import Creater, { ICreaterProps } from "./creater"; 8 | export class IndentedCodeBlockCreater extends Creater { 9 | canCreate (task: ICreaterProps) { 10 | return null; // 暂时不支持 11 | } 12 | } 13 | export default IndentedCodeBlockCreater; -------------------------------------------------------------------------------- /src/plugins/undo-redo/undo-redo-editor.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: 撤销回退以扩展方式添加 3 | * @Author: ZengYong 4 | * @CreateDate: 2021-11-17 12:20:04 5 | */ 6 | import { Operation } from "../.."; 7 | 8 | export interface History { 9 | redos: Operation[][] 10 | undos: Operation[][] 11 | } 12 | 13 | export class UndoRedoEditor { 14 | history: History; 15 | operations: Operation[]; 16 | undo: () => void; 17 | redo: () => void; 18 | } 19 | export default UndoRedoEditor; -------------------------------------------------------------------------------- /src/utils/browser.ts: -------------------------------------------------------------------------------- 1 | 2 | export const IS_APPLE = 3 | typeof navigator !== 'undefined' && /Mac OS X/.test(navigator.userAgent) 4 | 5 | export const IS_ANDROID = 6 | typeof navigator !== 'undefined' && /Android/.test(navigator.userAgent) 7 | 8 | export const IS_FIREFOX = 9 | typeof navigator !== 'undefined' && 10 | /^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent) 11 | 12 | export const IS_SAFARI = 13 | typeof navigator !== 'undefined' && 14 | /Version\/[\d\.]+.*Safari/.test(navigator.userAgent) 15 | -------------------------------------------------------------------------------- /src/plugins/collaborative-editing/collaborative-editing.ts: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | * @Description: 协同编辑扩展 4 | * @Author: ZengYong 5 | * @CreateDate: 2022-10-30 19:09:40 6 | */ 7 | import { Editor, Operation } from "../../index" 8 | import { CollaborativeEditor } from "./collaborative-editing-editor"; 9 | 10 | export const withCollaborative = (editor: T) => { 11 | 12 | const collaborativeEditor = editor as T & CollaborativeEditor; 13 | const apply = editor.apply.bind(editor); 14 | 15 | collaborativeEditor.apply = (op: Operation) => { 16 | apply(op); 17 | } 18 | 19 | return collaborativeEditor; 20 | } -------------------------------------------------------------------------------- /src/markdown-parse/node/code-node.ts: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | * @Description: 行内代码 4 | * @Author: ZengYong 5 | * @CreateDate: 2021-11-19 15:52:14 6 | */ 7 | import MNode, { NodeType } from "./node"; 8 | export class CodeNode extends MNode { 9 | 10 | readonly isContainer = false; 11 | readonly isBlockContainer = false; 12 | readonly canContainText = true; 13 | readonly isParagraph = false; // 是否是段落block 14 | content: string = ''; 15 | 16 | 17 | constructor (sourceStart: number, content: string) { 18 | super(sourceStart); 19 | this.type = NodeType.Code; 20 | this.content = content; 21 | } 22 | 23 | 24 | } 25 | export default CodeNode; -------------------------------------------------------------------------------- /src/markdown-parse/node/del-node.ts: -------------------------------------------------------------------------------- 1 | 2 | import MNode, { NodeType } from "./node"; 3 | export class DelNode extends MNode { 4 | 5 | readonly isContainer = true; 6 | readonly isBlockContainer = false; 7 | readonly canContainText = true; 8 | readonly isParagraph = false; 9 | 10 | 11 | constructor (sourceStart: number) { 12 | super(sourceStart); 13 | this.type = NodeType.Del; 14 | } 15 | 16 | // @Override 17 | // continue () { 18 | // return true; 19 | // } 20 | 21 | // @Override 22 | // finalize() { 23 | 24 | // } 25 | 26 | // @Override 27 | // canContain(mnode: MNode) { 28 | // return false; 29 | // } 30 | } 31 | export default DelNode; -------------------------------------------------------------------------------- /src/markdown-parse/node/emph-node.ts: -------------------------------------------------------------------------------- 1 | 2 | import MNode, { NodeType } from "./node"; 3 | export class EmphNode extends MNode { 4 | 5 | readonly isContainer = true; 6 | readonly isBlockContainer = false; 7 | readonly canContainText = true; 8 | readonly isParagraph = false; 9 | 10 | 11 | constructor (sourceStart: number) { 12 | super(sourceStart); 13 | this.type = NodeType.Emph; 14 | } 15 | 16 | // @Override 17 | // continue () { 18 | // return true; 19 | // } 20 | 21 | // @Override 22 | // finalize() { 23 | 24 | // } 25 | 26 | // @Override 27 | // canContain(mnode: MNode) { 28 | // return false; 29 | // } 30 | } 31 | export default EmphNode; -------------------------------------------------------------------------------- /src/markdown-parse/node/underline-node.ts: -------------------------------------------------------------------------------- 1 | 2 | import MNode, { NodeType } from "./node"; 3 | export class UnderlineNode extends MNode { 4 | 5 | readonly isContainer = false; 6 | readonly isBlockContainer = false; 7 | readonly canContainText = true; 8 | readonly isParagraph = false; 9 | 10 | 11 | constructor (sourceStart: number) { 12 | super(sourceStart); 13 | this.type = NodeType.Underline; 14 | } 15 | 16 | // @Override 17 | // continue () { 18 | // return true; 19 | // } 20 | 21 | // @Override 22 | // finalize() { 23 | 24 | // } 25 | 26 | // @Override 27 | // canContain(mnode: MNode) { 28 | // return false; 29 | // } 30 | } 31 | export default UnderlineNode; -------------------------------------------------------------------------------- /src/view/render-view.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: 所见即所得模式(编辑模式)视图 3 | * @Author: ZengYong 4 | * @CreateDate: 2021-09-18 16:39:41 5 | */ 6 | import BaseView from "./base-view"; 7 | import { TextModel, SelectionModel, SelectionCustom } from "../model/"; 8 | 9 | class RenderView extends BaseView { 10 | 11 | /** @override 将选区模型转换成 Dom 的真实选区 */ 12 | customSelToDomSel (customSelection: SelectionCustom) { 13 | return super.customSelToDomSel(customSelection); 14 | } 15 | 16 | /** @override 将 Dom 真实选区转换成选区模型 */ 17 | domSelToCustomSel (domSelection: Selection) { 18 | return super.domSelToCustomSel(domSelection); 19 | } 20 | 21 | /** @override */ 22 | render () { 23 | super.render(); 24 | } 25 | } 26 | export default RenderView; -------------------------------------------------------------------------------- /src/markdown-parse/node/image-node.ts: -------------------------------------------------------------------------------- 1 | 2 | import MNode, { NodeType } from "./node"; 3 | export class ImageNode extends MNode { 4 | 5 | readonly isContainer = true; 6 | readonly isBlockContainer = false; 7 | readonly canContainText = true; 8 | readonly isParagraph = false; 9 | src: string; 10 | 11 | constructor (sourceStart: number, src: string) { 12 | super(sourceStart); 13 | this.type = NodeType.Image; 14 | this.src = src; 15 | } 16 | 17 | // @Override 18 | // continue () { 19 | // return true; 20 | // } 21 | 22 | // @Override 23 | // finalize() { 24 | 25 | // } 26 | 27 | // @Override 28 | // canContain(mnode: MNode) { 29 | // return false; 30 | // } 31 | } 32 | export default ImageNode; -------------------------------------------------------------------------------- /src/markdown-parse/node/link-node.ts: -------------------------------------------------------------------------------- 1 | 2 | import MNode, { NodeType } from "./node"; 3 | export class LinkNode extends MNode { 4 | 5 | readonly isContainer = true; 6 | readonly isBlockContainer = false; 7 | readonly canContainText = true; 8 | readonly isParagraph = false; 9 | href: string; 10 | 11 | constructor (sourceStart: number, href: string) { 12 | super(sourceStart); 13 | this.type = NodeType.Link; 14 | this.href = href; 15 | } 16 | 17 | // @Override 18 | // continue () { 19 | // return true; 20 | // } 21 | 22 | // @Override 23 | // finalize() { 24 | 25 | // } 26 | 27 | // @Override 28 | // canContain(mnode: MNode) { 29 | // return false; 30 | // } 31 | } 32 | export default LinkNode; -------------------------------------------------------------------------------- /src/markdown-parse/node/strong-node.ts: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | * @Description: 加粗节点 4 | * @Author: ZengYong 5 | * @CreateDate: 2021-10-22 17:47:48 6 | */ 7 | import MNode, { NodeType } from "./node"; 8 | export class StrongNode extends MNode { 9 | 10 | readonly isContainer = true; 11 | readonly isBlockContainer = false; 12 | readonly canContainText = true; 13 | readonly isParagraph = false; 14 | 15 | constructor (sourceStart: number) { 16 | super(sourceStart); 17 | this.type = NodeType.Strong; 18 | } 19 | 20 | // @Override 21 | // continue () { 22 | // return false; 23 | // } 24 | 25 | // @Override 26 | // finalize() { 27 | 28 | // } 29 | 30 | // @Override 31 | // canContain(mnode: MNode) { 32 | // return false; 33 | // } 34 | } 35 | export default StrongNode; -------------------------------------------------------------------------------- /src/view/preview-view.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: 预览模式(只读模式)视图 3 | * @Author: ZengYong 4 | * @CreateDate: 2021-09-18 16:39:41 5 | */ 6 | import { TextModel, SelectionModel, SelectionCustom } from "../model/"; 7 | import BaseView from "./base-view"; 8 | import markdown from "../markdown-parse" 9 | 10 | class PreviewView extends BaseView { 11 | 12 | constructor (textModel: TextModel, selectionModel: SelectionModel, viewContainer: HTMLElement) { 13 | super(textModel, selectionModel, viewContainer); 14 | this.viewContainer_.setAttribute('contenteditable', 'false'); 15 | } 16 | 17 | addListeners () { 18 | 19 | } 20 | 21 | /** @override */ 22 | render () { 23 | this.viewContainer_.innerHTML = markdown.md2html(this.textModel_.getSpacer()) 24 | } 25 | } 26 | export default PreviewView; -------------------------------------------------------------------------------- /src/markdown-parse/node/document-node.ts: -------------------------------------------------------------------------------- 1 | 2 | import MNode, { NodeType } from "./node"; 3 | export class DocumentNode extends MNode { 4 | 5 | readonly isContainer = true; 6 | readonly isBlockContainer = false; 7 | readonly canContainText = false; 8 | readonly isParagraph = true; 9 | 10 | 11 | constructor (sourceStart: number) { 12 | super(sourceStart); 13 | this.type = NodeType.Document; 14 | } 15 | 16 | // @Override 17 | continue (currentLine: string, offset: number, column: number) { 18 | return { offset: -1, column: -1, spaceInTab: -1 }; 19 | } 20 | 21 | // @Override 22 | // finalize() { 23 | // super.finalize(); 24 | // } 25 | 26 | // @Override 27 | canContain(mnode: MNode) { 28 | return mnode.type !== NodeType.Item; 29 | } 30 | } 31 | export default DocumentNode; -------------------------------------------------------------------------------- /src/markdown-parse/node/thematic-break-node.ts: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | * @Description: 分割线 4 | * @Author: ZengYong 5 | * @CreateDate: 2021-10-22 17:46:37 6 | */ 7 | import MNode, { NodeType } from "./node"; 8 | export class ThematicBreakNode extends MNode { 9 | 10 | readonly isContainer = false; 11 | readonly isBlockContainer = false; 12 | readonly canContainText = true; // 不需要创建p标签,因为没有内容 13 | readonly isParagraph = true; 14 | 15 | constructor (sourceStart: number) { 16 | super(sourceStart); 17 | this.type = NodeType.ThematicBreak; 18 | } 19 | 20 | continue (currentLine: string, offset: number, column: number) { 21 | return null; 22 | } 23 | 24 | // finalize() { 25 | // super.finalize(); 26 | // } 27 | 28 | canContain(mnode: MNode) { 29 | return false; 30 | } 31 | } 32 | export default ThematicBreakNode; -------------------------------------------------------------------------------- /src/markdown-parse/markdown-render/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: 将语法树 AST doc 转换为 HTML 渲染 3 | * @Author: ZengYong 4 | * @CreateDate: 2021-10-20 15:19:48 5 | */ 6 | 7 | import { SubNode } from "../node"; 8 | import HtmlGenerate from "./html-generate"; 9 | import MTreeWalker from "../tree-walker"; 10 | 11 | class MarkdownRender { 12 | 13 | render (mnode: SubNode, htmlGenerate?: HtmlGenerate) { 14 | if (!htmlGenerate) { 15 | htmlGenerate = new HtmlGenerate(); 16 | } 17 | const walker = new MTreeWalker(mnode); 18 | let buffer = ''; 19 | let current; 20 | while (current = walker.next()) { 21 | if (htmlGenerate[current.mnode.type]) { 22 | buffer = htmlGenerate[current.mnode.type](buffer, current.mnode, current.close) || ''; 23 | } 24 | } 25 | return buffer; 26 | } 27 | } 28 | export default MarkdownRender; -------------------------------------------------------------------------------- /src/markdown-parse/node/head-node.ts: -------------------------------------------------------------------------------- 1 | 2 | import MNode, { NodeType } from "./node"; 3 | export class HeadNode extends MNode { 4 | 5 | readonly isContainer = true; 6 | readonly isBlockContainer = false; 7 | readonly canContainText = true; 8 | readonly isParagraph = true; 9 | level: number = 1; 10 | 11 | constructor (sourceStart: number, level: number) { 12 | super(sourceStart); 13 | this.type = NodeType.Head; 14 | this.level = level; 15 | this.blockMarkerBefore = '#'.repeat(level) + ' '; 16 | } 17 | 18 | // @Override 19 | continue (currentLine: string, offset: number, column: number) { 20 | return null; 21 | } 22 | 23 | // @Override 24 | // finalize() { 25 | // super.finalize(); 26 | // } 27 | 28 | // @Override 29 | canContain(mnode: MNode) { 30 | return false; 31 | } 32 | } 33 | export default HeadNode; -------------------------------------------------------------------------------- /src/markdown-parse/node/table-td-node.ts: -------------------------------------------------------------------------------- 1 | 2 | import MNode, { NodeType } from "./node"; 3 | export class TableTdNode extends MNode { 4 | 5 | readonly isContainer = true; 6 | readonly isBlockContainer = false; 7 | readonly canContainText = true; 8 | readonly isParagraph = true; 9 | align: string = ''; 10 | 11 | constructor (sourceStart: number, align: string = 'left') { 12 | super(sourceStart); 13 | this.type = NodeType.TableTd; 14 | this.align = align; 15 | } 16 | 17 | // @Override 18 | continue (currentLine: string, offset: number, column: number) { 19 | let continueResult: any = null; 20 | return continueResult; 21 | } 22 | 23 | // @Override 24 | // finalize() { 25 | // super.finalize(); 26 | // } 27 | 28 | // @Override 29 | // canContain(mnode: MNode) { 30 | // return false; 31 | // } 32 | } 33 | export default TableTdNode; -------------------------------------------------------------------------------- /src/markdown-parse/node/table-th-node.ts: -------------------------------------------------------------------------------- 1 | 2 | import MNode, { NodeType } from "./node"; 3 | export class TableThNode extends MNode { 4 | 5 | readonly isContainer = true; 6 | readonly isBlockContainer = false; 7 | readonly canContainText = true; 8 | readonly isParagraph = true; 9 | align: string = ''; 10 | 11 | constructor (sourceStart: number, align: string = 'left') { 12 | super(sourceStart); 13 | this.type = NodeType.TableTh; 14 | this.align = align; 15 | 16 | } 17 | 18 | // @Override 19 | continue (currentLine: string, offset: number, column: number) { 20 | let continueResult: any = null; 21 | return continueResult; 22 | } 23 | 24 | // @Override 25 | // finalize() { 26 | // super.finalize(); 27 | // } 28 | 29 | // @Override 30 | // canContain(mnode: MNode) { 31 | // return false; 32 | // } 33 | } 34 | export default TableThNode; -------------------------------------------------------------------------------- /src/markdown-parse/node/table-tbody-node.ts: -------------------------------------------------------------------------------- 1 | 2 | import MNode, { NodeType } from "./node"; 3 | export class TableTbodyNode extends MNode { 4 | 5 | readonly isContainer = true; 6 | readonly isBlockContainer = true; 7 | readonly canContainText = false; 8 | readonly isParagraph = true; 9 | 10 | constructor (sourceStart: number) { 11 | super(sourceStart); 12 | this.type = NodeType.TableTbody; 13 | } 14 | 15 | // @Override 16 | continue (currentLine: string, offset: number, column: number) { 17 | let continueResult: any = null; 18 | continueResult = { offset: -1, column: -1, spaceInTab: -1 }; 19 | return continueResult; 20 | } 21 | 22 | // @Override 23 | // finalize() { 24 | // super.finalize(); 25 | // } 26 | 27 | // @Override 28 | canContain(mnode: MNode) { 29 | return mnode.type === NodeType.TableTr; 30 | } 31 | } 32 | export default TableTbodyNode; -------------------------------------------------------------------------------- /src/markdown-parse/node/table-tr-node.ts: -------------------------------------------------------------------------------- 1 | 2 | import MNode, { NodeType } from "./node"; 3 | export class TableTrNode extends MNode { 4 | 5 | readonly isContainer = true; 6 | readonly isBlockContainer = false; 7 | readonly canContainText = true; // 不需要p标签 8 | readonly isParagraph = true; 9 | isheader: boolean = false; 10 | 11 | constructor (sourceStart: number, isheader: boolean = false) { 12 | super(sourceStart); 13 | this.type = NodeType.TableTr; 14 | this.isheader = isheader; 15 | } 16 | 17 | // @Override 18 | continue (currentLine: string, offset: number, column: number) { 19 | let continueResult: any = null; 20 | return continueResult; 21 | } 22 | 23 | // @Override 24 | // finalize() { 25 | // super.finalize(); 26 | // } 27 | 28 | // @Override 29 | canContain(mnode: MNode) { 30 | return false; 31 | } 32 | } 33 | export default TableTrNode; -------------------------------------------------------------------------------- /src/markdown-parse/node/checkbox-node.ts: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | * @Description: 文本节点,也是实际情况生成最多的节点 4 | * @Author: ZengYong 5 | * @CreateDate: 2021-10-22 17:46:04 6 | */ 7 | import MNode, { NodeType } from "./node"; 8 | export class CheckboxNode extends MNode { 9 | 10 | readonly isContainer = false; 11 | readonly isBlockContainer = false; 12 | readonly canContainText = false; 13 | readonly isParagraph = false; 14 | checked: boolean = false; 15 | 16 | constructor (sourceStart: number, checked: boolean = false) { 17 | super(sourceStart); 18 | this.type = NodeType.Checkbox; 19 | this.checked = checked; 20 | } 21 | 22 | // @Override 23 | // continue () { 24 | // return false; 25 | // } 26 | 27 | // @Override 28 | // finalize() { 29 | 30 | // } 31 | 32 | // @Override 33 | // canContain(mnode: MNode) { 34 | // return false; 35 | // } 36 | } 37 | export default CheckboxNode; -------------------------------------------------------------------------------- /src/markdown-parse/node/text-node.ts: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | * @Description: 文本节点,也是实际情况生成最多的节点 4 | * @Author: ZengYong 5 | * @CreateDate: 2021-10-22 17:46:04 6 | */ 7 | import MNode, { NodeType } from "./node"; 8 | export class TextNode extends MNode { 9 | 10 | readonly isContainer = false; 11 | readonly isBlockContainer = false; 12 | readonly canContainText = true; 13 | readonly isParagraph = false; 14 | text: string; 15 | 16 | constructor (sourceStart: number, text: string) { 17 | super(sourceStart); 18 | this.type = NodeType.Text; 19 | this.text = text; 20 | this.sourceEnd = sourceStart + text.length; 21 | } 22 | 23 | // @Override 24 | // continue () { 25 | // return false; 26 | // } 27 | 28 | // @Override 29 | // finalize() { 30 | 31 | // } 32 | 33 | // @Override 34 | // canContain(mnode: MNode) { 35 | // return false; 36 | // } 37 | } 38 | export default TextNode; -------------------------------------------------------------------------------- /src/markdown-parse/node/paragraph-node.ts: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | * @Description: 段落节点。即对应 p 标签 4 | * @Author: ZengYong 5 | * @CreateDate: 2021-10-22 17:48:12 6 | */ 7 | 8 | import MNode, { NodeType } from "./node"; 9 | export class ParagraphNode extends MNode { 10 | 11 | readonly isContainer = true; 12 | readonly isBlockContainer = false; 13 | readonly canContainText = true; 14 | readonly isParagraph = true; 15 | 16 | 17 | constructor (sourceStart: number) { 18 | super(sourceStart); 19 | this.type = NodeType.Paragraph; 20 | } 21 | 22 | // @Override 23 | continue (currentLine: string, offset: number, column: number) { 24 | return null; 25 | } 26 | 27 | // @Override 28 | finalize(sourceEnd?: number) { 29 | super.finalize(sourceEnd); 30 | // TODO 引用链接 map 解析 31 | } 32 | 33 | // @Override 34 | canContain(mnode: MNode) { 35 | return false; 36 | } 37 | } 38 | export default ParagraphNode; -------------------------------------------------------------------------------- /src/markdown-parse/markdown-block-creater/atx-heading-creater.ts: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | * @Description: ATX 标题识别,即# 4 | * @Author: ZengYong 5 | * @CreateDate: 2021-10-21 11:21:57 6 | */ 7 | import Creater, { ICreaterProps } from "./creater"; 8 | import { HeadNode } from "../node"; 9 | import { advanceOffset } from "../funs" 10 | export class AtxHeadingCreater extends Creater { 11 | canCreate (task: ICreaterProps) { 12 | let createResult = null; 13 | const match = task.line.slice(task.offset).match(/^#{1,6}(?:[ \t]+)/); 14 | if (match) { 15 | const result = advanceOffset(task.line, task.offset, task.column, match[0].length); 16 | createResult = { 17 | offset: result.offset, 18 | column: result.column, 19 | spaceInTab: result.spaceInTab, 20 | mnode: new HeadNode(task.sourceStart, match[0].trim().length) 21 | } 22 | } 23 | return createResult; 24 | } 25 | } 26 | export default AtxHeadingCreater; -------------------------------------------------------------------------------- /src/markdown-parse/node/table-node.ts: -------------------------------------------------------------------------------- 1 | 2 | import MNode, { NodeType } from "./node"; 3 | export class TableNode extends MNode { 4 | 5 | readonly isContainer = true; 6 | readonly isBlockContainer = true; 7 | readonly canContainText = false; 8 | readonly isParagraph = true; 9 | aligns: string[] = []; 10 | 11 | constructor (sourceStart: number) { 12 | super(sourceStart); 13 | this.type = NodeType.Table; 14 | } 15 | 16 | // @Override 17 | continue (currentLine: string, offset: number, column: number) { 18 | let continueResult: any = null; 19 | if (currentLine.length) { 20 | continueResult = { offset: -1, column: -1, spaceInTab: -1 }; 21 | } 22 | return continueResult; 23 | 24 | } 25 | 26 | // @Override 27 | // finalize() { 28 | // super.finalize(); 29 | // } 30 | 31 | // @Override 32 | canContain(mnode: MNode) { 33 | return mnode.type === NodeType.TableThead || mnode.type === NodeType.TableTbody 34 | } 35 | } 36 | export default TableNode; -------------------------------------------------------------------------------- /src/operations/insert-text-operation.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: 插入字符串 OP,所有 OP 主要操作数据模型 3 | * @Author: ZengYong 4 | * @CreateDate: 2021-09-17 19:15:18 5 | */ 6 | import Operation from "./operation"; 7 | import Editor from "../editor"; 8 | import RemoveTextOperation from "./remove-text-operation"; 9 | 10 | export class InsertTextOperation extends Operation{ 11 | 12 | protected spacers_: string; 13 | protected insertIndex_: number; 14 | 15 | constructor (spacers: string, insertIndex: number) { 16 | super(); 17 | this.spacers_ = spacers; 18 | this.insertIndex_ = insertIndex; 19 | } 20 | 21 | apply (editor: Editor) { 22 | editor.getTextModel().insert(this.insertIndex_, this.spacers_); 23 | } 24 | 25 | inverse () { 26 | return new RemoveTextOperation(this.insertIndex_, this.insertIndex_ + this.spacers_.length); 27 | } 28 | 29 | getSapcers () { 30 | return this.spacers_; 31 | } 32 | 33 | getInsertIndex_ () { 34 | return this.insertIndex_; 35 | } 36 | } 37 | export default InsertTextOperation; -------------------------------------------------------------------------------- /src/operations/remove-text-operation.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: 删除字符串 OP 3 | * @Author: ZengYong 4 | * @CreateDate: 2021-09-17 19:16:07 5 | */ 6 | import Operation from "./operation"; 7 | import Editor from "../editor"; 8 | import InsertTextOperation from "./insert-text-operation"; 9 | 10 | export class RemoveTextOperation extends Operation { 11 | 12 | protected startIndex_: number; 13 | protected endIndex_: number; 14 | protected removeText_: string; 15 | 16 | constructor (startIndex_: number, endIndex_: number) { 17 | super(); 18 | this.startIndex_ = startIndex_; 19 | this.endIndex_ = endIndex_; 20 | } 21 | 22 | apply (editor: Editor) { 23 | this.removeText_ = editor.getTextModel().remove(this.startIndex_, this.endIndex_); 24 | } 25 | 26 | inverse () { 27 | return new InsertTextOperation(this.removeText_, this.startIndex_); 28 | } 29 | 30 | getStartIndex () { 31 | return this.startIndex_; 32 | } 33 | 34 | getEndIndex () { 35 | return this.endIndex_; 36 | } 37 | } 38 | export default RemoveTextOperation; -------------------------------------------------------------------------------- /src/markdown-parse/node/html-block-node.ts: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | * @Description: 文本节点,也是实际情况生成最多的节点 4 | * @Author: ZengYong 5 | * @CreateDate: 2021-10-22 17:46:04 6 | */ 7 | import MNode, { NodeType } from "./node"; 8 | export class HtmlBlockNode extends MNode { 9 | 10 | readonly isContainer = false; 11 | readonly isBlockContainer = false; 12 | readonly canContainText = true; 13 | readonly isParagraph = true; 14 | htmlContent: string = ''; 15 | isCloseTag: boolean; 16 | 17 | constructor (sourceStart: number, htmlContent: string, isCloseTag: boolean) { 18 | super(sourceStart); 19 | this.type = NodeType.HtmlBlock; 20 | this.htmlContent = htmlContent; 21 | this.isCloseTag = isCloseTag; 22 | } 23 | 24 | // @Override 25 | continue (currentLine: string, offset: number, column: number) { 26 | return { offset: -1, column: -1, spaceInTab: -1};; 27 | } 28 | 29 | // @Override 30 | // finalize() { 31 | // super.finalize(); 32 | // } 33 | 34 | // @Override 35 | canContain(mnode: MNode) { 36 | return false; 37 | } 38 | } 39 | export default HtmlBlockNode; -------------------------------------------------------------------------------- /demo/index.ts: -------------------------------------------------------------------------------- 1 | import { Editor, EditorViewMode, withUndoRedo } from "../src"; 2 | import { text } from "./text"; 3 | const container = document.getElementById('myEditor'); 4 | 5 | let editor: Editor; 6 | if (container) { 7 | editor = withUndoRedo(new Editor(container, { placeholder: 'Please input your texts'})); 8 | editor.insertTextAtCursor(text); 9 | console.log('all contents', editor.getContent()); 10 | const btns = document.getElementsByClassName('mode-btn'); 11 | window['changeMode'] = (n: number) => { 12 | for (let i = 0; i < btns.length; i++) { 13 | btns[i].setAttribute('class', 'mode-btn'); 14 | } 15 | btns[n - 1].setAttribute('class', 'mode-btn curr'); 16 | switch (n) { 17 | case 1: editor.switchViewMode(EditorViewMode.RENDER);break; 18 | case 2: editor.switchViewMode(EditorViewMode.SOURCE_AND_PREVIEW);break; 19 | case 3: editor.switchViewMode(EditorViewMode.SOURCE);break; 20 | case 4: editor.switchViewMode(EditorViewMode.PREVIEW);break; 21 | default: editor.switchViewMode(EditorViewMode.RENDER);break; 22 | } 23 | } 24 | window['changeMode'](1); 25 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ben-love-zy 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/view/source-view.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: 源码模式(编辑模式)视图 3 | * @Author: ZengYong 4 | * @CreateDate: 2021-09-18 16:39:41 5 | */ 6 | import BaseView, { IDomPoint } from "./base-view"; 7 | import { TextModel, SelectionModel, SelectionCustom } from "../model/"; 8 | import { escapeXml } from '../markdown-parse/funs'; 9 | 10 | class SourceView extends BaseView { 11 | 12 | customPointToDomPoint (customPoint: number) { 13 | let domPoint = { 14 | domNode: this.viewContainer_, 15 | domOffset: 0 16 | } 17 | if (this.viewContainer_.childNodes.length) { 18 | domPoint = { 19 | domNode: this.viewContainer_.childNodes[0] as HTMLElement, 20 | domOffset: customPoint 21 | } 22 | } 23 | return domPoint; 24 | } 25 | 26 | domPointToCustomPoint (domPoint: IDomPoint) { 27 | return domPoint.domOffset; 28 | } 29 | 30 | /** @override */ 31 | render () { 32 | this.viewContainer_.innerHTML = escapeXml(this.textModel_.getSpacer())+'\n'; // 换行符是解决换行时不光标不跟随的问题 33 | this.updateDomSelection(); 34 | // this.viewContainer_.scrollTop = this.viewContainer_.scrollHeight; 35 | } 36 | } 37 | export default SourceView; -------------------------------------------------------------------------------- /src/event/index.ts: -------------------------------------------------------------------------------- 1 | import KeyboardEventHandler from "./keyboard-event"; 2 | import MouseEventHandler from "./mouse-event"; 3 | import ViewEventHandler from "./view-event"; 4 | import { Editor } from ".."; 5 | import { View } from '../view' 6 | 7 | /* 8 | * @Description: 事件集中处理 9 | * @Author: ZengYong 10 | * @CreateDate: 2021-09-23 18:47:12 11 | */ 12 | export class EventHandler { 13 | protected keyboardEventHandler_: KeyboardEventHandler; 14 | protected mouseEventHandler_: MouseEventHandler; 15 | protected viewEventHandler_: ViewEventHandler; 16 | 17 | constructor (editor: Editor, view: View) { 18 | this.keyboardEventHandler_ = new KeyboardEventHandler(editor, view); 19 | this.mouseEventHandler_ = new MouseEventHandler(editor, view); 20 | this.viewEventHandler_ = new ViewEventHandler(editor, view); 21 | } 22 | 23 | addListeners () { 24 | this.keyboardEventHandler_.addListeners(); 25 | this.mouseEventHandler_.addListeners(); 26 | this.viewEventHandler_.addListeners(); 27 | } 28 | 29 | dispose () { 30 | this.keyboardEventHandler_.dispose(); 31 | this.mouseEventHandler_.dispose(); 32 | this.viewEventHandler_.dispose(); 33 | } 34 | } 35 | export default EventHandler; -------------------------------------------------------------------------------- /src/markdown-parse/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: Markdown 单例转换类,用于 字符串、vdom(node)、html等互转 3 | * @Author: ZengYong 4 | * @CreateDate: 2021-09-23 17:44:48 5 | */ 6 | 7 | import MarkdownParserBlock from "./markdown-parser-block"; 8 | import MarkdownParserLine from "./markdown-parser-line"; 9 | import MarkdownRender from "./markdown-render"; 10 | import { SubNode, NodeType } from "./node"; 11 | import MTreeWalker from "./tree-walker"; 12 | 13 | class Markdown { 14 | 15 | /** 块解析 */ 16 | private parseBlock_ (md: string) { 17 | return new MarkdownParserBlock().parse(md); 18 | } 19 | 20 | /** 行解析 */ 21 | private parseLine_ (block: SubNode) { 22 | const walker = new MTreeWalker(block); 23 | let current; 24 | while (current = walker.next()) { 25 | if (!current.close && current.mnode.canContainText) { 26 | new MarkdownParserLine().parse(current.mnode); 27 | } 28 | } 29 | return block; 30 | } 31 | 32 | /** markdown 字符串转 html */ 33 | md2html (md: string) { 34 | return new MarkdownRender().render(this.md2node(md)); 35 | } 36 | 37 | /** markdown 字符串转语法树 */ 38 | md2node (md: string) { 39 | return this.parseLine_(this.parseBlock_(md)) 40 | } 41 | 42 | } 43 | export default new Markdown(); -------------------------------------------------------------------------------- /src/markdown-parse/markdown-block-creater/thematic-break-creater.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: 分割线识别(---或___或***) 3 | * @Author: ZengYong 4 | * @CreateDate: 2021-10-21 11:21:57 5 | */ 6 | 7 | import Creater, { ICreaterProps } from "./creater"; 8 | import { ThematicBreakNode } from "../node"; 9 | import { advanceOffset } from "../funs" 10 | export class ThematicBreakCreater extends Creater { 11 | canCreate (task: ICreaterProps) { 12 | let createResult = null; 13 | let lineRest = task.line.slice(task.offset).replace(/\s/g, ''); 14 | if (this.maybeThematicBreak_(lineRest, task.nextLine)) { 15 | const match = lineRest.match(/^(?:\*){3,}$|^(?:_){3,}$|^(?:-){3,}$/); 16 | if (match) { 17 | const result = advanceOffset(task.line, task.offset, task.column, task.line.length - task.offset); 18 | createResult = { 19 | offset: result.offset, 20 | column: result.column, 21 | spaceInTab: result.spaceInTab, 22 | mnode: new ThematicBreakNode(task.sourceStart) 23 | }; 24 | } 25 | } 26 | return createResult; 27 | 28 | } 29 | 30 | maybeThematicBreak_ (currentLine: string, nextLine: string | undefined) { 31 | return nextLine === undefined ? false : true; 32 | } 33 | } 34 | export default ThematicBreakCreater; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "ts-node": { 3 | // these options are overrides used only by ts-node 4 | // same as our --compilerOptions flag and our TS_NODE_COMPILER_OPTIONS environment variable 5 | "compilerOptions": { 6 | "module": "commonjs" 7 | } 8 | }, 9 | "compilerOptions": { 10 | "baseUrl": "./src", 11 | "outDir": "dist", 12 | "pretty": true, 13 | "module": "ESNext", 14 | "target": "ESNext", 15 | "jsx": "react", 16 | "lib": ["es2015", "dom"], 17 | "sourceMap": true, 18 | "allowJs": false, 19 | "moduleResolution": "node", 20 | "experimentalDecorators": true, 21 | "forceConsistentCasingInFileNames": true, 22 | "noImplicitReturns": true, 23 | "noImplicitThis": true, 24 | "noImplicitAny": true, 25 | "strictNullChecks": true, 26 | "suppressImplicitAnyIndexErrors": true, 27 | "noEmitHelpers": false, 28 | "noUnusedLocals": false, 29 | "importHelpers": true, 30 | "allowSyntheticDefaultImports": true, 31 | "esModuleInterop": true, 32 | "typeRoots": ["node_modules/@types", "src/**/*.d.ts", "*.d.ts"], 33 | "traceResolution": false, 34 | "skipLibCheck": true, 35 | "declaration": true 36 | }, 37 | "include": ["src"], 38 | "exclude": ["node_modules", "build", "config", "dist"] 39 | } 40 | -------------------------------------------------------------------------------- /src/event/view-event.ts: -------------------------------------------------------------------------------- 1 | // import { Editor } from ".."; 2 | import BaseEventHandler from "./base-event"; 3 | import { SelectionCustom } from "../model/selection-model"; 4 | import { BaseView } from '../view' 5 | /* 6 | * @Description: 模型层的自定义事件,如 dom 选区变化后,需先经过模型层处理。 7 | * @Author: ZengYong 8 | * @CreateDate: 2021-09-29 16:01:46 9 | */ 10 | export class ViewEventHandler extends BaseEventHandler{ 11 | 12 | private selectionchangeHandlerBinder_: any; 13 | // constructor (editor: Editor) { 14 | // super(editor); 15 | // } 16 | 17 | selectionchangeHandler_ (selection: SelectionCustom | null) { 18 | if (this.getComposing()) { // 中文输入状态不用更新选区 19 | return; 20 | } 21 | if (selection) { 22 | this.editor.setSelection(selection.anchor, selection.focus); 23 | } else { 24 | this.editor.removeSelection(); 25 | } 26 | } 27 | 28 | addListeners () { 29 | super.addListeners(); 30 | this.selectionchangeHandlerBinder_ = this.selectionchangeHandler_.bind(this); 31 | this.view.on(BaseView.EVENT_TYPE.SELECTION_CHANGE, this.selectionchangeHandlerBinder_); 32 | } 33 | 34 | dispose () { 35 | super.dispose(); 36 | this.view.off(BaseView.EVENT_TYPE.SELECTION_CHANGE, this.selectionchangeHandlerBinder_); 37 | } 38 | } 39 | export default ViewEventHandler; -------------------------------------------------------------------------------- /src/markdown-parse/markdown-parser-line/common.ts: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const TAGNAME = "[A-Za-z][A-Za-z0-9-]*"; 4 | const ATTRIBUTENAME = "[a-zA-Z_:][a-zA-Z0-9:._-]*"; 5 | const UNQUOTEDVALUE = "[^\"'=<>`\\x00-\\x20]+"; 6 | const SINGLEQUOTEDVALUE = "'[^']*'"; 7 | const DOUBLEQUOTEDVALUE = '"[^"]*"'; 8 | const ATTRIBUTEVALUE = 9 | "(?:" + 10 | UNQUOTEDVALUE + 11 | "|" + 12 | SINGLEQUOTEDVALUE + 13 | "|" + 14 | DOUBLEQUOTEDVALUE + 15 | ")"; 16 | const ATTRIBUTEVALUESPEC = "(?:" + "\\s*=" + "\\s*" + ATTRIBUTEVALUE + ")"; 17 | const ATTRIBUTE = "(?:" + "\\s+" + ATTRIBUTENAME + ATTRIBUTEVALUESPEC + "?)"; 18 | const OPENTAG = "<" + TAGNAME + ATTRIBUTE + "*" + "\\s*/?>"; 19 | const CLOSETAG = "]"; 20 | const HTMLCOMMENT = "|"; 21 | const PROCESSINGINSTRUCTION = "[<][?][\\s\\S]*?[?][>]"; 22 | const DECLARATION = "]*>"; 23 | const CDATA = ""; 24 | const HTMLTAG = 25 | "(?:" + 26 | OPENTAG + 27 | "|" + 28 | CLOSETAG + 29 | "|" + 30 | HTMLCOMMENT + 31 | "|" + 32 | PROCESSINGINSTRUCTION + 33 | "|" + 34 | DECLARATION + 35 | "|" + 36 | CDATA + 37 | ")"; 38 | 39 | // TODO 临时这样,尽快和../funs.ts合并或做其他优化 40 | export const reHtmlTag = new RegExp("^" + HTMLTAG); 41 | -------------------------------------------------------------------------------- /src/event/mouse-event.ts: -------------------------------------------------------------------------------- 1 | // import { Editor } from ".."; 2 | import BaseEventHandler from "./base-event"; 3 | /* 4 | * @Description: 鼠标事件 5 | * @Author: ZengYong 6 | * @CreateDate: 2021-09-18 17:01:46 7 | */ 8 | export class MouseEventHandler extends BaseEventHandler{ 9 | 10 | // constructor (editor: Editor) { 11 | // super(editor); 12 | // } 13 | 14 | mousedownHandler_ (e: MouseEvent) { 15 | 16 | } 17 | 18 | mousemoveHandler_ (e: MouseEvent) { 19 | 20 | } 21 | 22 | mouseupHandler_ (e: MouseEvent) { 23 | 24 | } 25 | 26 | addListeners () { 27 | const mousedownHandlerBinder_ = this.mousedownHandler_.bind(this); 28 | const mousemoveHandlerBinder_ = this.mousemoveHandler_.bind(this); 29 | const mouseupHandlerBinder_ = this.mouseupHandler_.bind(this); 30 | 31 | this.target.addEventListener('mousedown', mousedownHandlerBinder_); 32 | this.target.addEventListener('mousemove', mousemoveHandlerBinder_); 33 | this.target.addEventListener('mouseup', mouseupHandlerBinder_); 34 | 35 | this.cacheEventHandler ({ type: 'mousedown', listener: mousedownHandlerBinder_ }); 36 | this.cacheEventHandler ({ type: 'mousemove', listener: mousemoveHandlerBinder_ }); 37 | this.cacheEventHandler ({ type: 'mouseup', listener: mouseupHandlerBinder_ }); 38 | } 39 | } 40 | export default MouseEventHandler; -------------------------------------------------------------------------------- /src/markdown-parse/markdown-parser-line/delimiter-stack.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: 分隔符栈,记录所有分隔符基本信息。用于进行匹配后续匹配配对 3 | * @Author: ZengYong 4 | * @CreateDate: 2021-10-26 18:39:39 5 | */ 6 | import { TextNode } from "../node"; 7 | export class DelimitersStack { 8 | char: string; // 分隔符号,*、_、~等 9 | count: number; // 符号总共数量 10 | currentCount: number; // 当前还剩余的符号数量 11 | mnode: TextNode; // 关联的节点 12 | pre: DelimitersStack | null = null; // 上一个分隔符 13 | next: DelimitersStack | null = null; // 下一个分隔符 14 | canOpen: boolean; // 能否作为开始符 15 | canClose: boolean; // 能否作为结束符 16 | 17 | constructor (char: string, count: number, mnode: TextNode, canOpen: boolean, canClose: boolean) { 18 | this.char = char; 19 | this.count = count; 20 | this.currentCount = count; 21 | this.mnode = mnode; 22 | this.canOpen = canOpen; 23 | this.canClose = canClose; 24 | } 25 | 26 | /** 删除区间对象 */ 27 | static removeBetween (bottom: DelimitersStack, top: DelimitersStack) { 28 | if (bottom.next !== top) { 29 | bottom.next = top; 30 | top.pre = bottom; 31 | } 32 | } 33 | 34 | /** 从链表中删除当前对象 */ 35 | remove () { 36 | if (this.pre !== null) { 37 | this.pre.next = this.next; 38 | } 39 | if (this.next !== null) { 40 | this.next.pre = this.pre; 41 | } 42 | } 43 | } 44 | export default DelimitersStack; -------------------------------------------------------------------------------- /src/markdown-parse/tree-walker.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: MTreeWalker,以语法树中任何 Mnode 节点作为起点进行先序深度遍历。与原生的 TreeWalker 类似。 3 | * @Author: ZengYong 4 | * @CreateDate: 2021-10-19 19:29:49 5 | */ 6 | 7 | import { SubNode } from "./node"; 8 | 9 | export class MTreeWalker { 10 | private root_: SubNode; 11 | private current_: SubNode | null; 12 | private close_: boolean; 13 | 14 | constructor (mnode: SubNode) { 15 | this.root_ = mnode; 16 | this.current_ = mnode; 17 | this.close_ = false; 18 | } 19 | 20 | /** 获取下一个节点 */ 21 | next () { 22 | let mnode = this.current_; 23 | let close = this.close_; 24 | 25 | if (mnode === null) { 26 | return null; 27 | } 28 | 29 | if (!close && mnode.isContainer) { 30 | if (mnode.firstChild) { 31 | this.current_ = mnode.firstChild as SubNode; 32 | this.close_ = false; 33 | } else { 34 | this.close_ = true; 35 | } 36 | } else if (mnode === this.root_) { 37 | this.current_ = null; 38 | } else if (mnode.next === null) { 39 | this.current_ = mnode.parent as SubNode; 40 | this.close_ = true; 41 | } else { 42 | this.current_ = mnode.next as SubNode; 43 | this.close_ = false; 44 | } 45 | 46 | return { mnode, close }; 47 | } 48 | } 49 | export default MTreeWalker; -------------------------------------------------------------------------------- /src/operations/set-selection-operation.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: 设置选区 OP 3 | * @Author: ZengYong 4 | * @CreateDate: 2021-09-17 19:16:22 5 | */ 6 | import Operation from "./operation"; 7 | import Editor from "../editor"; 8 | import { SelectionCustom } from "../model/selection-model"; 9 | 10 | export class SetSelectionOperation extends Operation { 11 | 12 | protected selection_: SelectionCustom | null; 13 | protected oldSelection_: SelectionCustom; 14 | 15 | constructor (selection: SelectionCustom | null) { 16 | super(); 17 | this.selection_ = selection; 18 | } 19 | 20 | /** 合并两个选区op */ 21 | static merge (firstOp: SetSelectionOperation, secondOp: SetSelectionOperation) { 22 | const newSelection = new SetSelectionOperation(secondOp.getSelection()); 23 | newSelection.setOldSelection(firstOp.getOldSelection()); 24 | return newSelection; 25 | } 26 | 27 | apply (editor: Editor) { 28 | this.setOldSelection(editor.getSelectionModel().getSelection()); 29 | editor.getSelectionModel().setSelection(this.selection_); 30 | } 31 | 32 | inverse () { 33 | return new SetSelectionOperation(this.oldSelection_); 34 | } 35 | 36 | getSelection () { 37 | return this.selection_; 38 | } 39 | 40 | getOldSelection () { 41 | return this.oldSelection_; 42 | } 43 | 44 | setOldSelection (oldSelection: SelectionCustom) { 45 | this.oldSelection_ = oldSelection; 46 | } 47 | } 48 | export default SetSelectionOperation; -------------------------------------------------------------------------------- /src/markdown-parse/node/block-quote-node.ts: -------------------------------------------------------------------------------- 1 | 2 | import MNode, { NodeType } from "./node"; 3 | import { advanceOffset, isSpacerOrTab } from "../funs" 4 | export class BlockQuoteNode extends MNode { 5 | 6 | readonly isContainer = true; // 是否可以包含其他node节点 7 | readonly isBlockContainer = true; // 块级元素中的容器块,可以继续包含块级元素,否则为叶子块,只能包含行级元素 8 | readonly canContainText = false; // 是否可以直接显示文本,不需要再嵌套p标签等 9 | readonly isParagraph = true; // 是否是段落block 10 | 11 | 12 | constructor (sourceStart: number) { 13 | super(sourceStart); 14 | this.type = NodeType.BlockQuote; 15 | } 16 | 17 | // @Override 18 | continue (currentLine: string, offset: number, column: number) { 19 | let continueResult: any = null; 20 | if (currentLine[offset] === '>') { 21 | let result = advanceOffset(currentLine, offset, column, 1); 22 | // if (isSpacerOrTab(currentLine[result.offset])) { 23 | // result = advanceOffset(currentLine, result.offset, result.column, 1, true); 24 | // } 25 | continueResult = { 26 | offset: result.offset, 27 | column: result.column, 28 | spaceInTab: result.spaceInTab, 29 | }; 30 | } 31 | return continueResult; 32 | } 33 | 34 | // @Override 35 | // finalize(sourceEnd?: number) { 36 | // super.finalize(sourceEnd); 37 | // } 38 | 39 | // @Override 40 | canContain(mnode: MNode) { 41 | return mnode.type !== NodeType.Item; 42 | } 43 | } 44 | export default BlockQuoteNode; -------------------------------------------------------------------------------- /src/view/view-provider.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: 统一创建视图类 3 | * @Author: ZengYong 4 | * @CreateDate: 2021-09-18 17:33:09 5 | */ 6 | import PreviewView from "./preview-view"; 7 | import RenderView from "./render-view"; 8 | import SourceAndPreviewView from "./source-and-preview-view"; 9 | import SourceView from "./source-view"; 10 | import { TextModel, SelectionModel } from "../model/"; 11 | 12 | export enum ViewMode { 13 | PREVIEW = 'PREVIEW', // 预览-只读模式 14 | RENDER = 'RENDER', // 实时渲染模式 15 | SOURCE = 'SOURCE', // 源码模式 16 | SOURCE_AND_PREVIEW = 'SOURCE_AND_PREVIEW' // 双屏模式 17 | }; 18 | 19 | export type View = PreviewView | RenderView | SourceAndPreviewView | SourceView; 20 | 21 | export class ViewProvider { 22 | 23 | provide (viewMode: ViewMode, textModel: TextModel, selectionModel: SelectionModel, viewContainer: HTMLElement): View { 24 | switch (viewMode) { 25 | case ViewMode.PREVIEW: 26 | return new PreviewView(textModel, selectionModel, viewContainer); 27 | case ViewMode.RENDER: 28 | return new RenderView(textModel, selectionModel, viewContainer); 29 | case ViewMode.SOURCE: 30 | return new SourceView(textModel, selectionModel, viewContainer); 31 | case ViewMode.SOURCE_AND_PREVIEW: 32 | return new SourceAndPreviewView(textModel, selectionModel, viewContainer); 33 | default: 34 | return new PreviewView(textModel, selectionModel, viewContainer); 35 | } 36 | } 37 | } 38 | export default ViewProvider; -------------------------------------------------------------------------------- /demo/text.ts: -------------------------------------------------------------------------------- 1 | export const text = "## 💡 Web Editor Markdown\n[web-editor-markdown](https://github.com/Ben-love-zy/web-editor-markdown.git) is a Markdown editor in Web browser and for real-time rendering like `Typora`. It is based on TypeScript and JavaScript, and does not rely on any third-party framework. It supports Chinese friendly and can be easily extended and connected to native JavaScript, Vue, React, Angular and other applications. It provides four rendering modes: `SOURCE`, `SOURCE_AND_PREVIEW`, `RENDER` and `PREVIEW`. If necessary, its underlying layer also supports the ability of collaborative editing and provides atomic `Operation` for extending collaborative editing.\n### ✨ English Demo\n![](https://static.yximgs.com/udata/pkg/IS-DOCS-MD/zengyong/img/demo-en.gif)\n### ✨ Chinese Demo\n![](https://static.yximgs.com/udata/pkg/IS-DOCS-MD/zengyong/img/demo-zh.gif)\n### 🛠️ Getting started\n* install it\n```shell\nnpm install web-editor-markdown --save\n```\n* use it\n```ts\nimport { Editor, withUndoRedo } from 'web-editor-markdown';\nlet editor = new Editor(document.getElementById(\'id\'));\neditor = withUndoRedo(editor); // UndoRedo Plugin\neditor.insertTextAtCursor(\'**This is a bold text**\\n> tips:You can switch source mode with `cmd+/`\');\n```\n* others\n```ts\nimport { EditorViewMode } from 'web-editor-markdown';\neditor.switchViewMode(EditorViewMode.PREVIEW); // switch rendering mode,(shortcut key: \'cmd+/\')\nconsole.log(\'content\', editor.getContent());\n```\n* local source\n```shell\nnpm install\nnpm start\n```\n" -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web-editor-markdown", 3 | "version": "1.0.8", 4 | "description": "A markdown editor in browser, supports collaborative editing", 5 | "main": "dist/index.min.js", 6 | "types": "dist/index.d.ts", 7 | "files": [ 8 | "dist/*" 9 | ], 10 | "scripts": { 11 | "start": "webpack serve --open", 12 | "build": "webpack --config webpack.config.sdk.js" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/Ben-love-zy/web-editor-markdown.git" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/Ben-love-zy/web-editor-markdown/issues" 20 | }, 21 | "keywords": [ 22 | "markdown", 23 | "editor", 24 | "web", 25 | "Collaborative editing" 26 | ], 27 | "author": "Yong Zeng<303328447@qq.com>", 28 | "license": "MIT", 29 | "dependencies": { 30 | "commonmark": "^0.30.0", 31 | "events": "^3.3.0", 32 | "is-hotkey": "^0.2.0" 33 | }, 34 | "devDependencies": { 35 | "@babel/core": "^7.15.5", 36 | "@babel/preset-env": "^7.15.6", 37 | "@types/commonmark": "^0.27.5", 38 | "@types/is-hotkey": "^0.1.5", 39 | "autoprefixer": "^10.4.13", 40 | "babel-loader": "^8.2.2", 41 | "copy-webpack-plugin": "^9.0.1", 42 | "css-loader": "^6.7.1", 43 | "html-webpack-plugin": "^5.3.2", 44 | "less-loader": "^6.2.0", 45 | "postcss-loader": "^7.0.1", 46 | "style-loader": "^3.3.1", 47 | "ts-loader": "^9.2.5", 48 | "typescript": "^4.4.3", 49 | "webpack": "^5.52.1", 50 | "webpack-cli": "^4.8.0", 51 | "webpack-dev-server": "^4.2.1" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/markdown-parse/node/list-node.ts: -------------------------------------------------------------------------------- 1 | 2 | import MNode, { NodeType } from "./node"; 3 | export class ListNode extends MNode { 4 | 5 | readonly isContainer = true; 6 | readonly isBlockContainer = true; 7 | readonly canContainText = false; 8 | readonly isParagraph = true; 9 | column: number = 0; 10 | start: string = '1'; // 有序列表开始的序号 11 | listType: string = 'bullet'; // bullet 或 ordered 12 | bulletChar: string = '-'; // - 或者 * 或者 _ 13 | delimiter: string = '.'; // . 或者 ) 14 | 15 | constructor (sourceStart: number, listType: string, bulletChar: string, delimiter: string, column: number, start: string) { 16 | super(sourceStart); 17 | this.type = NodeType.List; 18 | this.column = column; 19 | this.listType = listType; 20 | this.bulletChar = bulletChar; 21 | this.delimiter = delimiter; 22 | this.start = start; 23 | } 24 | 25 | // @Override 26 | continue (currentLine: string, offset: number, column: number) { 27 | let continueResult: any = null; 28 | // 如果上一行是空白行,则退出当前列表 29 | // let notBlankLine = ''; 30 | // let lastChild = this.lastChild; 31 | // while (lastChild) { 32 | // notBlankLine = lastChild.stringContent; 33 | // lastChild = lastChild.lastChild; 34 | // } 35 | // if (notBlankLine) { 36 | continueResult = { offset: -1, column: -1, spaceInTab: -1 }; 37 | // } 38 | return continueResult; 39 | } 40 | 41 | // @Override 42 | // finalize() { 43 | // super.finalize(); 44 | // } 45 | 46 | // @Override 47 | canContain(mnode: MNode) { 48 | return mnode.type === NodeType.Item; 49 | } 50 | } 51 | export default ListNode; -------------------------------------------------------------------------------- /src/markdown-parse/node/code-block-node.ts: -------------------------------------------------------------------------------- 1 | 2 | import MNode, { NodeType } from "./node"; 3 | import { advanceOffset } from "../funs" 4 | export class CodeBlockNode extends MNode { 5 | 6 | readonly isContainer = true; 7 | readonly isBlockContainer = false; 8 | readonly canContainText = true; 9 | readonly isParagraph = true; // 是否是段落block 10 | lang: string; 11 | char: string = '```' 12 | 13 | 14 | constructor (sourceStart: number, lang: string, char: string) { 15 | super(sourceStart); 16 | this.type = NodeType.CodeBlock; 17 | this.lang = lang; 18 | this.char = char; 19 | this.blockMarkerBefore = char; 20 | } 21 | 22 | // @Override 23 | continue (currentLine: string, offset: number, column: number) { 24 | let continueResult: any = null; 25 | // const match = currentLine.slice(offset).match(/^(?:`{3,}|~{3,})(?= *$)/); 26 | const str = currentLine.slice(offset); 27 | if (str === '```' || str === '~~~') { // 代码块结束 28 | this.blockMarkerAfter = this.blockMarkerBefore; 29 | const result = advanceOffset(currentLine, offset, column, 3); 30 | continueResult = { 31 | end: true, 32 | offset: result.offset, 33 | column: result.column, 34 | spaceInTab: result.spaceInTab, 35 | }; 36 | } else { 37 | continueResult = { offset: -1, column: -1, spaceInTab: -1 }; 38 | } 39 | return continueResult; 40 | } 41 | 42 | // @Override 43 | // finalize(sourceEnd?: number) { 44 | // super.finalize(sourceEnd); 45 | // } 46 | 47 | // @Override 48 | canContain(mnode: MNode) { 49 | return false; 50 | } 51 | } 52 | export default CodeBlockNode; -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Web-Editor-Markdown 8 | 9 | 45 | 46 |
47 |
48 |     49 |     50 |     51 | 52 |
53 |
54 |
55 | 56 | -------------------------------------------------------------------------------- /src/markdown-parse/funs.ts: -------------------------------------------------------------------------------- 1 | 2 | function replaceUnsafeChar (str: string) { 3 | switch (str) { 4 | case "&": 5 | return "&"; 6 | case "<": 7 | return "<"; 8 | case ">": 9 | return ">"; 10 | case '"': 11 | return """; 12 | default: 13 | return str; 14 | } 15 | }; 16 | 17 | 18 | /** 当前位置前移,1个tab对应4个空格 */ 19 | export function advanceOffset (line: string, offset: number, column: number, count: number, forColumns?: boolean) { 20 | let char, spaceInTab = 0; 21 | while (count > 0 && (char = line[offset])) { 22 | if (char === "\t") { 23 | spaceInTab = 4 - (column % 4); // 一个tab内还剩余多少个空格 24 | if (forColumns) { 25 | const inTab = spaceInTab > count; // 剩余空格足够满足本次移动 26 | const columnDelta = inTab ? count : spaceInTab; // 最多移动一个tab 27 | offset += inTab ? 0 : 1; // 剩余空格足够满足本次移动,则offset不用移动,说明在一个tab字符内 28 | column += columnDelta; 29 | count -= columnDelta; 30 | } else { 31 | column += spaceInTab; 32 | spaceInTab = 0; 33 | offset += 1; 34 | count -= 1; 35 | } 36 | } else { 37 | offset += 1; 38 | column += 1; 39 | count -= 1; 40 | } 41 | } 42 | return { offset, column, spaceInTab } 43 | } 44 | 45 | export function isSpacerOrTab (char: string) { 46 | return char === ' '|| char === '\t'; 47 | } 48 | 49 | export function getPreSpacerOrTab (str: string) { 50 | const match = str.match(/^\s+/); 51 | return match ? match[0] : ''; 52 | } 53 | 54 | export function escapeXml (str: string) { 55 | const reXmlSpecial = new RegExp('[&<>"]', "g"); 56 | if (reXmlSpecial.test(str)) { 57 | return str.replace(reXmlSpecial, replaceUnsafeChar); 58 | } else { 59 | return str; 60 | } 61 | } -------------------------------------------------------------------------------- /src/markdown-parse/node/index.ts: -------------------------------------------------------------------------------- 1 | import BlockQuoteNode from "./block-quote-node"; 2 | import CodeBlockNode from "./code-block-node"; 3 | import DocumentNode from "./document-node"; 4 | import EmphNode from "./emph-node"; 5 | import HeadNode from "./head-node"; 6 | import HtmlBlockNode from "./html-block-node"; 7 | import ImageNode from "./image-node"; 8 | import LinkNode from "./link-node"; 9 | import ListNode from "./list-node"; 10 | import ItemNode from "./item-node"; 11 | import ParagraphNode from "./paragraph-node"; 12 | import StrongNode from "./strong-node"; 13 | import TextNode from "./text-node"; 14 | import ThematicBreakNode from "./thematic-break-node"; 15 | import DelNode from "./del-node"; 16 | import UnderlineNode from "./underline-node"; 17 | import TableNode from "./table-node"; 18 | import TableTheadNode from "./table-thead-node"; 19 | import TableTrNode from "./table-tr-node"; 20 | import TableThNode from "./table-th-node"; 21 | import TableTbodyNode from "./table-tbody-node"; 22 | import TableTdNode from "./table-td-node"; 23 | import CheckboxNode from "./checkbox-node"; 24 | import CodeNode from "./code-node"; 25 | 26 | export type SubNode = BlockQuoteNode | CodeBlockNode | DocumentNode | EmphNode | HeadNode | HtmlBlockNode | ImageNode | LinkNode | ListNode | ItemNode | ParagraphNode | StrongNode | TextNode | ThematicBreakNode | DelNode | UnderlineNode | TableNode | TableTheadNode | TableTrNode | TableThNode | TableTbodyNode | TableTdNode | CheckboxNode | CodeNode; 27 | export { BlockQuoteNode, CodeBlockNode, DocumentNode, EmphNode, HeadNode, HtmlBlockNode, ImageNode, LinkNode, ListNode, ItemNode, ParagraphNode, StrongNode, TextNode, ThematicBreakNode, DelNode, UnderlineNode, TableNode, TableTheadNode, TableTrNode, TableThNode, TableTbodyNode, TableTdNode, CheckboxNode, CodeNode }; 28 | export { MNode, NodeType } from "./node"; -------------------------------------------------------------------------------- /src/markdown-parse/node/table-thead-node.ts: -------------------------------------------------------------------------------- 1 | 2 | import { TableNode } from "."; 3 | import { advanceOffset } from "../funs"; 4 | import MNode, { NodeType } from "./node"; 5 | export class TableTheadNode extends MNode { 6 | 7 | readonly isContainer = true; 8 | readonly isBlockContainer = true; 9 | readonly canContainText = false; 10 | readonly isParagraph = true; 11 | 12 | constructor (sourceStart: number) { 13 | super(sourceStart); 14 | this.type = NodeType.TableThead; 15 | } 16 | 17 | // @Override 18 | continue (currentLine: string, offset: number, column: number) { 19 | let continueResult: any = null; 20 | let lineRest = currentLine.slice(offset); 21 | const tds = lineRest.replace(/\s/g, '').split('|'); 22 | let tableNode = this.parent as TableNode; 23 | for (let td of tds) { 24 | if (td) { 25 | const alignLeft = td[0] === ':'; 26 | const alignRight = td[td.length - 1] === ':'; 27 | let align = 'left'; 28 | // left 为默认值,减少其判断 29 | if (alignLeft && alignRight) { 30 | align = 'center'; 31 | } else if (!alignLeft && alignRight) { 32 | align = 'right'; 33 | } 34 | tableNode.aligns.push(align); 35 | } 36 | } 37 | const result = advanceOffset(currentLine, offset, column, lineRest.length); 38 | continueResult = { 39 | end: true, 40 | offset: result.offset, 41 | column: result.column, 42 | spaceInTab: result.spaceInTab, 43 | }; 44 | return continueResult; 45 | } 46 | 47 | // @Override 48 | // finalize() { 49 | // super.finalize(); 50 | // } 51 | 52 | // @Override 53 | canContain(mnode: MNode) { 54 | return false; // 已经和table打包创建了,不存在新包含的情况了 55 | // return mnode.type === NodeType.TableTr; 56 | } 57 | } 58 | export default TableTheadNode; -------------------------------------------------------------------------------- /src/markdown-parse/markdown-block-creater/factory.ts: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | * @Description: 责任链工厂,build返回用于创建块节点的处理器。责任链是可复用的,不用解析每行时都重新实例化。 4 | * @Author: ZengYong 5 | * @CreateDate: 2021-10-20 18:06:03 6 | */ 7 | import AtxHeadingCreater from "./atx-heading-creater"; 8 | import BlockQuoteCreater from "./block-quote-creater"; 9 | import FencedCodeBlockCreater from "./fenced-code-block-creater"; 10 | import HtmlBlockCreater from "./html-block-creater"; 11 | import IndentedCodeBlockCreater from "./indented-code-block-creater"; 12 | import ListItemCreater from "./list-item-creater"; 13 | import SetextHeadingCreater from "./setext-heading-creater"; 14 | import ThematicBreakCreater from "./thematic-break-creater"; 15 | import TableCreater from "./table-creater"; 16 | 17 | export class Factory { 18 | build () { 19 | const atxHeadingCreater = new AtxHeadingCreater(); 20 | const blockQuoteCreater = new BlockQuoteCreater(); 21 | const fencedCodeBlockCreater = new FencedCodeBlockCreater(); 22 | const htmlBlockCreater = new HtmlBlockCreater(); 23 | const indentedCodeBlockCreater = new IndentedCodeBlockCreater(); 24 | const listItemCreater = new ListItemCreater(); 25 | const setextHeadingCreater = new SetextHeadingCreater(); 26 | const thematicBreakCreater = new ThematicBreakCreater(); 27 | const tableCreater = new TableCreater(); 28 | 29 | atxHeadingCreater.setNext(blockQuoteCreater); 30 | blockQuoteCreater.setNext(fencedCodeBlockCreater); 31 | fencedCodeBlockCreater.setNext(htmlBlockCreater); 32 | htmlBlockCreater.setNext(indentedCodeBlockCreater); 33 | indentedCodeBlockCreater.setNext(listItemCreater); 34 | listItemCreater.setNext(setextHeadingCreater); 35 | setextHeadingCreater.setNext(thematicBreakCreater); 36 | thematicBreakCreater.setNext(tableCreater); 37 | return atxHeadingCreater; 38 | } 39 | } 40 | export default Factory; -------------------------------------------------------------------------------- /src/event/hotkeys.ts: -------------------------------------------------------------------------------- 1 | import { isKeyHotkey } from 'is-hotkey' 2 | import { IS_APPLE } from '../utils/browser' 3 | 4 | /* 5 | * @Description: 键盘快捷键集中识别 6 | * @Author: ZengYong 7 | * @CreateDate: 2021-09-24 12:17:11 8 | */ 9 | 10 | interface KeyHotFun { 11 | [x: string]: (e: KeyboardEvent) => boolean 12 | } 13 | 14 | class Hotkeys { 15 | /** 提前调用 isKeyHotkey 解析,提升运行效率 */ 16 | static IS_KEYS: KeyHotFun = { 17 | moveBackward: isKeyHotkey('left'), 18 | moveForward: isKeyHotkey('right'), 19 | deleteBackward: isKeyHotkey('shift?+backspace'), 20 | deleteForward: isKeyHotkey('shift?+delete'), 21 | isTab: isKeyHotkey('tab'), 22 | undo: isKeyHotkey('mod+z'), 23 | redo: IS_APPLE ? isKeyHotkey('cmd+shift+z') : isKeyHotkey('ctrl+y'), 24 | changeMode: isKeyHotkey('mod+/'), 25 | }; 26 | static instance: Hotkeys; 27 | 28 | private constructor () {} 29 | 30 | static getInstance () { 31 | if (!this.instance) { 32 | this.instance = new Hotkeys(); 33 | } 34 | return this.instance 35 | } 36 | 37 | isMoveBackward (e: KeyboardEvent) { 38 | return Hotkeys.IS_KEYS['moveBackward'](e); 39 | } 40 | 41 | isMoveForward (e: KeyboardEvent) { 42 | return Hotkeys.IS_KEYS['moveForward'](e); 43 | } 44 | 45 | isDeleteBackward (e: KeyboardEvent) { 46 | return Hotkeys.IS_KEYS['deleteBackward'](e); 47 | } 48 | 49 | isDeleteForward (e: KeyboardEvent) { 50 | return Hotkeys.IS_KEYS['deleteForward'](e); 51 | } 52 | 53 | isTab (e: KeyboardEvent) { 54 | return Hotkeys.IS_KEYS['isTab'](e); 55 | } 56 | 57 | isRedo (e: KeyboardEvent) { 58 | return Hotkeys.IS_KEYS['redo'](e); 59 | } 60 | 61 | isUndo (e: KeyboardEvent) { 62 | return Hotkeys.IS_KEYS['undo'](e); 63 | } 64 | isChangeMode (e: KeyboardEvent) { 65 | return Hotkeys.IS_KEYS['changeMode'](e); 66 | } 67 | } 68 | export default Hotkeys.getInstance();; -------------------------------------------------------------------------------- /src/event/base-event.ts: -------------------------------------------------------------------------------- 1 | import { Editor } from ".."; 2 | import { View } from '../view' 3 | /* 4 | * @Description: 事件基类 5 | * @Author: ZengYong 6 | * @CreateDate: 2021-09-18 17:11:09 7 | */ 8 | interface EventItem { 9 | type: keyof HTMLElementEventMap; 10 | listener: (this: HTMLElement, ev: Event) => any; 11 | } 12 | export class BaseEventHandler { 13 | protected isComposing: boolean = false; 14 | protected editor: Editor; 15 | protected target: HTMLElement; 16 | protected view: View; 17 | protected eventList_: EventItem[] = []; 18 | constructor (editor: Editor, view: View) { 19 | this.editor = editor; 20 | this.target = editor.getElement() as HTMLElement; 21 | this.view = view; 22 | } 23 | 24 | compositionStartHandler (e: CompositionEvent) { 25 | this.isComposing = true; 26 | } 27 | 28 | compositionEndHandler (e: CompositionEvent) { 29 | this.isComposing = false; 30 | } 31 | 32 | getComposing () { 33 | return this.isComposing; 34 | } 35 | 36 | cacheEventHandler (eventItem: EventItem) { 37 | this.eventList_.push(eventItem); 38 | } 39 | 40 | addListeners () { 41 | const compositionStartHandlerBinder = this.compositionStartHandler.bind(this); 42 | const compositionEndHandlerBinder = this.compositionEndHandler.bind(this); 43 | 44 | this.target.addEventListener('compositionstart', compositionStartHandlerBinder); 45 | this.target.addEventListener('compositionend', compositionEndHandlerBinder); 46 | 47 | this.cacheEventHandler ({ type: 'compositionstart', listener: compositionStartHandlerBinder }); 48 | this.cacheEventHandler ({ type: 'compositionend', listener: compositionEndHandlerBinder }); 49 | } 50 | 51 | dispose () { 52 | for (let eventItem of this.eventList_) { 53 | this.target.removeEventListener(eventItem.type, eventItem.listener); 54 | } 55 | this.eventList_ = []; 56 | } 57 | } 58 | export default BaseEventHandler; -------------------------------------------------------------------------------- /src/model/text-model.ts: -------------------------------------------------------------------------------- 1 | 2 | import EventEmitter from "events"; 3 | import { debounce } from "../utils/debounce"; 4 | /* 5 | * @Description: 文档内容数据模型 6 | * @Author: ZengYong 7 | * @CreateDate: 2021-09-17 19:16:37 8 | */ 9 | export class TextModel extends EventEmitter { 10 | 11 | static EVENT_TYPE = { 12 | TEXT_CHANGE: 'text-change' 13 | } 14 | 15 | protected spacers_: string; 16 | protected textChangeEmit_: () => void; 17 | 18 | constructor (spacers?: string) { 19 | super(); 20 | this.spacers_ = spacers || ''; 21 | this.textChangeEmit_ = debounce(() => { 22 | this.emit(TextModel.EVENT_TYPE.TEXT_CHANGE); 23 | }); 24 | } 25 | 26 | getSpacer () { 27 | return this.spacers_; 28 | } 29 | 30 | getLength () { 31 | return this.spacers_.length; 32 | } 33 | 34 | insert(spacerIndex: number, spacers: string) { 35 | const originalSpacers = this.spacers_; 36 | this.spacers_ = originalSpacers.slice(0, spacerIndex) + spacers + originalSpacers.slice(spacerIndex); 37 | this.textChangeEmit_(); 38 | } 39 | 40 | remove(startIndex: number, endIndex: number) { 41 | const originalSpacers = this.spacers_; 42 | if (startIndex > endIndex) { 43 | [ startIndex, endIndex ] = [ endIndex, startIndex]; 44 | } 45 | this.spacers_ = originalSpacers.slice(0, startIndex) + originalSpacers.slice(endIndex); 46 | this.textChangeEmit_(); 47 | return originalSpacers.slice(startIndex, endIndex); 48 | } 49 | 50 | /** 获取坐标所在行的坐标前面的内容 */ 51 | getLineByIndex (index: number) { 52 | index--; 53 | let line = ''; 54 | while (index >=0 && this.spacers_[index] !== '\n') { 55 | line = this.spacers_[index] + line; 56 | index-- 57 | } 58 | return line; 59 | } 60 | 61 | setContent (spacers: string) { 62 | this.spacers_ = spacers; 63 | this.textChangeEmit_(); 64 | } 65 | 66 | clear () { 67 | this.spacers_ = ''; 68 | this.textChangeEmit_(); 69 | } 70 | } 71 | 72 | export default TextModel; 73 | -------------------------------------------------------------------------------- /src/markdown-parse/markdown-block-creater/creater.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: 责任链处理器父类。根据行字符串处理生成其对应的AST节点,并添加到doc中。扩展为输入删除补充器+block创建器 3 | * @Author: ZengYong 4 | * @CreateDate: 2021-10-20 18:17:39 5 | */ 6 | import { SubNode } from "../node"; 7 | 8 | export interface ICompleterProps { 9 | line: string 10 | } 11 | 12 | export interface ICompleterResult { 13 | completeInput: string, 14 | lineRest: string, 15 | cursor?: number, 16 | needDeleteTab?: boolean 17 | } 18 | 19 | export interface IDeleteProps { 20 | line: string 21 | } 22 | 23 | export interface IDeleteResult { 24 | deleteLen: number, 25 | lineRest: string, 26 | cursor?: number 27 | } 28 | 29 | export interface ICreaterProps { 30 | line: string, 31 | nextLine: string | undefined, 32 | offset: number, 33 | column: number, 34 | container: SubNode, 35 | // indent: number, 36 | sourceStart: number 37 | } 38 | 39 | export interface ICreaterResult { 40 | offset: number, 41 | column: number, 42 | spaceInTab: number, 43 | mnode: SubNode 44 | } 45 | 46 | 47 | 48 | export class Creater { 49 | protected next: Creater; 50 | 51 | setNext (next: Creater) { 52 | this.next = next; 53 | } 54 | 55 | complete (task: ICompleterProps): ICompleterResult | null { 56 | let completeResult = this.canComplete(task); 57 | if (!completeResult && this.next) { 58 | completeResult = this.next.complete(task); 59 | } 60 | return completeResult; 61 | } 62 | 63 | canComplete (task: ICompleterProps): ICompleterResult | null { 64 | return null; 65 | } 66 | 67 | delete (task: IDeleteProps): IDeleteResult | null { 68 | let deleteResult = this.canDelete(task); 69 | if (!deleteResult && this.next) { 70 | deleteResult = this.next.delete(task); 71 | } 72 | return deleteResult; 73 | } 74 | 75 | canDelete (task: IDeleteProps): IDeleteResult | null { 76 | return null; 77 | } 78 | 79 | create (task: ICreaterProps): ICreaterResult | null { 80 | let createResult = this.canCreate(task); 81 | if (!createResult && this.next) { 82 | createResult = this.next.create(task); 83 | } 84 | return createResult; 85 | } 86 | 87 | canCreate (task: ICreaterProps): ICreaterResult | null { 88 | return null; 89 | } 90 | 91 | } 92 | export default Creater; -------------------------------------------------------------------------------- /src/markdown-parse/markdown-block-creater/fenced-code-block-creater.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: 通过```或者~~~触发的代码块识别 3 | * @Author: ZengYong 4 | * @CreateDate: 2021-10-21 11:21:57 5 | */ 6 | import Creater, { ICompleterProps, ICreaterProps, IDeleteProps } from "./creater"; 7 | import { CodeBlockNode } from "../node"; 8 | import { advanceOffset } from "../funs" 9 | export class FencedCodeBlockCreater extends Creater { 10 | static reCodeBlock = /^`{3,}(?!.*`)|^~{3,}/; 11 | 12 | canComplete (task: ICompleterProps) { 13 | let lineRest = task.line; 14 | const match = lineRest.match(FencedCodeBlockCreater.reCodeBlock); 15 | let completerResult = null; 16 | if (match) { 17 | let completeInput = '\n' + match[0] + '\n'; 18 | lineRest = lineRest.slice(match[0].length); 19 | completerResult = { 20 | completeInput, 21 | lineRest, 22 | cursor: 0 23 | } 24 | } 25 | return completerResult; 26 | } 27 | 28 | canDelete (task: IDeleteProps) { 29 | let deleteResult = null; 30 | // let lineRest = task.line; 31 | // const match = lineRest.match(FencedCodeBlockCreater.reCodeBlock); 32 | // let deleteLen = 0; 33 | // if (match) { 34 | // deleteResult = { lineRest: '', deleteLen} 35 | // } 36 | return deleteResult; 37 | } 38 | 39 | canCreate (task: ICreaterProps) { 40 | let createResult = null; 41 | let lineRest = task.line.slice(task.offset); 42 | if (this.maybeCodeBlock_(lineRest, task.nextLine)) { 43 | const match = lineRest.match(FencedCodeBlockCreater.reCodeBlock); 44 | if (match) { 45 | let result = advanceOffset(task.line, task.offset, task.column, match[0].length); 46 | const lang = task.line.slice(result.offset); 47 | result = advanceOffset(task.line, result.offset, result.column, lang.length); 48 | createResult = { 49 | offset: result.offset, 50 | column: result.column, 51 | spaceInTab: result.spaceInTab, 52 | mnode: new CodeBlockNode(task.sourceStart, lang, task.line + '\n') 53 | }; 54 | } 55 | } 56 | return createResult; 57 | } 58 | 59 | maybeCodeBlock_ (currentLine: string, nextLine: string | undefined) { 60 | return nextLine === undefined ? false : true; 61 | } 62 | } 63 | export default FencedCodeBlockCreater; -------------------------------------------------------------------------------- /src/markdown-parse/node/item-node.ts: -------------------------------------------------------------------------------- 1 | 2 | import MNode, { NodeType } from "./node"; 3 | import { advanceOffset, isSpacerOrTab } from "../funs" 4 | 5 | export class ItemNode extends MNode { 6 | 7 | readonly isContainer = true; 8 | readonly isBlockContainer = true; 9 | readonly canContainText = false; // 中间可能嵌入子列表,因此先取消其直接接收文本的能力,不然子列表和文本不能按顺序平级解析 10 | readonly isParagraph = true; 11 | column: number = 0; // 行首缩进 12 | listType: string = 'bullet'; // bullet 或 ordered 13 | bulletChar: string = '-'; // - 或者 * 或者 _ 14 | delimiter: string = '.'; // . 或者 ) 15 | listStyle: string = ''; 16 | 17 | constructor (sourceStart: number, listType: string, bulletChar: string, delimiter: string, column: number) { 18 | super(sourceStart); 19 | this.type = NodeType.Item; 20 | this.column = column; 21 | this.listType = listType; 22 | this.bulletChar = bulletChar; 23 | this.delimiter = delimiter; 24 | } 25 | 26 | // @Override 27 | continue (currentLine: string, offset: number, column: number) { 28 | if (currentLine === '') { 29 | return null; 30 | } 31 | let continueResult: any = null; 32 | // let offsetTemp = offset; 33 | // while (isSpacerOrTab(currentLine[offsetTemp])) { 34 | // offsetTemp++; 35 | // } 36 | // const currentLineText = currentLine.slice(offsetTemp); 37 | // let match = currentLineText.match(/^[*+-]\s/) || currentLineText.match(/^(\d{1,9})[.)]\s/); 38 | // 如果上一行是空白行,则退出当前列表 39 | // let notBlankLine = ''; 40 | // let lastChild = this.lastChild; 41 | // while (lastChild) { 42 | // notBlankLine = lastChild.stringContent; 43 | // lastChild = lastChild.lastChild; 44 | // } 45 | // if (notBlankLine) { 46 | // if (!match || (match && indent - 2 >= this.indent)) { 47 | // // 这里不能更新pos,否则 indent 会失效 48 | // // const result = advanceOffset(currentLine, offset, column, 2, true); 49 | // // continueResult = { offset: result.offset, column: result.column, spaceInTab: -1 }; 50 | // continueResult = { offset: -1, column: -1, spaceInTab: -1 }; 51 | // } 52 | // } 53 | 54 | if (column - 2 >= this.column) { 55 | continueResult = { offset, column, spaceInTab: -1 }; 56 | } 57 | return continueResult 58 | } 59 | 60 | // @Override 61 | // finalize() { 62 | // super.finalize(); 63 | // } 64 | 65 | // @Override 66 | canContain(mnode: MNode) { 67 | return mnode.type !== NodeType.Item; 68 | } 69 | } 70 | export default ItemNode; -------------------------------------------------------------------------------- /src/view/source-and-preview-view.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: 分屏模式或源码-预览模式(编辑模式) 3 | * @Author: ZengYong 4 | * @CreateDate: 2021-09-18 16:39:41 5 | */ 6 | import BaseView, { IDomPoint } from "./base-view"; 7 | import { TextModel, SelectionModel, SelectionCustom } from "../model/"; 8 | import markdown from "../markdown-parse" 9 | import { escapeXml } from '../markdown-parse/funs'; 10 | 11 | class SourceAndPreviewView extends BaseView { 12 | private previewElement_: HTMLElement | null; 13 | private sourceAndPreviewRenderBinder_: any; 14 | 15 | constructor (textModel: TextModel, selectionModel: SelectionModel, viewContainer: HTMLElement) { 16 | super(textModel, selectionModel, viewContainer); 17 | const previewElement = document.createElement("pre"); 18 | // previewElement.setAttribute("spellcheck", "false"); 19 | // previewElement.setAttribute("style", "width: 50%; background-color: #fafbfc;border-left: 2px solid #ddd"); 20 | previewElement.setAttribute("class", "web-editor-pre preview"); 21 | this.viewContainer_.parentElement?.appendChild(previewElement); 22 | this.previewElement_ = previewElement; 23 | } 24 | 25 | customPointToDomPoint (customPoint: number) { 26 | let domPoint = { 27 | domNode: this.viewContainer_, 28 | domOffset: 0 29 | } 30 | if (this.viewContainer_.childNodes.length) { 31 | domPoint = { 32 | domNode: this.viewContainer_.childNodes[0] as HTMLElement, 33 | domOffset: customPoint 34 | } 35 | } 36 | return domPoint; 37 | } 38 | 39 | domPointToCustomPoint (domPoint: IDomPoint) { 40 | return domPoint.domOffset; 41 | } 42 | 43 | showMarker () { 44 | 45 | } 46 | 47 | /** @override */ 48 | render () { 49 | this.viewContainer_.innerHTML = escapeXml(this.textModel_.getSpacer()) + '\n'; 50 | this.updateDomSelection(); 51 | if (this.previewElement_) { 52 | this.previewElement_.innerHTML = markdown.md2html(this.textModel_.getSpacer()); 53 | // this.viewContainer_.scrollTop = this.viewContainer_.scrollHeight; 54 | } 55 | } 56 | 57 | dispose () { 58 | super.dispose(); 59 | if (this.sourceAndPreviewRenderBinder_) { 60 | this.textModel_.off(TextModel.EVENT_TYPE.TEXT_CHANGE, this.sourceAndPreviewRenderBinder_); 61 | this.sourceAndPreviewRenderBinder_ = null; 62 | } 63 | if (this.previewElement_) { 64 | this.previewElement_.remove(); 65 | this.previewElement_ = null; 66 | } 67 | } 68 | } 69 | export default SourceAndPreviewView; -------------------------------------------------------------------------------- /src/model/selection-model.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from "events"; 2 | import { TextModel } from "./text-model"; 3 | import { debounce } from "../utils/debounce"; 4 | /* 5 | * @Description: 选区数据模型 6 | * @Author: ZengYong 7 | * @CreateDate: 2021-09-17 19:17:06 8 | */ 9 | 10 | export interface SelectionCustom { 11 | anchor: number; 12 | focus: number; 13 | } 14 | 15 | export class SelectionModel extends EventEmitter { 16 | 17 | static EVENT_TYPE = { 18 | SELECTION_CHANGE: 'selection-change' 19 | } 20 | 21 | protected textModel_: TextModel; 22 | protected selection_: SelectionCustom | null; 23 | protected eventTimer_: number | null; 24 | protected selectionChangeEmit_: () => void; 25 | 26 | constructor (textModel: TextModel) { 27 | super(); 28 | this.textModel_ = textModel; 29 | this.selection_ = { anchor: 0, focus: 0 }; 30 | this.selectionChangeEmit_ = debounce(() => { 31 | this.emit(SelectionModel.EVENT_TYPE.SELECTION_CHANGE); 32 | }, 100); 33 | } 34 | /** 判断两个选区模型是否是同一块区域 */ 35 | static isEqual (selection1: SelectionCustom, selection2: SelectionCustom) { 36 | return selection1.anchor === selection2.anchor && selection1.focus === selection2.focus; 37 | } 38 | 39 | getSelection () { 40 | return { ...this.selection_ } as SelectionCustom; 41 | } 42 | 43 | setSelection (selection: SelectionCustom | null) { 44 | if (selection && this.selection_ && selection.anchor === this.selection_.anchor && selection.focus === this.selection_.focus) { 45 | return; 46 | } 47 | this.selection_ = selection ? { ...selection } : null; 48 | // 模型选区变化事件目前其实不需要发布,因为view在textModel发布后会立即更新selection,而不是等待订阅选区模型的事件更新,只有通过api调用设置选区才会有此触发场景 49 | // 并且textModel发布事件时有节流延时,所以view在接收到更新时已经能获取到选区最新数据 50 | // this.selectionChangeEmit_(); 51 | } 52 | 53 | isCollapsed () { 54 | return this.selection_ && this.selection_.anchor === this.selection_.focus; 55 | } 56 | 57 | isBackward () { 58 | return this.selection_ && this.selection_.anchor > this.selection_.focus; 59 | } 60 | 61 | /** 折叠选区, toStart 表示以视觉上的开始点为准,而不是以 focus 为准 */ 62 | // collapse (toStart?: boolean) { 63 | // if (toStart === true) { 64 | // this.selection_.anchor < this.selection_.focus ? this.selection_.anchor = this.selection_.focus : this.selection_.focus = this.selection_.anchor; 65 | // } else { 66 | // this.selection_.anchor = this.selection_.focus; 67 | // } 68 | // return { ...this.selection_ } as SelectionCustom; 69 | // } 70 | 71 | /** 移动光标位置,负数为后退,正数为前进 */ 72 | move (n: number) { 73 | 74 | } 75 | 76 | } 77 | 78 | export default SelectionModel; 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | web-editor-markdown 4 |
5 | A markdown editor in browser, supports collaborative editing 6 |

7 | npm bundle size 8 | 9 | 10 | 11 |
12 |

13 | 14 | ## 💡 Web Editor Markdown 15 | 16 | [web-editor-markdown](https://github.com/Ben-love-zy/web-editor-markdown.git) is a Markdown editor in Web browser and for real-time rendering like `Typora`. It is based on TypeScript and JavaScript, and does not rely on any third-party framework. It supports Chinese friendly and can be easily extended and connected to native JavaScript, Vue, React, Angular and other applications. It provides four rendering modes: `SOURCE`, `SOURCE_AND_PREVIEW`, `RENDER` and `PREVIEW`. If necessary, its underlying layer also supports the ability of collaborative editing and provides atomic `Operation` for extending collaborative editing. 17 | 18 | ### ✨ English Demo 19 | ![](https://gitee.com/zengyong2020/web-editor-markdown/raw/master/demo-en.gif) 20 | ### ✨ Chinese Demo 21 | ![](https://gitee.com/zengyong2020/web-editor-markdown/raw/master/demo-zh.gif) 22 | 23 | 24 | ### 🛠️ Getting started 25 | * install it 26 | ```shell 27 | npm install web-editor-markdown --save 28 | ``` 29 | * use it 30 | ```ts 31 | import { Editor, withUndoRedo } from "web-editor-markdown"; 32 | let editor = new Editor(document.getElementById('id')); 33 | editor = withUndoRedo(editor); // UndoRedo Plugin 34 | editor.insertTextAtCursor('**This is a bold text**\n> tips:You can switch source mode with `cmd+/`'); 35 | ``` 36 | 37 | * others 38 | ```ts 39 | import { EditorViewMode } from "web-editor-markdown"; 40 | editor.switchViewMode(EditorViewMode.PREVIEW); // switch rendering mode,(shortcut key: 'cmd+/') 41 | console.log('content', editor.getContent()); 42 | ``` 43 | 44 | * local source 45 | ```shell 46 | npm install 47 | npm start 48 | ``` 49 | -------------------------------------------------------------------------------- /src/markdown-parse/markdown-block-creater/block-quote-creater.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: 引用识别 3 | * @Author: ZengYong 4 | * @CreateDate: 2021-10-21 11:21:57 5 | */ 6 | import Creater, { ICompleterProps, IDeleteProps, ICreaterProps } from "./creater"; 7 | import { BlockQuoteNode } from "../node"; 8 | import { advanceOffset, isSpacerOrTab } from "../funs" 9 | export class BlockQuoteCreater extends Creater { 10 | static reBlockQuoteMarker = /^>\s*/; 11 | 12 | canDelete (task: IDeleteProps) { 13 | let deleteResult = null; 14 | let lineRest = task.line; 15 | const match = lineRest.match(BlockQuoteCreater.reBlockQuoteMarker); 16 | let deleteLen = 0; 17 | if (match) { 18 | lineRest = lineRest.slice(match[0].length); 19 | deleteLen = match[0].length; 20 | // if (lineRest.length) { 21 | // const spacerMatch = lineRest.match(/^\s+/); 22 | // if (spacerMatch) { 23 | // lineRest = lineRest.slice(spacerMatch[0].length); 24 | // deleteLen += spacerMatch[0].length; 25 | // } 26 | // } 27 | } 28 | if (deleteLen > 0) { 29 | deleteResult = { lineRest, deleteLen } 30 | } 31 | return deleteResult; 32 | } 33 | 34 | 35 | canComplete (task: ICompleterProps) { 36 | let lineRest = task.line; 37 | let completerResult = null; 38 | 39 | const blockQuoteMatch = lineRest.match(BlockQuoteCreater.reBlockQuoteMarker); 40 | if (blockQuoteMatch) { 41 | lineRest = lineRest.slice(blockQuoteMatch[0].length); 42 | if (lineRest.length) { 43 | completerResult = { 44 | completeInput: blockQuoteMatch[0], 45 | lineRest 46 | } 47 | } else { 48 | completerResult = { 49 | completeInput: '', 50 | lineRest, 51 | needDeleteTab: true 52 | } 53 | } 54 | } 55 | return completerResult; 56 | } 57 | 58 | canCreate (task: ICreaterProps) { 59 | let createResult = null; 60 | let char = task.line[task.offset]; 61 | if (char === '>') { 62 | let offsetNew = task.offset; 63 | let columnNew = task.column; 64 | let spaceInTabNew = 0; 65 | const { offset, column, spaceInTab } = advanceOffset(task.line, offsetNew, columnNew, 1); 66 | char = task.line[offset]; 67 | offsetNew = offset; 68 | columnNew = column; 69 | spaceInTabNew = spaceInTab; 70 | if (isSpacerOrTab(char)) { 71 | const result = advanceOffset(task.line, offset, column, 1, true); 72 | offsetNew = result.offset; 73 | columnNew = result.column; 74 | spaceInTabNew = result.spaceInTab; 75 | } 76 | createResult = { 77 | offset: offsetNew, 78 | column: columnNew, 79 | spaceInTab: spaceInTabNew, 80 | mnode: new BlockQuoteNode(task.sourceStart) 81 | } 82 | } 83 | return createResult 84 | } 85 | } 86 | export default BlockQuoteCreater; -------------------------------------------------------------------------------- /src/markdown-parse/markdown-block-creater/table-creater.ts: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | * @Description: 表格识别并创建 4 | * @Author: ZengYong 5 | * @CreateDate: 2021-11-09 20:54:07 6 | */ 7 | import { NodeType, TableNode, TableTbodyNode, TableTheadNode, TableTrNode } from "../node"; 8 | import Creater, { ICreaterProps } from "./creater"; 9 | import { advanceOffset, getPreSpacerOrTab } from "../funs" 10 | export class TableCreater extends Creater { 11 | canCreate (task: ICreaterProps) { 12 | let createResult = null; 13 | let lineRest = task.line.slice(task.offset); 14 | if (lineRest) { 15 | const containerType = task.container.type; 16 | let mnnode; 17 | if (containerType === NodeType.Table) { 18 | const tableTbodyNode = new TableTbodyNode(task.sourceStart); 19 | const tableTrNode = new TableTrNode(task.sourceStart); 20 | tableTrNode.stringContent = lineRest; 21 | tableTbodyNode.appendChild(tableTrNode); 22 | mnnode = tableTbodyNode; 23 | } else if (containerType === NodeType.TableTbody) { 24 | const tableTrNode = new TableTrNode(task.sourceStart); 25 | tableTrNode.stringContent = lineRest; 26 | mnnode = tableTrNode; 27 | } else if (this.maybeTable_(lineRest, task.nextLine)) { 28 | const tableNode = new TableNode(task.sourceStart); 29 | const tableTheadNode = new TableTheadNode(task.sourceStart); 30 | const tableTrNode = new TableTrNode(task.sourceStart, true); 31 | tableTrNode.stringContent = lineRest; 32 | tableTheadNode.appendChild(tableTrNode); 33 | tableNode.appendChild(tableTheadNode); 34 | mnnode = tableNode; 35 | } 36 | if (mnnode) { 37 | const result = advanceOffset(task.line, task.offset, task.column, lineRest.length); 38 | createResult = { 39 | offset: result.offset, 40 | column: result.column, 41 | spaceInTab: result.spaceInTab, 42 | mnode: mnnode 43 | } 44 | } 45 | } 46 | return createResult; 47 | } 48 | 49 | maybeTable_ (currentLine: string, nextLine: string | undefined) { 50 | currentLine = currentLine.replace(/\\\|/g, '**'); // \| 表示转义了,不参与表格判断 51 | if (currentLine && currentLine.indexOf('|') > -1 && currentLine.indexOf('- ') !== 0 && nextLine) { 52 | // nextLine = nextLine.replace(/\s/g, ''); 53 | // 去掉前面空格 54 | const preSpacerOrTab = getPreSpacerOrTab(nextLine); 55 | if (preSpacerOrTab) { 56 | nextLine = nextLine.slice(preSpacerOrTab.length); 57 | } 58 | if (nextLine[0] != '|') { 59 | nextLine = '|' + nextLine; 60 | } 61 | if (nextLine.match(/^(\|\s*:?-+:?\s*)+\|?$/)) { 62 | if (currentLine[0] === '|') { 63 | currentLine = currentLine.slice(1); 64 | } 65 | if (currentLine[currentLine.length - 1] === '|') { 66 | currentLine = currentLine.slice(0, -1); 67 | } 68 | if (nextLine[0] === '|') { 69 | nextLine = nextLine.slice(1); 70 | } 71 | if (nextLine[nextLine.length - 1] === '|') { 72 | nextLine = nextLine.slice(0, -1); 73 | } 74 | const firstNum = currentLine.split('|').length; 75 | const nextNum = nextLine.split('|').length; 76 | if (firstNum === nextNum) { 77 | return true; 78 | } 79 | } 80 | } 81 | return false; 82 | } 83 | } 84 | export default TableCreater; -------------------------------------------------------------------------------- /webpack.config.sdk.js: -------------------------------------------------------------------------------- 1 | 2 | const path = require('path') 3 | 4 | module.exports = { 5 | mode: 'production', 6 | output: { 7 | filename: '[name].min.js', 8 | path: path.resolve(__dirname, './dist'), 9 | libraryTarget: "umd", 10 | }, 11 | entry: { 12 | 'index': './src/index.ts', 13 | }, 14 | resolve: { 15 | extensions: ['.js', '.ts', '.png', '.scss'], 16 | }, 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.less$/, 21 | include: [path.resolve(__dirname, 'src')], 22 | use: [ 23 | { 24 | loader: 'style-loader', 25 | }, 26 | { 27 | loader: 'css-loader', // translates CSS into CommonJS 28 | options: { 29 | url: false, 30 | }, 31 | }, 32 | { 33 | loader: 'postcss-loader', 34 | options: { 35 | postcssOptions: { 36 | plugins: [ 37 | ['autoprefixer', {grid: true, remove: false}], 38 | ], 39 | }, 40 | }, 41 | }, 42 | { 43 | loader: 'less-loader', // compiles Sass to CSS 44 | }, 45 | ], 46 | }, 47 | { 48 | test: /\.scss$/, 49 | include: [path.resolve(__dirname, 'src')], 50 | use: [ 51 | { 52 | loader: 'style-loader', 53 | }, 54 | { 55 | loader: 'css-loader', // translates CSS into CommonJS 56 | options: { 57 | url: false, 58 | }, 59 | }, 60 | { 61 | loader: 'postcss-loader', 62 | options: { 63 | postcssOptions: { 64 | plugins: [ 65 | ['autoprefixer', {grid: true, remove: false}], 66 | ], 67 | }, 68 | }, 69 | }, 70 | { 71 | loader: 'sass-loader', // compiles Sass to CSS 72 | }, 73 | ], 74 | }, 75 | { 76 | test: /\.ts$/, 77 | use: 'ts-loader', 78 | }, 79 | { 80 | test: /\.js$/, 81 | exclude: '/node_modules/', 82 | use: { 83 | loader: 'babel-loader', 84 | options: { 85 | presets: [ 86 | [ 87 | '@babel/env', 88 | { 89 | targets: { 90 | browsers: [ 91 | 'last 2 Chrome major versions', 92 | 'last 2 Firefox major versions', 93 | 'last 2 Safari major versions', 94 | 'last 2 Edge major versions', 95 | 'last 2 iOS major versions', 96 | 'last 2 ChromeAndroid major versions', 97 | ], 98 | }, 99 | }, 100 | ], 101 | ], 102 | }, 103 | }, 104 | }, 105 | // { 106 | // test: /\.png$/, 107 | // include: [path.resolve(__dirname, './src/assets/images')], 108 | // use: [ 109 | // 'file-loader', 110 | // ], 111 | // }, 112 | ], 113 | }, 114 | // plugins: [ 115 | // new HtmlWebpackPlugin({ 116 | // chunks: ['index'], 117 | // filename: './index.html', 118 | // template: './demo/index.html', 119 | // }), 120 | // new CopyPlugin({ 121 | // patterns: [ 122 | // // {from: 'src/css', to: 'css'}, 123 | // // {from: 'src/images', to: 'images'}, 124 | // {from: 'src/js', to: 'js'} 125 | // ], 126 | // }), 127 | // ] 128 | } 129 | -------------------------------------------------------------------------------- /src/assets/index.less: -------------------------------------------------------------------------------- 1 | 2 | .web-editor-pre{ 3 | white-space: pre-wrap; 4 | word-wrap: break-word; 5 | margin: 0; 6 | box-sizing: border-box; 7 | min-height: 800px;; 8 | line-height: 2; 9 | font-size: 16px; 10 | color: #333333; 11 | font-family: Arial,'Microsoft YaHei','微软雅黑','黑体',Heiti,sans-serif,SimSun,'宋体',serif; 12 | overflow: scroll; 13 | width: 50%; 14 | height: 100%; 15 | /* height: 600px; */ 16 | outline: 0 none; 17 | background-color: #fff; 18 | padding: 10px 20px; 19 | box-shadow: 0px 0px 8px 3px #ccc; 20 | &::after{ 21 | content: ' '; 22 | } 23 | &.preview{ 24 | background-color: #fafbfc; 25 | border-left: 2px solid #ddd 26 | } 27 | a{ 28 | color: #4285f4; 29 | text-decoration: underline; 30 | } 31 | .hide { 32 | display: none; 33 | } 34 | ol, ul, li { 35 | margin: 0; 36 | padding: 0; 37 | } 38 | ul, ol { 39 | margin-left: 20px; 40 | } 41 | 42 | h1::before, 43 | h2::before 44 | h3::before, 45 | h4::before, 46 | h5::before, 47 | h6::before, 48 | p:empty::before { 49 | content: ' '; 50 | } 51 | 52 | p { 53 | margin: 10px 0; 54 | &::after 55 | { 56 | content: ' '; 57 | } 58 | } 59 | blockquote { 60 | color: #6a737d; 61 | border-left: 3px solid rgb(196, 199, 204); 62 | margin: 0 0 18px 0px; 63 | padding: 0 16px 64 | } 65 | img{ 66 | max-width: 100%; 67 | } 68 | .editor-marker { 69 | color: #660e7a; 70 | font-weight: bold; 71 | opacity: 0.5; 72 | } 73 | 74 | .list-style-none{ 75 | list-style: none; 76 | } 77 | input{ 78 | margin: 0 10px 3px 0; 79 | vertical-align: middle; 80 | } 81 | pre { 82 | white-space: pre-wrap; 83 | word-wrap: break-word; 84 | padding: 10px; 85 | margin: 0; 86 | box-sizing: border-box; 87 | &.code-block { 88 | background-color: rgba(27, 31, 35, .05); 89 | margin-bottom: 10px; 90 | border-radius: 3px; 91 | } 92 | } 93 | .code-block-inline { 94 | background-color: rgba(27, 31, 35, .05); 95 | margin-bottom: 10px; 96 | border-radius: 3px; 97 | padding: 3px 10px; 98 | } 99 | table{ 100 | border-collapse: collapse; 101 | empty-cells: show; 102 | margin-bottom: 16px; 103 | overflow: auto; 104 | border-spacing: 0; 105 | word-break: keep-all; 106 | width: 100%; 107 | tr { 108 | border: 1px solid #ddd; 109 | padding: 5px; 110 | } 111 | th, td{ 112 | border: 1px solid #ddd; 113 | padding: 10px; 114 | } 115 | } 116 | 117 | } 118 | 119 | // .dark { 120 | // background: rgb(47, 54, 61); 121 | // } 122 | // .dark .list{ 123 | // background-color: #1d2125; 124 | // color: #fff 125 | // } 126 | // .dark .my-editor{ 127 | // border: none; 128 | // } 129 | // .dark span{ 130 | // color: #fff 131 | // } 132 | // .dark .editor-pre{ 133 | // background-color:#24292e ; 134 | // color: #d1d5da 135 | // } 136 | // .dark pre.code-block{ 137 | // background-color: rgba(66, 133, 244, .36); 138 | // } 139 | // .dark .code-block-inline { 140 | // background-color: rgba(66, 133, 244, .36); 141 | // } 142 | // .dark .editor-pre.preview{ 143 | // background-color: #2f363d; 144 | // border: 2px solid #141414 145 | // } 146 | // .dark .editor-pre .editor-marker { 147 | // color: #93c51e 148 | // } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | 2 | const path = require('path') 3 | const HtmlWebpackPlugin = require('html-webpack-plugin') 4 | const CopyPlugin = require('copy-webpack-plugin') 5 | 6 | module.exports = { 7 | mode: 'development', 8 | output: { 9 | filename: '[name]', 10 | path: path.resolve(__dirname, './dist'), 11 | }, 12 | entry: { 13 | 'index.js': './demo/index.ts', 14 | }, 15 | devtool:'eval-source-map', 16 | resolve: { 17 | extensions: ['.js', '.ts', 'less', '.png', '.scss'], 18 | }, 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.less$/, 23 | include: [path.resolve(__dirname, 'src')], 24 | use: [ 25 | { 26 | loader: 'style-loader', 27 | }, 28 | { 29 | loader: 'css-loader', // translates CSS into CommonJS 30 | options: { 31 | url: false, 32 | }, 33 | }, 34 | { 35 | loader: 'postcss-loader', 36 | options: { 37 | postcssOptions: { 38 | plugins: [ 39 | ['autoprefixer', {grid: true, remove: false}], 40 | ], 41 | }, 42 | }, 43 | }, 44 | { 45 | loader: 'less-loader', // compiles Sass to CSS 46 | }, 47 | ], 48 | }, 49 | { 50 | test: /\.scss$/, 51 | include: [path.resolve(__dirname, 'src')], 52 | use: [ 53 | { 54 | loader: 'style-loader', 55 | }, 56 | { 57 | loader: 'css-loader', // translates CSS into CommonJS 58 | options: { 59 | url: false, 60 | }, 61 | }, 62 | { 63 | loader: 'postcss-loader', 64 | options: { 65 | postcssOptions: { 66 | plugins: [ 67 | ['autoprefixer', {grid: true, remove: false}], 68 | ], 69 | }, 70 | }, 71 | }, 72 | { 73 | loader: 'sass-loader', // compiles Sass to CSS 74 | }, 75 | ], 76 | }, 77 | { 78 | test: /\.ts$/, 79 | use: 'ts-loader', 80 | }, 81 | { 82 | test: /\.js$/, 83 | exclude: '/node_modules/', 84 | use: { 85 | loader: 'babel-loader', 86 | options: { 87 | presets: [ 88 | [ 89 | '@babel/env', 90 | { 91 | targets: { 92 | browsers: [ 93 | 'last 2 Chrome major versions', 94 | 'last 2 Firefox major versions', 95 | 'last 2 Safari major versions', 96 | 'last 2 Edge major versions', 97 | 'last 2 iOS major versions', 98 | 'last 2 ChromeAndroid major versions', 99 | ], 100 | }, 101 | }, 102 | ], 103 | ], 104 | }, 105 | }, 106 | }, 107 | // { 108 | // test: /\.png$/, 109 | // include: [path.resolve(__dirname, './src/assets/images')], 110 | // use: [ 111 | // 'file-loader', 112 | // ], 113 | // }, 114 | ], 115 | }, 116 | plugins: [ 117 | new HtmlWebpackPlugin({ 118 | chunks: ['index.js'], 119 | filename: './index.html', 120 | template: './demo/index.html', 121 | }), 122 | // new CopyPlugin({ 123 | // patterns: [ 124 | // // {from: 'src/css', to: 'css'}, 125 | // // {from: 'src/images', to: 'images'}, 126 | // {from: 'src/js', to: 'js'} 127 | // ], 128 | // }), 129 | ], 130 | devServer: { 131 | static: { 132 | directory: path.join(__dirname, '.'), 133 | }, 134 | port: 9001, 135 | host: 'localhost', 136 | proxy: { 137 | '/api': { 138 | target: 'http://localhost:8080', 139 | pathRewrite: {'^/api': ''}, 140 | }, 141 | }, 142 | }, 143 | } 144 | -------------------------------------------------------------------------------- /src/event/keyboard-event.ts: -------------------------------------------------------------------------------- 1 | // import { Editor } from ".."; 2 | import BaseEventHandler from "./base-event"; 3 | import hotkeys from "./hotkeys"; 4 | /* 5 | * @Description: 键盘事件 6 | * @Author: ZengYong 7 | * @CreateDate: 2021-09-18 17:01:46 8 | */ 9 | export class KeyboardEventHandler extends BaseEventHandler{ 10 | // constructor (editor: Editor) { 11 | // super(editor); 12 | // } 13 | 14 | compositionStartHandler (e: CompositionEvent) { 15 | super.compositionStartHandler(e); 16 | } 17 | 18 | compositionEndHandler (e: CompositionEvent) { 19 | super.compositionEndHandler(e); 20 | const text = e.data; 21 | if (text) { 22 | this.editor.insertTextAtCursor(text); 23 | } 24 | } 25 | 26 | // TODO 中文输入需要提取出来处理,这里中文输入有问题 27 | beforeInputHandler_ (e: InputEvent) { 28 | const inputType = e.inputType; 29 | if (inputType === 'insertCompositionText' || inputType === 'deleteCompositionText') { 30 | return; 31 | } 32 | e.preventDefault(); 33 | let text: string | null = null; 34 | switch (inputType) { 35 | case 'deleteContentBackward': // 删除光标前面 36 | this.editor.deleteTextAtCursor(); 37 | break; 38 | case 'deleteContentForward': // 删除光标后面 39 | // TODO 40 | break; 41 | case 'insertParagraph': // 回车 42 | this.editor.insertTextAtCursor('\n'); 43 | break; 44 | case 'insertText': 45 | text = e.data; 46 | if (text) { 47 | this.editor.insertTextAtCursor(text); 48 | } 49 | break; 50 | case 'insertFromPaste': 51 | const data = e.dataTransfer; 52 | text = (data as DataTransfer).getData('text/plain') 53 | if (text) { 54 | this.editor.insertTextAtCursor(text); 55 | } 56 | break; 57 | } 58 | } 59 | 60 | keydownHandler_ (e: KeyboardEvent) { 61 | // console.error('e2', e, hotkeys.isTab(e)) 62 | // if (hotkeys.isDeleteBackward(e)) { 63 | // e.preventDefault(); 64 | // this.editor.deleteText(); 65 | // return; 66 | // } 67 | // if (hotkeys.isDeleteForward(e)) { 68 | // e.preventDefault(); 69 | // return; 70 | // } 71 | // if (hotkeys.isMoveBackward(e)) { 72 | // e.preventDefault(); 73 | // this.editor.moveBackward(); 74 | // return; 75 | // } 76 | // if (hotkeys.isMoveForward(e)) { 77 | // e.preventDefault(); 78 | // this.editor.moveForward(); 79 | // return; 80 | // } 81 | if (hotkeys.isTab(e)) { 82 | e.preventDefault(); 83 | this.editor.insertTextAtCursor('\t'); 84 | } 85 | 86 | if (hotkeys.isRedo(e)) { 87 | e.preventDefault(); 88 | if (this.editor['redo'] && typeof this.editor['redo'] === 'function') { 89 | this.editor['redo'](); 90 | } 91 | return; 92 | } 93 | if (hotkeys.isUndo(e)) { 94 | e.preventDefault(); 95 | if (this.editor['undo'] && typeof this.editor['undo'] === 'function') { 96 | this.editor['undo'](); 97 | } 98 | return; 99 | } 100 | if (hotkeys.isChangeMode(e)) { 101 | e.preventDefault(); 102 | this.editor.switchViewMode(); 103 | return; 104 | } 105 | } 106 | 107 | addListeners () { 108 | const beforeInputHandlerBinder = this.beforeInputHandler_.bind(this); 109 | const keydownHandlerBinder = this.keydownHandler_.bind(this); 110 | const compositionStartHandlerBinder = this.compositionStartHandler.bind(this); 111 | const compositionEndHandlerBinder = this.compositionEndHandler.bind(this); 112 | this.target.addEventListener('beforeinput', beforeInputHandlerBinder); 113 | this.target.addEventListener('keydown', keydownHandlerBinder); 114 | this.target.addEventListener('compositionstart', compositionStartHandlerBinder); 115 | this.target.addEventListener('compositionend', compositionEndHandlerBinder); 116 | 117 | 118 | this.cacheEventHandler ({ type: 'beforeinput', listener: beforeInputHandlerBinder }); 119 | this.cacheEventHandler ({ type: 'keydown', listener: keydownHandlerBinder }); 120 | this.cacheEventHandler ({ type: 'compositionstart', listener: compositionStartHandlerBinder }); 121 | this.cacheEventHandler ({ type: 'compositionend', listener: compositionEndHandlerBinder }); 122 | } 123 | 124 | } 125 | export default KeyboardEventHandler; -------------------------------------------------------------------------------- /src/markdown-parse/node/node.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: Mnode,Markdown解析后的AST语法树节点,链表形式(便于插入和删除操作)。与原生的dom类似。 3 | * @Author: ZengYong 4 | * @CreateDate: 2021-10-19 19:27:37 5 | */ 6 | 7 | 8 | export enum NodeType{ 9 | Document = 'document', 10 | BlockQuote = 'blockQuote', 11 | List = 'list', 12 | Item = 'item', 13 | Paragraph = 'paragraph', 14 | Head = 'head', 15 | Table = 'table', 16 | TableThead = 'tableThead', 17 | TableTh = 'tableTh', 18 | TableTbody = 'tableTbody', 19 | TableTr = 'tableTr', 20 | TableTd = 'tableTd', 21 | Emph = 'emph', 22 | Strong = 'strong', 23 | Link = 'link', 24 | Image = 'image', 25 | CustomInline = 'customInline', 26 | CustomBlock = 'customBlock', 27 | CodeBlock = 'codeBlock', 28 | HtmlBlock = 'htmlBlock', 29 | ThematicBreak = 'thematicBreak', 30 | Text = 'text', 31 | Underline = 'underline', 32 | Checkbox = 'checkbox', 33 | Del = 'del', 34 | Code = 'code', 35 | } 36 | 37 | 38 | 39 | export class MNode { 40 | type: NodeType; 41 | parent: MNode | null = null; 42 | firstChild: MNode | null = null; 43 | lastChild: MNode | null = null; 44 | prev: MNode | null = null; 45 | next: MNode | null = null; 46 | open: boolean = true; 47 | stringContent: string = ''; 48 | sourceStart: number; 49 | sourceEnd: number; 50 | blockMarkerBefore: string | undefined = ''; // 标识占位符(叶子块需要,目前只有代码块和标题需要,因为其他叶子快内部可以包含p标签) 51 | blockMarkerAfter: string | undefined = ''; // 标识占位符(叶子块需要,目前只有代码块和标题需要,因为其他叶子快内部可以包含p标签) 52 | marker: string = ''; // 是否是文本标记符占位节点 53 | isShow: boolean = true; // 是否需要显示和隐藏(隐藏的话直接不创建dom节点,占位场景) 54 | // lastLineBlank: boolean = false; // 末尾是否存在空白行,用于换行时容器切换 55 | 56 | constructor (sourceStart: number) { 57 | this.sourceStart = sourceStart; 58 | } 59 | 60 | unlink () { 61 | if (this.prev) { 62 | this.prev.next = this.next; 63 | } else if (this.parent) { 64 | this.parent.firstChild = this.next; 65 | } 66 | if (this.next) { 67 | this.next.prev = this.prev; 68 | } else if (this.parent) { 69 | this.parent.lastChild = this.prev; 70 | } 71 | this.parent = null; 72 | this.next = null; 73 | this.prev = null; 74 | } 75 | 76 | appendChild (mnode: MNode) { 77 | mnode.unlink(); 78 | mnode.parent = this; 79 | if (this.lastChild) { 80 | this.lastChild.next = mnode; 81 | mnode.prev = this.lastChild; 82 | this.lastChild = mnode; 83 | } else { 84 | this.firstChild = this.lastChild = mnode; 85 | } 86 | } 87 | 88 | prependChild (mnode: MNode) { 89 | mnode.unlink(); 90 | mnode.parent = this; 91 | if (this.firstChild) { 92 | this.firstChild.prev = mnode; 93 | mnode.next = this.firstChild; 94 | this.firstChild = mnode; 95 | } else { 96 | this.firstChild = mnode; 97 | this.lastChild = mnode; 98 | } 99 | } 100 | 101 | insertAfter (mnode: MNode) { 102 | mnode.unlink(); 103 | mnode.next = this.next; 104 | if (mnode.next) { 105 | mnode.next.prev = mnode; 106 | } 107 | mnode.prev = this; 108 | this.next = mnode; 109 | mnode.parent = this.parent; 110 | if (!mnode.next && mnode.parent) { 111 | mnode.parent.lastChild = mnode; 112 | } 113 | } 114 | 115 | insertBefore (mnode: MNode) { 116 | mnode.unlink(); 117 | mnode.prev = this.prev; 118 | if (mnode.prev) { 119 | mnode.prev.next = mnode; 120 | } 121 | mnode.next = this; 122 | this.prev = mnode; 123 | mnode.parent = this.parent; 124 | if (!mnode.prev && mnode.parent) { 125 | mnode.parent.firstChild = mnode; 126 | } 127 | } 128 | 129 | setStringContent (stringContent: string) { 130 | this.stringContent = stringContent; 131 | } 132 | 133 | getStringContent () { 134 | return this.stringContent; 135 | } 136 | 137 | /** 段落延续规则怕判断(当前节点块是否可包含跨行内容,即新的一行能否放入当前块,而不用新建节点) */ 138 | continue (currentLine: string, offset: number, colum: number): any { 139 | return null; 140 | } 141 | /** 在节点解析关闭时运行。理解为node关闭回调钩子,关闭后需要处理的事务,如html替换掉\n、识别段首链接等 */ 142 | finalize(sourceEnd?: number) { 143 | this.open = false; 144 | if (sourceEnd) { 145 | this.sourceEnd = sourceEnd; 146 | } 147 | } 148 | 149 | /** 是否能包含类型为 nodeType 的子块 */ 150 | canContain(mnode: MNode) { 151 | return false; 152 | } 153 | } 154 | export default MNode; -------------------------------------------------------------------------------- /src/plugins/undo-redo/undo-redo.ts: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | * @Description: 撤销回退扩展 4 | * @Author: ZengYong 5 | * @CreateDate: 2021-09-17 19:17:45 6 | */ 7 | import { Editor, Operation, SetSelectionOperation, RemoveTextOperation, InsertTextOperation } from "../../index" 8 | import { UndoRedoEditor, History } from "./undo-redo-editor"; 9 | import { debounce } from '../../utils/debounce' 10 | 11 | 12 | 13 | export const withUndoRedo = (editor: T) => { 14 | 15 | const undoRedoEditor = editor as T & UndoRedoEditor; 16 | const apply = editor.apply.bind(editor); 17 | const clearContent = editor.clearContent.bind(editor); 18 | const setContent = editor.setContent.bind(editor); 19 | 20 | undoRedoEditor.history = { undos: [], redos: [] }; 21 | undoRedoEditor.operations = []; 22 | 23 | undoRedoEditor.redo = () => { 24 | const { history } = undoRedoEditor; 25 | const { redos } = history; 26 | 27 | if (redos.length > 0) { 28 | const batch = redos.pop() as Operation[]; 29 | 30 | for (const op of batch) { 31 | apply(op); 32 | } 33 | 34 | // history.redos.pop(); 35 | history.undos.push(batch); 36 | } 37 | } 38 | 39 | undoRedoEditor.undo = () => { 40 | const { history } = undoRedoEditor; 41 | const { undos } = history; 42 | 43 | if (undos.length > 0) { 44 | const batch = undos.pop() as Operation[]; 45 | const inverseOps = batch.map((op) => { 46 | return op.inverse(); 47 | }).reverse(); 48 | for (const op of inverseOps) { 49 | apply(op); 50 | } 51 | 52 | history.redos.push(batch); 53 | // history.undos.pop(); 54 | } 55 | } 56 | 57 | undoRedoEditor.apply = (op: Operation) => { 58 | const { operations, history } = undoRedoEditor; 59 | // const { undos } = history; 60 | 61 | // const lastBatch = undos[undos.length - 1]; 62 | // const lastOp = lastBatch && lastBatch[lastBatch.length - 1]; 63 | // const merge = shouldMerge(op, lastOp); 64 | // const overwrite = shouldOverwrite(op, lastOp) 65 | 66 | // if (lastBatch && merge) { 67 | // if (overwrite) { 68 | // lastBatch.pop() 69 | // } 70 | // lastBatch.push(op); 71 | // } else { 72 | // undos.push([op]); 73 | // } 74 | 75 | // // TODO 可以考虑放异步 76 | // while (undos.length > 100) { 77 | // undos.shift(); 78 | // } 79 | // if (shouldClear(op)) { 80 | // history.redos = []; 81 | // } 82 | apply(op); 83 | operations.push(op); 84 | addHistory(operations, history); 85 | } 86 | 87 | undoRedoEditor.clearContent = () => { 88 | const { history } = undoRedoEditor; 89 | clearContent(); 90 | setTimeout(() => { 91 | history.undos = []; 92 | history.redos = []; 93 | }, 300); 94 | 95 | } 96 | 97 | undoRedoEditor.setContent = (content: string) => { 98 | const { history } = undoRedoEditor; 99 | setContent(content); 100 | setTimeout(() => { 101 | history.undos = []; 102 | history.redos = []; 103 | }, 300); 104 | } 105 | 106 | return undoRedoEditor; 107 | } 108 | 109 | 110 | const shouldMerge = (op: Operation, prev: Operation | undefined): boolean => { 111 | if (op instanceof SetSelectionOperation) { 112 | return true 113 | } 114 | 115 | // if ( 116 | // prev && 117 | // op instanceof InsertTextOperation && 118 | // prev instanceof InsertTextOperation && 119 | // op.getInsertIndex_() === prev.getInsertIndex_() + prev.getSapcers().length 120 | // ) { 121 | // return true 122 | // } 123 | 124 | // if ( 125 | // prev && 126 | // op instanceof RemoveTextOperation && 127 | // prev instanceof RemoveTextOperation && 128 | // op.getEndIndex() === prev.getStartIndex() 129 | // ) { 130 | // return true 131 | // } 132 | 133 | return false 134 | } 135 | 136 | const shouldOverwrite = (op: Operation, prev: Operation | undefined): boolean => { 137 | if (prev && op instanceof SetSelectionOperation && prev instanceof SetSelectionOperation) { 138 | return true 139 | } 140 | 141 | return false 142 | } 143 | 144 | const shouldClear = (op: Operation): boolean => { 145 | return op instanceof InsertTextOperation; 146 | } 147 | 148 | const addHistory = debounce((operations: Operation[], history: History) => { 149 | // if (operations instanceof SetSelectionOperation) { 150 | // return 151 | // } 152 | const { undos } = history; 153 | let lastBatch = undos[undos.length - 1]; 154 | let lastBatchOp = lastBatch && lastBatch[lastBatch.length - 1]; 155 | let sameBatch = false; 156 | for (let op of operations) { 157 | let overwrite = false; 158 | // 将连续的选区变动合并,减少消耗,同时这里涉及到撤销时光标跟随的逻辑 159 | if (op instanceof SetSelectionOperation && lastBatchOp && lastBatchOp instanceof SetSelectionOperation) { 160 | op = SetSelectionOperation.merge(lastBatchOp, op); 161 | overwrite = true; 162 | } 163 | if (sameBatch || overwrite) { 164 | if (overwrite) { 165 | lastBatch.pop() 166 | } 167 | lastBatch.push(op); 168 | } else { 169 | lastBatch = [op]; 170 | undos.push(lastBatch); 171 | sameBatch = true; // 超过节流时间强制合并 172 | } 173 | lastBatchOp = op; 174 | if (shouldClear(op)) { 175 | history.redos = []; 176 | } 177 | } 178 | // TODO 可以考虑放异步 179 | while (undos.length > 100) { 180 | undos.shift(); 181 | } 182 | operations.length = 0; 183 | }, 300); 184 | -------------------------------------------------------------------------------- /src/markdown-parse/markdown-block-creater/list-item-creater.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: 列表和列表项识别 3 | * @Author: ZengYong 4 | * @CreateDate: 2021-10-21 11:21:57 5 | */ 6 | 7 | import Creater, { ICompleterProps, IDeleteProps, ICreaterProps } from "./creater"; 8 | import { NodeType, ListNode, ItemNode, SubNode } from "../node"; 9 | import { advanceOffset, isSpacerOrTab } from "../funs" 10 | export class ListItemCreater extends Creater { 11 | // static reBulletListMarker = /^[*+-]/; 12 | // static reOrderedListMarker = /^(\d{1,9})([.)])/; 13 | 14 | static reBulletListMarker = /^([*+-]\s)/; 15 | static reOrderedListMarker = /^(\d{1,9})([.)])\s/; 16 | 17 | canDelete (task: IDeleteProps) { 18 | let deleteResult = null; 19 | let lineRest = task.line; 20 | let deleteLen = 0; 21 | let listType = 'bullet'; 22 | let match = lineRest.match(ListItemCreater.reBulletListMarker); 23 | if (!match) { 24 | match = lineRest.match(ListItemCreater.reOrderedListMarker); 25 | listType = 'ordered'; 26 | } 27 | if (match) { 28 | lineRest = lineRest.slice(match[0].length); 29 | deleteLen = match[0].length; 30 | if (listType === 'bullet') { 31 | const checkboxMatch = lineRest.match(/^\s*\[(?:[x X])\]\s/); 32 | if (checkboxMatch) { 33 | lineRest = lineRest.slice(checkboxMatch[0].length); 34 | deleteLen += checkboxMatch[0].length; 35 | } 36 | } 37 | // if (lineRest.length) { 38 | // const spacerMatch = lineRest.match(/^\s+/); 39 | // if (spacerMatch) { 40 | // lineRest = lineRest.slice(spacerMatch[0].length); 41 | // deleteLen += spacerMatch[0].length; 42 | // } 43 | // } 44 | } 45 | if (deleteLen > 0) { 46 | deleteResult = { lineRest, deleteLen } 47 | } 48 | return deleteResult; 49 | } 50 | 51 | canComplete111 (task: ICompleterProps) { 52 | let lineRest = task.line; 53 | const bulletMatch = lineRest.match(ListItemCreater.reBulletListMarker); // 无序列表 54 | let completerResult = null; 55 | let match; 56 | let lastMatch = ''; 57 | let lastSpacerMatch = ''; 58 | let preSpacerCount = 0; // 需在前面补充多少个空格 59 | while ((match = lineRest.match(ListItemCreater.reBulletListMarker)) || (match = lineRest.match(ListItemCreater.reOrderedListMarker))) { 60 | lineRest = lineRest.slice(match[0].length); 61 | if (lineRest.length) { 62 | const spacerMatch = lineRest.match(/^\s+/); 63 | if (spacerMatch) { 64 | lineRest = lineRest.slice(spacerMatch[0].length); 65 | if (lineRest.length) { // 非空行才更新,因为空行需要退位 66 | lastSpacerMatch = spacerMatch[0]; 67 | preSpacerCount += lastSpacerMatch.length; 68 | } 69 | } 70 | if (lineRest.length) { // 非空行才更新,因为空行需要退位 71 | lastMatch = match[0]; 72 | preSpacerCount += lastMatch.length; 73 | } 74 | } 75 | } 76 | return completerResult; 77 | } 78 | 79 | canComplete (task: ICompleterProps) { 80 | let lineRest = task.line; 81 | let bulletMatch = lineRest.match(ListItemCreater.reBulletListMarker); // 无序列表 82 | let orderedMatch = null; 83 | let completerResult = null; 84 | // let needDeletePreLine = false; // 是否需要删除上一行 85 | // 非空列表行 86 | if (bulletMatch) { 87 | lineRest = lineRest.slice(bulletMatch[0].length); 88 | if (lineRest) { 89 | let completeInput = bulletMatch[0]; 90 | const checkboxMatch = lineRest.match(/^\[(?:[x X])\]\s/); 91 | if (checkboxMatch) { 92 | lineRest = lineRest.slice(checkboxMatch[0].length); 93 | if (lineRest) { 94 | completeInput += checkboxMatch[0]; 95 | } 96 | } 97 | if (lineRest) { 98 | completerResult = { 99 | completeInput, 100 | lineRest 101 | } 102 | } 103 | } 104 | } else { 105 | orderedMatch = lineRest.match(ListItemCreater.reOrderedListMarker); // 有序列表 106 | if (orderedMatch) { 107 | lineRest = lineRest.slice(orderedMatch[0].length) 108 | if (lineRest) { 109 | const orderedMatchArr = orderedMatch[0].split('.'); 110 | const orderedNum = parseInt(orderedMatchArr[0]); 111 | completerResult = { 112 | completeInput: (orderedNum + 1) + '.' + orderedMatchArr[1], 113 | lineRest 114 | } 115 | } 116 | } 117 | } 118 | if ((bulletMatch || orderedMatch) && (!lineRest)) { 119 | completerResult = { 120 | completeInput: '', 121 | lineRest, 122 | needDeleteTab: true 123 | } 124 | } 125 | // completerResult.needDeletePreLine = needDeletePreLine; 126 | return completerResult; 127 | } 128 | 129 | 130 | 131 | canCreate (task: ICreaterProps) { 132 | let createResult = null; 133 | let mnnode: SubNode; 134 | let listNode = null; 135 | const matchResult = this.matchList_(task.line, task.offset); 136 | if (matchResult) { 137 | if (task.container.type !== NodeType.List || !this.isSameList_(task.container as ListNode | ItemNode, matchResult)) { 138 | listNode = new ListNode(task.sourceStart, matchResult.listType, matchResult.bulletChar, matchResult.delimiter, task.column, matchResult.start); 139 | } 140 | const itemNode = new ItemNode(task.sourceStart, matchResult.listType, matchResult.bulletChar, matchResult.delimiter, task.column); 141 | if (listNode) { 142 | listNode.appendChild(itemNode); 143 | mnnode = listNode; 144 | } else { 145 | mnnode = itemNode; 146 | } 147 | const result = advanceOffset(task.line, task.offset, task.column, matchResult.offset); 148 | createResult = { 149 | offset: result.offset, 150 | column: result.column, 151 | spaceInTab: result.spaceInTab, 152 | mnode: mnnode 153 | } 154 | } 155 | return createResult; 156 | } 157 | 158 | isSameList_ (container: ListNode | ItemNode, matchResult: any) { 159 | return ( 160 | container.listType === matchResult.listType && 161 | container.delimiter === matchResult.delimiter && 162 | container.bulletChar === matchResult.bulletChar 163 | ); 164 | } 165 | 166 | matchList_ (line: string, offset: number) { 167 | let listType = ''; 168 | let bulletChar = ''; 169 | let delimiter = ''; 170 | let start = '1'; 171 | const rest = line.slice(offset); 172 | let match = rest.match(ListItemCreater.reBulletListMarker); 173 | if (match) { 174 | listType = 'bullet'; 175 | bulletChar = match[0][0]; 176 | } else { 177 | match = rest.match(ListItemCreater.reOrderedListMarker); 178 | if (match) { 179 | start = match[1]; 180 | listType = 'ordered'; 181 | delimiter = match[2]; 182 | } else { 183 | return null; 184 | } 185 | } 186 | // const nextChar = line[offset + match[0].length]; 187 | // // 符号后面必须有空格或者tab才触发列表 188 | // if (!isSpacerOrTab(nextChar)) { 189 | // return null; 190 | // } 191 | return { listType, bulletChar, delimiter, offset: match[0].length, start}; 192 | } 193 | 194 | 195 | } 196 | export default ListItemCreater; -------------------------------------------------------------------------------- /src/markdown-parse/markdown-parser-block/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: 将 md 字符串转换成语法树 doc,当前类主要进行 block 段落级别处理 3 | * @Author: ZengYong 4 | * @CreateDate: 2021-09-23 17:44:48 5 | */ 6 | 7 | import { DocumentNode, NodeType, ParagraphNode ,SubNode } from "../node"; 8 | import BlockCreaterFactory from "../markdown-block-creater/factory"; 9 | import { getPreSpacerOrTab } from "../funs"; 10 | 11 | class MarkdownParserBlock { 12 | 13 | private doc_: DocumentNode; // 语法树 14 | private sourceIndex_: number; // 整个字符串的当前位置,用于源码映射 15 | private offset_: number; // 当前行当前处理的字符位置 16 | private column_: number; // 缩进记录 17 | private tip_: SubNode; // 当前容器位置 18 | private blockParseCreater_ = new BlockCreaterFactory().build(); // 块创建器:根据行首字符创建不同的 AST 节点类型(采用责任链模式) 19 | private currentLine_: string; // 当前正在处理的行 20 | private nextLine_: string | undefined; // 下一行 21 | private blank_: boolean; // 当前处理的行最后是否包含了换行符,或者是空行 22 | private indent_: number; // 当前处理的行最前面有几个空格缩进,tab为4个 23 | private spaceInTab_: number; // 当前 offset 在 tab 内剩余的空格数 24 | // private inTable_: boolean = false; 25 | 26 | parse (md: string) { 27 | this.doc_ = new DocumentNode(0); 28 | this.tip_ = this.doc_; 29 | this.sourceIndex_ = 0; 30 | const lines = md.split(/\r\n|\n|\r/); 31 | const len = lines.length; 32 | for (let i = 0; i < len; i++) { 33 | const line = lines[i]; 34 | this.nextLine_ = lines[i + 1]; 35 | this.parseBlock_(line); 36 | this.sourceIndex_ += line.length + 1;// + 1是为了模型的换行符,保持模型位置同步,但因为换行符不属于任何一行,所以调用finalize方法时需要 -1。 37 | } 38 | // this.sourceIndex_--; // 最后一行的末尾不加1 39 | while (this.tip_) { 40 | this.tip_.finalize(this.sourceIndex_ - 1); 41 | this.tip_ = this.tip_.parent as SubNode; 42 | } 43 | return this.doc_; 44 | } 45 | 46 | parseBlock_ (line: string) { 47 | this.offset_ = 0; 48 | this.column_ = 0; 49 | this.currentLine_ = line; 50 | // this.dealSpace_(); 51 | const container = this.beforeParse_(); 52 | if (!container) return; 53 | this.tip_ = container as SubNode; 54 | 55 | // 根据 MD 规范:代码块、列表和列表项这三种为容器块, 需要继续循环,其他均为叶子 56 | let createResult; 57 | // 代码块内部不用进行块解析 58 | if (container.type !== NodeType.CodeBlock) { 59 | // let spaceResult; 60 | do { 61 | this.dealSpace_(); 62 | createResult = this.blockParseCreater_.create({ 63 | line: line, 64 | nextLine: this.nextLine_, 65 | offset: this.offset_, 66 | column: this.column_, 67 | container: this.tip_, 68 | // indent: this.indent_, 69 | sourceStart: this.sourceIndex_ + this.offset_ 70 | }); 71 | if (createResult) { 72 | this.addChild_(createResult.mnode); 73 | this.fixOffsetAndColumn_(createResult.offset, createResult.column, createResult.spaceInTab); 74 | if (createResult.mnode.type === NodeType.CodeBlock) { 75 | // 刚生成代码块,不用进行后续逻辑了,避免增加多余的空行 76 | return; 77 | } 78 | } 79 | } while (createResult && createResult.mnode.isBlockContainer && this.currentLine_[this.offset_]); 80 | } 81 | // 代码块刚识别生成后还在第一行,不用添加内容 text 82 | // if (!createResult || (createResult && createResult.mnode.type !== NodeType.CodeBlock)) { 83 | if (!this.tip_.canContainText) { 84 | this.addChild_(new ParagraphNode(this.sourceIndex_ + this.offset_)); 85 | } 86 | this.addLineText_(); 87 | 88 | // if (this.blank_ && container.type === NodeType.Item) { 89 | // container.lastLineBlank = true; 90 | // } 91 | // } 92 | } 93 | 94 | /** 检测段落延续,延续段落不需要新建节点,同时跳过对应的offset,相当于预处理一些工作 */ 95 | beforeParse_ () { 96 | let container: SubNode = this.doc_; 97 | const lineIndent = this.dealSpace_(); 98 | this.indent_ = lineIndent; 99 | while (container) { 100 | if (!container.open) { 101 | container = container.parent as SubNode; 102 | break; 103 | } 104 | const continueResult = container.continue(this.currentLine_, this.offset_, this.column_); 105 | if (continueResult) { 106 | // 预处理时可能需要变更指针,同时也就改变了下一次的dealSpace_计算indent结果 107 | this.fixOffsetAndColumn_(continueResult.offset, continueResult.column, continueResult.spaceInTab); 108 | if (continueResult.end) { 109 | container.finalize(this.sourceIndex_ + this.offset_); 110 | // this.tip_ = this.tip_.parent as SubNode; // 会导致底层的tip绕过finalize,如container是thread,但tip是其子元素tr时,这里tip不用此时立即更新,而是走下一行循环进入此方法底部的while判断即可 111 | return; 112 | } 113 | if (container.lastChild) { 114 | container = container.lastChild as SubNode; 115 | this.dealSpace_(); 116 | } else { 117 | break; 118 | } 119 | } else { 120 | container = container.parent as SubNode; 121 | break; 122 | } 123 | } 124 | // 结束未匹配到的底层节点 125 | while (container !== this.tip_) { 126 | this.tip_.finalize(this.sourceIndex_ - 1); 127 | this.tip_ = this.tip_.parent as SubNode; 128 | } 129 | return container; 130 | } 131 | 132 | addChild_ (mnode: SubNode) { 133 | while (!this.tip_.canContain(mnode)) { 134 | this.tip_.finalize(this.sourceIndex_ - 1); 135 | this.tip_ = this.tip_.parent as SubNode; 136 | } 137 | this.tip_.appendChild(mnode); 138 | let lastChild: SubNode | null = mnode; 139 | while (lastChild) { 140 | this.tip_ = lastChild; 141 | lastChild = lastChild.lastChild as SubNode | null 142 | } 143 | } 144 | 145 | dealSpace_ () { 146 | const currentLine = this.currentLine_; 147 | let c; 148 | // let offset = this.offset_; 149 | let column = this.column_; 150 | while ((c = currentLine.charAt(this.offset_)) !== "") { 151 | if (c === " ") { // 空格 152 | this.offset_++; 153 | this.column_++; 154 | } else if (c === "\t") { // tab 155 | this.offset_++; 156 | this.column_ += 4 - (this.column_ % 4); 157 | } else { 158 | break; 159 | } 160 | } 161 | this.blank_ = c === "\n" || c === "\r" || c === ""; 162 | return this.column_ - column; 163 | // this.indent_ = column - this.column_; 164 | // return { offset, column } 165 | } 166 | 167 | fixOffsetAndColumn_ (offset: number, column: number, spaceInTab: number) { 168 | if (offset > -1) { 169 | this.offset_ = offset; 170 | } 171 | if (column > -1) { 172 | this.column_ = column; 173 | } 174 | if (spaceInTab > -1) { 175 | this.spaceInTab_ = spaceInTab; 176 | } 177 | } 178 | 179 | addLineText_ () { 180 | let stringContent = this.tip_.getStringContent() || ''; 181 | if (this.spaceInTab_) { // 表示当前处于一个tab内部(一个tab 4个空格) 182 | this.offset_ += 1; // 补齐一个tab的空格 183 | // add space characters: 184 | const charsToTab = 4 - (this.column_ % 4); 185 | stringContent += " ".repeat(charsToTab); 186 | } 187 | // 代码块内部没有用标签换行,所以需要用字符换行。但是第一行的前面不能加换行 188 | if (this.tip_.type === NodeType.CodeBlock) { 189 | // console.error('stringContent', stringContent, stringContent.length) 190 | stringContent += this.currentLine_; // 代码块保留整行内容,包括行首空格 191 | stringContent += '\n'; 192 | } else if (this.tip_.type === NodeType.Paragraph && this.tip_.parent?.type === NodeType.Document) { 193 | // TODO 普通段落标签保留整行内容,包括行首空格,并修改源码坐标映射(待优化) 194 | stringContent += this.currentLine_; 195 | this.tip_.sourceStart = this.sourceIndex_; 196 | } else { 197 | stringContent += this.currentLine_.slice(this.offset_); 198 | } 199 | this.tip_.setStringContent(stringContent); 200 | } 201 | } 202 | export default MarkdownParserBlock; -------------------------------------------------------------------------------- /src/markdown-parse/markdown-render/html-generate.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: 根据节点生成对应的html标签 3 | * @Author: ZengYong 4 | * @CreateDate: 2021-10-27 18:13:27 5 | */ 6 | import { NodeType , BlockQuoteNode, CodeBlockNode, DocumentNode, EmphNode, HeadNode, HtmlBlockNode, ImageNode, LinkNode, ListNode, ItemNode, ParagraphNode, StrongNode, TextNode, ThematicBreakNode, DelNode, UnderlineNode, SubNode, TableThNode, TableNode, TableTheadNode, TableTrNode, TableTdNode, TableTbodyNode, CheckboxNode, CodeNode } from "../node"; 7 | import { escapeXml } from "../funs"; 8 | export class HtmlGenerate { 9 | 10 | createTag_ (tagName: string, attrs?: string[][], selfClosing?: boolean) { 11 | let buffer = `<${tagName}`; 12 | if (attrs) { 13 | for (let attr of attrs) { 14 | buffer += ` ${attr[0]}="${attr[1]}"`; 15 | } 16 | } 17 | if (selfClosing) { 18 | buffer += ' /'; 19 | } 20 | buffer += '>'; 21 | return buffer; 22 | } 23 | 24 | /** 设置源码映射对应的坐标 */ 25 | getSource_ (mnode: SubNode, attrs: string[][] = []) { 26 | attrs.push(['i', String(mnode.sourceStart || 0) + '-' + String(mnode.sourceEnd || 0)]); 27 | if (mnode.isParagraph) { 28 | attrs.push(['class', 'editor-block']) 29 | } 30 | return attrs; 31 | } 32 | 33 | [NodeType.BlockQuote] (buffer: string, mnode: BlockQuoteNode, close?: boolean) { 34 | if (close) { 35 | buffer += this.createTag_('/blockquote'); 36 | } else { 37 | buffer += this.createTag_('blockquote', this.getSource_(mnode)); 38 | } 39 | return buffer; 40 | } 41 | 42 | [NodeType.CodeBlock] (buffer: string, mnode: CodeBlockNode, close?: boolean) { 43 | if (close) { 44 | buffer += this.createTag_('/code'); 45 | buffer += this.createTag_('/pre'); 46 | } else { 47 | const source = this.getSource_(mnode); 48 | for (let item of source) { 49 | if (item[0] === 'class') { 50 | item[1] += ' code-block'; 51 | break; 52 | } 53 | } 54 | buffer += this.createTag_('pre', source); 55 | buffer += this.createTag_('code', source); 56 | } 57 | // const source = this.getSource_(mnode); 58 | // buffer += this.createTag_('pre', source); 59 | // buffer += this.createTag_('code', source); 60 | // buffer += mnode.stringContent; 61 | // buffer += this.createTag_('/code'); 62 | // buffer += this.createTag_('/pre'); 63 | return buffer; 64 | } 65 | 66 | [NodeType.Emph] (buffer: string, mnode: EmphNode, close?: boolean) { 67 | if (close) { 68 | buffer += this.createTag_('/em'); 69 | } else { 70 | buffer += this.createTag_('em', this.getSource_(mnode)); 71 | } 72 | return buffer; 73 | } 74 | 75 | [NodeType.Head] (buffer: string, mnode: HeadNode, close?: boolean) { 76 | const tagName = "h" + mnode.level; 77 | if (close) { 78 | buffer += this.createTag_('/' + tagName); 79 | } else { 80 | buffer += this.createTag_(tagName, this.getSource_(mnode)); 81 | } 82 | return buffer; 83 | } 84 | 85 | [NodeType.HtmlBlock] (buffer: string, mnode: HtmlBlockNode, close?: boolean) { 86 | // TODO 先暂时不显示html效果,因为源码映射时 html已经不占用source了,这里i属性区间会有问题 87 | // let htmlContent = mnode.htmlContent; 88 | // if (!mnode.isCloseTag) { 89 | // const len = htmlContent.length; 90 | // htmlContent = `${htmlContent.slice(0, len - 1)} i="${mnode.sourceStart}-${mnode.sourceEnd}" >`; 91 | // } 92 | // buffer += htmlContent; 93 | 94 | // TODO 暂时将html作为普通文本显示 95 | buffer += escapeXml(mnode.htmlContent); 96 | return buffer; 97 | } 98 | 99 | [NodeType.Image] (buffer: string, mnode: ImageNode, close?: boolean) { 100 | // if (!close) { 101 | // buffer += '';
102 |     // } else {
103 |     //   buffer += ''; 104 | // } 105 | if (!close) { 106 | const attrs = [['src', escapeXml(mnode.src)]]; 107 | let imgStr = this.createTag_('img', this.getSource_(mnode, attrs)); 108 | imgStr = imgStr.substr(0, imgStr.length - 1); 109 | imgStr += ' alt="'; 110 | buffer += imgStr; 111 | } else { 112 | buffer += '" />'; 113 | } 114 | return buffer; 115 | } 116 | 117 | [NodeType.Link] (buffer: string, mnode: LinkNode, close?: boolean) { 118 | const attrs = [['href', mnode.href]]; 119 | if (close) { 120 | buffer += this.createTag_('/a'); 121 | } else { 122 | buffer += this.createTag_('a', this.getSource_(mnode, attrs)); 123 | } 124 | return buffer; 125 | } 126 | 127 | [NodeType.List] (buffer: string, mnode: ListNode, close?: boolean) { 128 | const tagName = mnode.listType === 'bullet' ? 'ul' : 'ol'; 129 | if (close) { 130 | buffer += this.createTag_('/' + tagName); 131 | } else { 132 | const attrs: string[][] = []; 133 | if (tagName === 'ol') { 134 | attrs.push(['start', mnode.start]); 135 | } 136 | buffer += this.createTag_(tagName, attrs); 137 | } 138 | return buffer; 139 | } 140 | 141 | [NodeType.Item] (buffer: string, mnode: ItemNode, close?: boolean) { 142 | if (close) { 143 | buffer += this.createTag_('/li'); 144 | } else { 145 | const source = this.getSource_(mnode); 146 | if (mnode.listStyle === 'none') { 147 | for (let item of source) { 148 | if (item[0] === 'class') { 149 | item[1] += ' list-style-none'; 150 | break; 151 | } 152 | } 153 | } 154 | buffer += this.createTag_('li', source); 155 | } 156 | return buffer; 157 | } 158 | 159 | [NodeType.Paragraph] (buffer: string, mnode: ParagraphNode, close?: boolean) { 160 | if (mnode.isShow) { 161 | if (close) { 162 | buffer += this.createTag_('/p'); 163 | } else { 164 | buffer += this.createTag_('p', this.getSource_(mnode)); 165 | } 166 | } 167 | return buffer; 168 | } 169 | 170 | [NodeType.Strong] (buffer: string, mnode: StrongNode, close?: boolean) { 171 | if (close) { 172 | buffer += this.createTag_('/strong'); 173 | } else { 174 | buffer += this.createTag_('strong', this.getSource_(mnode)); 175 | } 176 | return buffer; 177 | } 178 | 179 | [NodeType.Text] (buffer: string, mnode: TextNode, close?: boolean) { 180 | if (mnode.marker) { 181 | const attrs = [['class', `editor-marker hide`], ['m', mnode.marker]]; 182 | buffer += this.createTag_('span', this.getSource_(mnode, attrs)) + escapeXml(mnode.text); 183 | buffer += this.createTag_('/span'); 184 | } else { 185 | buffer += mnode.text; 186 | } 187 | return buffer 188 | // buffer += this.createTag_('span', this.getSource_(mnode)) + mnode.text + this.createTag_('/span'); 189 | // return buffer; 190 | } 191 | 192 | [NodeType.ThematicBreak] (buffer: string, mnode: ThematicBreakNode, close?: boolean) { 193 | buffer += this.createTag_('hr', this.getSource_(mnode), true); 194 | return buffer; 195 | } 196 | 197 | [NodeType.Del] (buffer: string, mnode: DelNode, close?: boolean) { 198 | if (close) { 199 | buffer += this.createTag_('/del'); 200 | } else { 201 | buffer += this.createTag_('del', this.getSource_(mnode)); 202 | } 203 | return buffer; 204 | } 205 | 206 | [NodeType.Underline] (buffer: string, mnode: UnderlineNode, close?: boolean) { 207 | // TODO 208 | return buffer; 209 | } 210 | 211 | [NodeType.Table] (buffer: string, mnode: TableNode, close?: boolean) { 212 | if (close) { 213 | buffer += this.createTag_('/table'); 214 | } else { 215 | buffer += this.createTag_('table', this.getSource_(mnode)); 216 | } 217 | return buffer; 218 | } 219 | 220 | [NodeType.TableThead] (buffer: string, mnode: TableTheadNode, close?: boolean) { 221 | if (close) { 222 | buffer += this.createTag_('/thead'); 223 | } else { 224 | buffer += this.createTag_('thead', this.getSource_(mnode)); 225 | } 226 | return buffer; 227 | } 228 | 229 | [NodeType.TableTr] (buffer: string, mnode: TableTrNode, close?: boolean) { 230 | if (close) { 231 | buffer += this.createTag_('/tr'); 232 | } else { 233 | buffer += this.createTag_('tr', this.getSource_(mnode)); 234 | } 235 | return buffer; 236 | } 237 | 238 | [NodeType.TableTh] (buffer: string, mnode: TableThNode, close?: boolean) { 239 | if (close) { 240 | buffer += this.createTag_('/th'); 241 | } else { 242 | const attrs = [['align', mnode.align]]; 243 | buffer += this.createTag_('th', this.getSource_(mnode, attrs)); 244 | } 245 | return buffer; 246 | } 247 | 248 | [NodeType.TableTbody] (buffer: string, mnode: TableTbodyNode, close?: boolean) { 249 | if (close) { 250 | buffer += this.createTag_('/tbody'); 251 | } else { 252 | buffer += this.createTag_('tbody', this.getSource_(mnode)); 253 | } 254 | return buffer; 255 | } 256 | 257 | [NodeType.TableTd] (buffer: string, mnode: TableTdNode, close?: boolean) { 258 | if (close) { 259 | buffer += this.createTag_('/td'); 260 | } else { 261 | const attrs = [['align', mnode.align]]; 262 | buffer += this.createTag_('td', this.getSource_(mnode, attrs)); 263 | } 264 | return buffer; 265 | } 266 | 267 | [NodeType.Checkbox] (buffer: string, mnode: CheckboxNode, close?: boolean) { 268 | const attrs: string[][] = [['type', 'checkbox']]; 269 | if (mnode.checked) { 270 | attrs.push(['checked', 'checked']) 271 | } 272 | buffer += this.createTag_('input', this.getSource_(mnode, attrs), true); 273 | return buffer; 274 | } 275 | 276 | [NodeType.Code] (buffer: string, mnode: CodeNode, close?: boolean) { 277 | const source = this.getSource_(mnode, [['class', 'code-block-inline']]); 278 | buffer += this.createTag_('code', source); 279 | buffer += mnode.content; 280 | buffer += this.createTag_('/code'); 281 | return buffer; 282 | } 283 | 284 | } 285 | export default HtmlGenerate; -------------------------------------------------------------------------------- /src/editor.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: 编辑器核心控制器及入口 3 | * @Author: ZengYong 4 | * @CreateDate: 2021-09-17 19:19:18 5 | */ 6 | import './assets/index.less'; 7 | import { TextModel, SelectionModel } from './model'; 8 | import { InsertTextOperation, RemoveTextOperation, SetSelectionOperation, Operation} from './operations'; 9 | import { ViewProvider, ViewMode, View } from './view' 10 | import EventHandler from './event' 11 | import blockCreaterFactory from "./markdown-parse/markdown-block-creater/factory"; 12 | import { isSpacerOrTab, getPreSpacerOrTab } from './markdown-parse/funs'; 13 | 14 | export const EditorViewMode = ViewMode; 15 | 16 | export interface EditorConfigProps { 17 | mode?: ViewMode, 18 | placeholder?: string 19 | } 20 | 21 | interface EditorConfigInnerProps { 22 | mode: ViewMode, 23 | placeholder: string 24 | } 25 | 26 | 27 | export class Editor { 28 | protected markdownStringCompleter_ = new blockCreaterFactory().build(); 29 | protected style_: HTMLStyleElement; 30 | protected elementContainer_: HTMLElement; 31 | protected element_: HTMLElement; 32 | protected config_: EditorConfigInnerProps; 33 | protected textModel_: TextModel; 34 | protected selectionModel_: SelectionModel; 35 | protected viewProvider_: ViewProvider; 36 | protected view_: View; 37 | protected eventHandler_: EventHandler; 38 | // protected operations_: Operation[] = []; 39 | 40 | constructor (elementContainer_: HTMLElement, config: EditorConfigProps = {}) { 41 | this.elementContainer_ = elementContainer_; 42 | this.config_ = { 43 | mode: ViewMode.RENDER, 44 | placeholder: '', 45 | ...config 46 | }; 47 | this.initElement_(); 48 | this.textModel_ = new TextModel(); 49 | this.selectionModel_ = new SelectionModel(this.textModel_); 50 | this.viewProvider_ = new ViewProvider(); 51 | this.initView_(); 52 | 53 | } 54 | 55 | private initElement_ () { 56 | const element = document.createElement("pre"); 57 | element.setAttribute("placeholder", this.config_.placeholder); 58 | element.setAttribute("contenteditable", "true"); 59 | element.setAttribute("spellcheck", "false"); 60 | // element.setAttribute("style", "width: 50%; height: 100%; outline: 0 none; background-color: #fff"); 61 | element.setAttribute("class", "web-editor-pre"); 62 | this.elementContainer_.appendChild(element); 63 | // element.innerHTML = '
  • 123

  • 4567890

' 64 | this.element_ = element; 65 | } 66 | 67 | private initView_ () { 68 | this.view_ = this.viewProvider_.provide(this.config_.mode, this.textModel_, this.selectionModel_, this.element_); 69 | this.eventHandler_ = new EventHandler(this, this.view_); 70 | this.eventHandler_.addListeners(); 71 | this.view_.render(); 72 | } 73 | 74 | // private initStyle_ () { 75 | // const style = document.createElement('style'); 76 | // style.innerHTML='.marker{ display: none }'; 77 | // document.getElementsByTagName('HEAD').item(0)?.appendChild(style); 78 | // this.style_ = style; 79 | // } 80 | 81 | getTextModel () { 82 | return this.textModel_; 83 | } 84 | 85 | getSelectionModel () { 86 | return this.selectionModel_; 87 | } 88 | 89 | getElement () { 90 | return this.element_; 91 | } 92 | 93 | focus () { 94 | this.element_.focus(); 95 | } 96 | 97 | blur () { 98 | this.element_.blur(); 99 | } 100 | /** 所有 operation 执行的入口函数 */ 101 | apply (operation: Operation) { 102 | operation.apply(this); 103 | this.focus(); 104 | } 105 | 106 | /** 在光标或者选区处插入字符串 */ 107 | insertTextAtCursor (text: string) { 108 | const enterComplete = this.beforeInsert(text); 109 | text = enterComplete.input; 110 | if (!text.length) { 111 | return; 112 | } 113 | const selection = this.selectionModel_.getSelection(); 114 | let startIndex = selection.anchor; 115 | if (!this.selectionModel_.isCollapsed()) { 116 | this.apply(new RemoveTextOperation(selection.anchor, selection.focus)); 117 | if (this.selectionModel_.isBackward()) { 118 | startIndex = selection.focus; 119 | } 120 | } 121 | // 子集列表回车需要走删除逻辑 122 | if (enterComplete.needDelete && enterComplete.needDelete.length) { 123 | let line = this.textModel_.getLineByIndex(startIndex); 124 | let lineStart = startIndex - line.length; 125 | let deleteStart = lineStart + enterComplete.needDelete[0]; 126 | let deleteEnd = lineStart + enterComplete.needDelete[1]; 127 | this.apply(new RemoveTextOperation(deleteStart, deleteEnd)); 128 | startIndex -= (deleteEnd - deleteStart); 129 | } else { 130 | const cursorDelta = enterComplete.cursorDelta === undefined ? text.length : enterComplete.cursorDelta; 131 | this.apply(new InsertTextOperation(text, startIndex)); 132 | startIndex += cursorDelta; 133 | } 134 | this.setSelection(startIndex); // 更新选区光标模型 135 | } 136 | 137 | /** 任意位置插入字符串,光标保持在原位置 */ 138 | insertText (index: number, text: string) { 139 | const selection = this.selectionModel_.getSelection(); 140 | this.apply(new InsertTextOperation(text, index)); 141 | if (selection.anchor <= index) { 142 | this.setSelection(selection.anchor); 143 | } else { 144 | this.setSelection(selection.anchor + text.length); 145 | } 146 | } 147 | 148 | /** 在光标处往回删除一个字符,如果有选中,则删除整个选区 */ 149 | deleteTextAtCursor () { 150 | let selection = this.selectionModel_.getSelection(); 151 | let startIndex = selection.anchor; 152 | if (!this.selectionModel_.isCollapsed()) { 153 | this.apply(new RemoveTextOperation(selection.anchor, selection.focus)); 154 | if (this.selectionModel_.isBackward()) { 155 | startIndex = selection.focus; 156 | } 157 | } else if (selection.anchor > 0) { 158 | const deleteResult = this.beforeDelete(); 159 | const deleteLen = deleteResult.deleteLen; 160 | if (deleteLen === 0) { 161 | return; 162 | } 163 | if (deleteResult.cursorDelta !== undefined && deleteResult.cursorDelta > 0) { 164 | startIndex += deleteResult.cursorDelta; 165 | this.setSelection(startIndex); 166 | } 167 | const deleteStartIndex = startIndex - deleteLen; 168 | this.apply(new RemoveTextOperation(deleteStartIndex, startIndex)); 169 | startIndex = deleteStartIndex; 170 | } 171 | 172 | this.setSelection(startIndex); // 更新选区光标模型 173 | } 174 | 175 | /** 任意位置删除字符串,光标保持原位置 */ 176 | deleteText (index: number, len: number) { 177 | const selection = this.selectionModel_.getSelection(); 178 | this.apply(new RemoveTextOperation(index, index + len)); 179 | if (selection.anchor <= index) { 180 | this.setSelection(selection.anchor); 181 | } else if (index + len < selection.anchor) { 182 | this.setSelection(selection.anchor - len); 183 | } else { 184 | this.setSelection(index); 185 | } 186 | } 187 | 188 | /** 设置选区或光标 */ 189 | setSelection (anchor: number, focus?: number) { 190 | this.apply(new SetSelectionOperation({ anchor: anchor, focus: focus || focus === 0 ? focus : anchor })); 191 | } 192 | 193 | /** 取消选区 */ 194 | removeSelection () { 195 | this.apply(new SetSelectionOperation(null)); 196 | } 197 | 198 | /** 光标回移 */ 199 | moveBackward () { 200 | const selection = this.selectionModel_.getSelection(); 201 | if (!this.selectionModel_.isCollapsed()) { 202 | // TODO 203 | } 204 | } 205 | 206 | /** 光标正移 */ 207 | moveForward () { 208 | const selection = this.selectionModel_.getSelection(); 209 | if (!this.selectionModel_.isCollapsed()) { 210 | // TODO 211 | } 212 | } 213 | 214 | /** 切换渲染模式 */ 215 | switchViewMode (mode?: ViewMode) { 216 | if (mode === this.config_.mode) { 217 | return; 218 | } 219 | let newMode = mode; 220 | if (!newMode) { 221 | if (this.config_.mode === ViewMode.RENDER) { 222 | newMode = ViewMode.SOURCE 223 | } else { 224 | newMode = ViewMode.RENDER 225 | } 226 | } 227 | this.eventHandler_.dispose(); 228 | this.view_.dispose(); 229 | this.config_.mode = newMode; 230 | this.initView_(); 231 | this.focus(); 232 | } 233 | 234 | // 回车或者tab等输入时自动补齐源码,如代码块、列表等 235 | beforeInsert (input: string) { 236 | let cursorDelta: number | undefined; 237 | let needDelete: Array = []; 238 | let originInput = input; 239 | if (input === '\n') { 240 | const inTable = this.view_.inDom('table'); 241 | if (inTable) { 242 | // TODO 表格中回车,其他的补充输入后续都考虑利用view的inDom方法来识别,而不是在creater里面再去解析一次 243 | const sourceIndex = inTable.sourceIndex; 244 | const textL = this.textModel_.getLength(); 245 | // md解析渲染时已经隐藏了表格后面的一个空白行 246 | input = sourceIndex[1] === textL ? '\n\n' : '\n'; 247 | this.insertText(sourceIndex[1], input); 248 | this.setSelection(sourceIndex[1] + 2); 249 | input = ''; 250 | } else { 251 | const selection = this.selectionModel_.getSelection(); 252 | let line = this.textModel_.getLineByIndex(selection.anchor); 253 | while(true) { 254 | let preSpacerOrTab = getPreSpacerOrTab(line); 255 | let preLen = preSpacerOrTab.length; 256 | if (preLen) { 257 | line = line.slice(preLen); 258 | // input += preSpacerOrTab; 259 | } 260 | const completeResult = this.markdownStringCompleter_.complete({ line }); 261 | if (completeResult) { 262 | // 列表回车时需要回退上一级tab,因此实际是行首的tab,如当前是一级tab,则删除列表符合 263 | if (completeResult.needDeleteTab) { 264 | needDelete = preLen ? [0, 1] : [0, line.length]; 265 | break; 266 | } else { 267 | line = completeResult.lineRest; 268 | input += preSpacerOrTab + completeResult.completeInput; 269 | if (completeResult.cursor !== undefined) { 270 | // 代码块源码模式不做自动补齐,暂时不太优雅地在此处处理 271 | if (this.config_.mode !== ViewMode.RENDER) { 272 | return { input: originInput }; 273 | } 274 | cursorDelta = completeResult.cursor + 1; 275 | } 276 | } 277 | 278 | } else { 279 | break; 280 | } 281 | } 282 | } 283 | } else if (this.config_.mode === ViewMode.RENDER && input === '\t') { 284 | const selection = this.selectionModel_.getSelection(); 285 | let line = this.textModel_.getLineByIndex(selection.anchor); 286 | const match = line.match(/^(\s*[*+-]\s+)+/) || line.match(/^(\s*(\d{1,9})([.)])\s+)+/); 287 | if (match && match[0].length === line.length) { 288 | this.insertText(selection.anchor - line.length, '\t'); 289 | input = ''; 290 | } 291 | } else if (this.config_.mode === ViewMode.SOURCE && input === '|' && this.view_.inDom('table')) { 292 | if (this.view_.inDom('table')) { 293 | input = '\\|'; 294 | } 295 | } 296 | return { input, cursorDelta, needDelete }; 297 | } 298 | 299 | 300 | // 回退删除时自动删除额外的内容,如列表的前缀符号 301 | beforeDelete () { 302 | if (this.config_.mode !== ViewMode.RENDER) { 303 | return { deleteLen: 1 }; 304 | } 305 | const selection = this.selectionModel_.getSelection(); 306 | const inDom = this.view_.inDom('td') || this.view_.inDom('th'); 307 | if (inDom) { 308 | const sourceIndex = inDom.sourceIndex; 309 | if (sourceIndex[0] === selection.anchor) { 310 | return { deleteLen: 0 }; 311 | } 312 | } 313 | let cursorDelta: number | undefined; 314 | let line = this.textModel_.getLineByIndex(selection.anchor); 315 | let deleteLen = 1; 316 | while(true) { 317 | let preSpacerOrTab = getPreSpacerOrTab(line); 318 | let preLen = preSpacerOrTab.length; 319 | if (preLen) { 320 | line = line.slice(preLen); 321 | if (line.length === 0) { 322 | deleteLen += preLen; 323 | break; 324 | } 325 | } 326 | const deleteResult = this.markdownStringCompleter_.delete({ line }); 327 | if (deleteResult) { 328 | line = deleteResult.lineRest; 329 | deleteLen = deleteResult.deleteLen; 330 | if (deleteResult.cursor !== undefined && deleteResult.cursor > 0) { 331 | cursorDelta = deleteResult.cursor; // 先移动光标 332 | } 333 | } else { 334 | break; 335 | } 336 | } 337 | return { deleteLen: line.length ? 1 : deleteLen, cursorDelta }; 338 | 339 | 340 | // let preSpacerOrTab = getPreSpacerOrTab(line); 341 | // let preLen = preSpacerOrTab.length; 342 | // let deleteLen = 1; 343 | // if (preLen) { 344 | // line = line.slice(preLen); 345 | // if (line.length === 0) { 346 | // deleteLen = preLen + 1; // + 1是把换行符也删掉 347 | // return deleteLen; 348 | // } 349 | // } 350 | // const deleteResult = this.markdownStringCompleter_.delete({ line }); 351 | // if (deleteResult) { 352 | // deleteLen = deleteResult.deleteLen; 353 | // } 354 | // return deleteLen; 355 | } 356 | 357 | getContent () { 358 | return this.textModel_.getSpacer(); 359 | } 360 | 361 | setContent (content: string) { 362 | this.textModel_.setContent(content); 363 | this.setSelection(0); 364 | } 365 | 366 | clearContent () { 367 | this.textModel_.clear(); 368 | this.setSelection(0); 369 | } 370 | 371 | /** 清理已经添加的 dom 和事件等 */ 372 | dispose () { 373 | this.eventHandler_.dispose(); 374 | this.view_.dispose(); 375 | this.element_.remove(); 376 | this.style_.remove(); 377 | } 378 | 379 | } 380 | 381 | export default Editor; -------------------------------------------------------------------------------- /src/view/base-view.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: 视图层基类 3 | * @Author: ZengYong 4 | * @CreateDate: 2021-09-18 16:39:41 5 | */ 6 | import { TextModel, SelectionModel, SelectionCustom } from "../model/"; 7 | import EventEmitter from "events"; 8 | //@ts-ignore 9 | import { Parser, HtmlRenderer } from "../markdown-parse/lib/index.js"; 10 | import markdown from "../markdown-parse" 11 | import { debounce } from "../utils/debounce"; 12 | 13 | export interface IDomPoint { 14 | domNode: HTMLElement; 15 | domOffset: number 16 | } 17 | export class BaseView extends EventEmitter { 18 | static EVENT_TYPE = { 19 | SELECTION_CHANGE: 'selection-change' 20 | } 21 | protected textModel_: TextModel; 22 | protected selectionModel_: SelectionModel; 23 | protected viewContainer_: HTMLElement; 24 | 25 | private renderBinder_: any; 26 | // private updateDomSelectionBinder_: any; 27 | private domSelectionChangeHandlerBinder_: any; 28 | 29 | constructor (textModel: TextModel, selectionModel: SelectionModel, viewContainer: HTMLElement) { 30 | super(); 31 | 32 | this.textModel_ = textModel; 33 | this.selectionModel_ = selectionModel; 34 | this.viewContainer_ = viewContainer; 35 | this.viewContainer_.setAttribute('contenteditable', 'true'); 36 | this.addListeners(); 37 | // this.render(); 38 | 39 | } 40 | 41 | /** 鼠标或键盘导致的原生 dom 选区变化,同步到选区模型 */ 42 | domSelectionChangeHandler (e: Event) { 43 | const domSelection = window.getSelection(); 44 | let selection: SelectionCustom | null = null; 45 | if (domSelection) { 46 | selection = this.domSelToCustomSel(domSelection); 47 | } 48 | this.showMarker(domSelection); 49 | this.emit(BaseView.EVENT_TYPE.SELECTION_CHANGE, selection); 50 | } 51 | 52 | /** 更新 dom 真实选区 */ 53 | updateDomSelection () { 54 | // TODO 暂时不放timer里,到时测试一下性能 55 | const domSelection = window.getSelection(); 56 | if (domSelection) { 57 | // const selectionFromDom = this.domSelToCustomSel(domSelection); 58 | const selectionFromModel = this.selectionModel_.getSelection(); 59 | // TODO:见下面注释 60 | /** 61 | * 如果即将更新到 dom 的选区和当前 dom 本身的选区一致,则不需操作dom。这里的判断逻辑是否影响光标操作体验待测试。 62 | * 两个原因:1.避免冗余的dom操作;2.如果变化原因本身就是 dom 自身触发的,若数据模型再去触发 dom 更新,陷入循环 63 | * 补充解释:注释掉,暂时不需要判断了,因为当前已经取消了对selectionModel事件的订阅,因此只有view的主动调用才会执行此函数 64 | */ 65 | // if (!SelectionModel.isEqual(selectionFromDom, selectionFromModel)) { 66 | domSelection.removeAllRanges(); 67 | const range = this.customSelToDomSel(selectionFromModel); 68 | if (range) { 69 | domSelection.addRange(range); 70 | // 立即调用showmarker,避免在marker之间输入时,marker触发延时导致闪烁的问题 71 | this.showMarker(domSelection); 72 | } 73 | // } 74 | } 75 | } 76 | 77 | addListeners () { 78 | // 模型事件 79 | this.renderBinder_ = this.render.bind(this); 80 | // 模型事件触发已经节流,此处不需节流 81 | this.textModel_.on(TextModel.EVENT_TYPE.TEXT_CHANGE, this.renderBinder_); 82 | 83 | //(暂时取消订阅,参考selectionModel中的发布事件说明) 84 | // this.updateDomSelectionBinder_ = this.updateDomSelection_.bind(this); 85 | // this.selectionModel_.on(SelectionModel.EVENT_TYPE.SELECTION_CHANGE, this.updateDomSelectionBinder_); 86 | 87 | // dom 选区事件 88 | this.domSelectionChangeHandlerBinder_ = debounce(this.domSelectionChangeHandler.bind(this), 100); 89 | window.document.addEventListener('selectionchange', this.domSelectionChangeHandlerBinder_); 90 | } 91 | 92 | 93 | /** 将选区模型转换成 Dom 的真实选区 */ 94 | customSelToDomSel (customSelection: SelectionCustom) { 95 | let range: Range | null = null; 96 | if (customSelection) { 97 | const rangeStart = this.customPointToDomPoint(customSelection.anchor); 98 | const rangeEnd = customSelection.anchor === customSelection.focus ? rangeStart : this.customPointToDomPoint(customSelection.focus); 99 | if (rangeStart && rangeEnd) { 100 | range = window.document.createRange(); 101 | range.setStart(rangeStart.domNode, rangeStart.domOffset); 102 | range.setEnd(rangeEnd.domNode, rangeEnd.domOffset); 103 | } 104 | // range.collapse(true); 105 | } 106 | return range; 107 | } 108 | 109 | /** 将 Dom 真实选区转换成选区模型 */ 110 | domSelToCustomSel (domSelection: Selection) { 111 | let selection: SelectionCustom = { anchor: 0, focus: 0 }; 112 | if (domSelection.anchorNode) { 113 | const anchorIndex = this.domPointToCustomPoint({ 114 | domNode: domSelection.anchorNode as HTMLElement, 115 | domOffset: domSelection.anchorOffset 116 | }); 117 | const focusIndex = domSelection.isCollapsed 118 | ? anchorIndex 119 | : this.domPointToCustomPoint({ 120 | domNode: domSelection.focusNode as HTMLElement, 121 | domOffset: domSelection.focusOffset 122 | }); 123 | selection = { anchor: anchorIndex, focus: focusIndex } 124 | } 125 | return selection 126 | } 127 | /** TODO 此方法应该被不同渲染模式子类重写 */ 128 | customPointToDomPoint (customPoint: number) { 129 | let container: HTMLElement = this.viewContainer_; 130 | let offset: number = 0; 131 | let eles; 132 | let domPoint: IDomPoint | null = null; 133 | while (eles = container.childNodes) { 134 | if (eles.length === 0) { 135 | break; 136 | } 137 | // 前一个节点,若没有找到显性的区间节点,则可能使用上一个节点的末尾或者下一节点的开头 138 | let preChild: HTMLElement | null = null; 139 | let preSourceIndex: number[] | null = null; 140 | let len = eles.length; 141 | for (let i = 0; i < len; i++) { 142 | const ele = eles[i] as HTMLElement; 143 | const soucrIndex = this.getNodeSource_(ele); 144 | // 注意:源码映射的子元素之间不一定是连续的soucrIndex,但是有序的,因为有些元素是消耗了隐藏的soucrIndex。 145 | if (customPoint >= soucrIndex[0] && customPoint <= soucrIndex[1]) { 146 | container = ele; 147 | offset = customPoint - soucrIndex[0]; 148 | break; 149 | } else if (customPoint < soucrIndex[0]) { // 说明目标在前面的隐性区间 150 | if (!preChild) { // 行首隐性区间 151 | container = ele; 152 | offset = 0; 153 | } else { 154 | container = preChild; 155 | offset = customPoint - (preSourceIndex as number[])[0]; 156 | } 157 | break; 158 | } else if (i === len - 1) { // 说明落在了行尾的隐性区间 159 | container = ele; 160 | offset = soucrIndex[1] - soucrIndex[0]; 161 | break; 162 | } 163 | preChild = ele; 164 | preSourceIndex = soucrIndex; 165 | } 166 | } 167 | if (!(container instanceof Text) && offset > 0) { // offset=0时就保持原节点,否则无法兼容空p标签,即换行时光标无法自动落在空行 168 | if (container.nextSibling) { 169 | container = container.nextSibling as HTMLElement; 170 | offset = 0; 171 | } else { 172 | container = container.parentElement as HTMLElement; 173 | offset = container.childNodes.length; 174 | } 175 | } 176 | domPoint = { 177 | domNode: container, 178 | domOffset: offset 179 | } 180 | return domPoint; 181 | } 182 | 183 | /** TODO 此方法应该被不同渲染模式子类重写 */ 184 | domPointToCustomPoint (domPoint: IDomPoint) { 185 | let domNode = domPoint.domNode; 186 | const sourceIndex = this.getNodeSource_(domNode); 187 | let domOffset = domPoint.domOffset; 188 | let point = sourceIndex[0] + domOffset; 189 | if (!(domNode instanceof Text) && domOffset > 0) { // domOffset>0 ,因此一定存在子元素 190 | const childNodes = domNode.childNodes; 191 | const domOffsetSourceIndex = this.getNodeSource_(childNodes[domOffset - 1] as HTMLElement); 192 | point = domOffsetSourceIndex[1]; 193 | } 194 | return point; 195 | } 196 | 197 | /** 根据dom节点获取其源码映射区间 */ 198 | getNodeSource_ (domNode: HTMLElement) { 199 | const sourceIndex = [0, 0]; 200 | if (domNode instanceof Text) { 201 | const textL = domNode.length; 202 | if (domNode.previousElementSibling) { 203 | const preIndexStr = domNode.previousElementSibling.getAttribute('i'); 204 | if (preIndexStr) { 205 | const preIndexRange = preIndexStr.split('-'); 206 | sourceIndex[0] = parseInt(preIndexRange[1]); 207 | sourceIndex[1] = sourceIndex[0] + textL; 208 | } 209 | } else if (domNode.parentElement) { 210 | const parentIndexStr = domNode.parentElement.getAttribute('i'); 211 | if (parentIndexStr) { 212 | const parentIndexRange = parentIndexStr.split('-'); 213 | sourceIndex[0] = parseInt(parentIndexRange[0]); 214 | sourceIndex[1] = sourceIndex[0] + textL; 215 | } 216 | } 217 | } else { 218 | // 有时光标落在了p标签,或者换行产生新空行时,往下找一级 219 | let indexStr = domNode.getAttribute('i'); 220 | if (!indexStr) { 221 | const children = domNode.childNodes; 222 | if (children.length) { 223 | domNode = children[0] as HTMLElement; 224 | indexStr = domNode.getAttribute('i'); 225 | } 226 | } 227 | if (indexStr) { 228 | const indexRange = indexStr.split('-'); 229 | sourceIndex[0] = parseInt(indexRange[0]); 230 | sourceIndex[1] = parseInt(indexRange[1]); 231 | } 232 | } 233 | return sourceIndex; 234 | } 235 | 236 | /** TODO:考虑增加缓存,优化渲染html时主动触发+被动触发双次触发的问题 */ 237 | showMarker (domSelection: Selection | null) { 238 | const showMarkerNodes = document.querySelectorAll('.editor-marker:not(.hide)'); 239 | for (let i = 0; i < showMarkerNodes.length; i++) { 240 | showMarkerNodes[i].setAttribute('class', 'editor-marker hide'); 241 | } 242 | if (domSelection) { 243 | let markerNode: HTMLElement | null = domSelection.anchorNode as HTMLElement; 244 | if (markerNode instanceof Text) { 245 | markerNode = markerNode.parentNode as HTMLElement; 246 | } 247 | while (markerNode) { 248 | let className = markerNode.getAttribute('class'); 249 | if (className === 'web-editor-pre') { 250 | break; 251 | } 252 | if (className === 'editor-block') { 253 | if (['H1', 'H2', 'H3', 'H4', 'H5', 'H6'].indexOf(markerNode.tagName) > -1) { 254 | const titleMarker = markerNode.querySelectorAll('.editor-marker.hide'); 255 | for (let i = 0; i < titleMarker.length; i++) { 256 | titleMarker[i].setAttribute('class', 'editor-marker'); 257 | } 258 | } 259 | break; 260 | } 261 | if (className !== 'editor-marker hide') { 262 | if (markerNode.previousSibling) { 263 | markerNode = markerNode.previousSibling as HTMLElement; 264 | } else if (markerNode.nextSibling) 265 | markerNode = markerNode.nextSibling as HTMLElement; 266 | } 267 | if (!(markerNode instanceof Text)) { 268 | className = markerNode.getAttribute('class'); 269 | if (className === 'editor-marker hide') { 270 | markerNode.setAttribute('class', 'editor-marker'); 271 | const otherMarkerNode = (markerNode.getAttribute('m') === 'before' ? markerNode.nextSibling?.nextSibling : markerNode.previousSibling?.previousSibling) as HTMLElement | null; 272 | if (otherMarkerNode) { 273 | otherMarkerNode.setAttribute('class', 'editor-marker'); 274 | } 275 | } else { 276 | break; 277 | } 278 | } 279 | markerNode = markerNode.parentNode as HTMLElement | null; 280 | } 281 | } 282 | } 283 | 284 | inDom (tagName: string) { 285 | const domSelection = window.getSelection(); 286 | if (domSelection) { 287 | let anchorNode = domSelection.anchorNode as HTMLElement | null; 288 | while (anchorNode) { 289 | if (!(anchorNode instanceof Text)) { 290 | const className = anchorNode.getAttribute('class'); 291 | if (className && className.indexOf('web-editor-pre') > -1) { 292 | break; 293 | } 294 | if (anchorNode?.tagName.toLowerCase() === tagName) { 295 | const sourceIndex = this.getNodeSource_(anchorNode); 296 | return { sourceIndex }; 297 | } 298 | } 299 | anchorNode = anchorNode.parentElement; 300 | } 301 | } 302 | return null; 303 | } 304 | 305 | // TODO 此方法应该被不同渲染模式子类重写 306 | render () { 307 | // const parsed = new Parser({smart: true}).parse(this.textModel_.getSpacer()); 308 | // console.error('spacer', this.textModel_.getSpacer()) 309 | // console.error('commonMark-Node', parsed); 310 | // console.error('commonMark-Html',new HtmlRenderer().render(parsed)); 311 | // console.error('customMark-Node', markdown.md2node(this.textModel_.getSpacer())) 312 | // console.time('customMark-time') 313 | // console.error('customMark-Html', markdown.md2html(this.textModel_.getSpacer())) 314 | // console.timeEnd('customMark-time') 315 | // this.viewContainer_.innerHTML = this.textModel_.getSpacer() 316 | this.viewContainer_.innerHTML = markdown.md2html(this.textModel_.getSpacer()) 317 | this.updateDomSelection(); 318 | // this.viewContainer_.scrollTop = this.viewContainer_.scrollHeight; 319 | } 320 | 321 | 322 | dispose () { 323 | if (this.renderBinder_) { 324 | this.textModel_.off(TextModel.EVENT_TYPE.TEXT_CHANGE, this.renderBinder_); 325 | this.renderBinder_ = null; 326 | } 327 | // if (this.updateDomSelectionBinder_) { 328 | // this.selectionModel_.off(SelectionModel.EVENT_TYPE.SELECTION_CHANGE, this.updateDomSelectionBinder_); 329 | // this.updateDomSelectionBinder_ = null; 330 | // } 331 | if (this.domSelectionChangeHandlerBinder_) { 332 | window.document.removeEventListener('selectionchange', this.domSelectionChangeHandlerBinder_); 333 | this.domSelectionChangeHandlerBinder_ = null; 334 | } 335 | this.viewContainer_.innerHTML = ''; 336 | } 337 | } 338 | export default BaseView; -------------------------------------------------------------------------------- /src/markdown-parse/markdown-parser-line/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: 进行单行内的语法解析 3 | * @Author: ZengYong 4 | * @CreateDate: 2021-10-20 15:18:48 5 | */ 6 | 7 | import { SubNode, TextNode, EmphNode, StrongNode, DelNode, ImageNode, LinkNode, HeadNode, NodeType, TableTdNode, TableThNode, TableTheadNode, TableTrNode, TableNode, CheckboxNode, ItemNode, HtmlBlockNode, CodeNode } from "../node"; 8 | import { reHtmlTag } from './common'; 9 | import DelimitersStack from "./delimiter-stack"; 10 | 11 | class MarkdownParserLine { 12 | 13 | private block_: SubNode; 14 | private line_: string; 15 | private sourceStart_: number; 16 | private offset_: number; // 当前处理的字符位置 17 | private delimiter_: DelimitersStack | null = null; 18 | private aligns_: string[] = []; // table中的对齐方向 19 | static whiteSpaceReg = /^\s/; // 空格 tab等 20 | // static textReg = /^[^\n`\[\]\\!<&*_~'"]+/m; // 普通文本 21 | static textReg = /^[^\[\]\|\\ 0 && !MarkdownParserLine.whiteSpaceReg.test(this.line_[startPos - 1])) { 129 | canClose = true; 130 | } 131 | this.stackPush_(this.line_.substr(startPos, count), char, count, canOpen, canClose); 132 | } 133 | 134 | /** 转换节点,将分隔符节点转换为对应的实际节点,根据 CM 规范找出 opener 和 closer 并依次处理。stackBottom 是为了提高性能排除不必要的访问 */ 135 | transNormal_ (stackBottom: DelimitersStack | null) { 136 | // closer 先设定为栈底,然后往上找最近的满足条件的 137 | let closer = this.delimiter_; 138 | let opener = null; 139 | let openerBottom = {}; 140 | while (closer !== null && closer.pre !== stackBottom) { 141 | closer = closer.pre; 142 | } 143 | while (closer) { 144 | const char = closer.char; 145 | if (!closer.canClose || !(char === '*' || char === '_' || char === '~')) { 146 | closer = closer.next; 147 | } else { 148 | opener = this.transNormalOpener_(closer, openerBottom[closer.char] || stackBottom); 149 | // 未匹配到opener,则closer后移。 150 | if (!opener) { 151 | const oldCloser = closer; 152 | closer = closer.next; 153 | 154 | // 用于算法优化:针对某个closer,若没有找到opener,则标识一下,下次再遇到相同的closer,就匹配到标识处位置即可,不用遍历到栈底。 155 | openerBottom[oldCloser.char] = oldCloser.pre; 156 | 157 | // 如果没有发现匹配的opener,并且当前closer也不能作为其他的opener,则从栈中移除此closer 158 | if (!oldCloser.canOpen) { 159 | if (this.delimiter_ === oldCloser) { 160 | this.delimiter_ = oldCloser.pre; 161 | } 162 | oldCloser.remove(); 163 | } 164 | } else { 165 | // 找到opener和closer后,根据分隔符创建新的节点,把opener和closer之间的原有内容,包含到新的节点中去 166 | let transNode: SubNode; 167 | let useCount: number; 168 | const openerNode = opener.mnode; 169 | const closerNode = closer.mnode; 170 | if (char === '*' || char === '_') { 171 | if (opener.currentCount >= 2 && closer.currentCount >= 2) { 172 | useCount = 2; 173 | transNode = new StrongNode(openerNode.sourceEnd); 174 | } else { 175 | useCount = 1; 176 | transNode = new EmphNode(openerNode.sourceEnd); 177 | } 178 | } else { 179 | useCount = 1; 180 | transNode = new DelNode(openerNode.sourceEnd); 181 | } 182 | transNode.finalize(closerNode.sourceStart); 183 | // 删除已经消耗掉的分隔符 184 | opener.currentCount -= useCount; 185 | openerNode.sourceEnd -= useCount; 186 | closer.currentCount -= useCount; 187 | closerNode.sourceStart += useCount; 188 | openerNode.text = openerNode.text.slice(useCount); 189 | closerNode.text = closerNode.text.slice(useCount); 190 | // 把opener和closer之间的原有内容,包含到新的节点中去 191 | let betweenNode = openerNode.next; 192 | let next; 193 | while (betweenNode && betweenNode !== closerNode) { 194 | next = betweenNode.next; 195 | // betweenNode.unlink(); 196 | transNode.appendChild(betweenNode); 197 | betweenNode = next; 198 | } 199 | // 将转换后的新节点插入语法树中 200 | openerNode.insertAfter(transNode); 201 | let marker = char.repeat(useCount); 202 | this.addMarkerNode_(transNode, marker, marker); 203 | 204 | // opener和closer之间的分隔符将作为普通文本显示,没有机会去匹配转换了,因此从链表中移除 205 | DelimitersStack.removeBetween(opener, closer); 206 | 207 | // 分隔符被消耗后,剩余分隔符数量为0时,语法树中的节点和分隔符栈中节点都没必要存在了 208 | if (opener.currentCount === 0) { 209 | opener.remove(); 210 | openerNode.unlink(); 211 | } 212 | if (closer.currentCount === 0) { 213 | // closer 需要后移,用于下次循环 214 | const nextCloser = closer.next; 215 | if (this.delimiter_ === closer) { 216 | this.delimiter_ = closer.pre; 217 | } 218 | closer.remove(); 219 | closer = nextCloser; 220 | closerNode.unlink(); 221 | } 222 | } 223 | } 224 | } 225 | } 226 | 227 | transNormalOpener_ (closer: DelimitersStack, stackBottom: DelimitersStack) { 228 | let opener = closer.pre; 229 | while (opener && opener !== stackBottom) { 230 | if (opener.canOpen && opener.char === closer.char) { 231 | return opener; 232 | } else { 233 | opener = opener.pre; 234 | } 235 | } 236 | return null; 237 | } 238 | 239 | parseOpenBracket_ () { 240 | let char = '['; 241 | this.offset_++; 242 | this.stackPush_(char, char, 1, true, false); 243 | } 244 | 245 | parseCloseBracket_ () { 246 | let opener = this.delimiter_; 247 | let isImage = false; 248 | // 优化点:这里如果新起一个栈表示括号符号,而不是复用普通分隔符栈,则可以省掉此循环查找 249 | while (opener) { 250 | if (opener.char === '[') { 251 | break; 252 | } 253 | if (opener.char === '![') { 254 | isImage = true; 255 | break; 256 | } 257 | opener = opener.pre; 258 | } 259 | // 未找到对应opener,直接插入普通文本节点。若中括号之间没有内容,则也不匹配(CM规范没有这一项) 260 | //if (!opener || this.line_[this.offset_ - 1] === '[') { 261 | 262 | // 未找到对应opener,直接插入普通文本节点。 263 | if (!opener) { 264 | this.block_.appendChild(new TextNode(this.sourceStart_ + this.offset_, ']')); 265 | return; 266 | } 267 | 268 | this.offset_++; 269 | 270 | let matched = false; // 是否能匹配成功 271 | let startPos = this.offset_; // 万一匹配失败,需要将坐标恢复 272 | let dest = ''; 273 | if (this.line_[this.offset_] === '(') { 274 | this.offset_++; 275 | let char; 276 | // 清除前面的空格(先注释,除非将数据模型中的对应位置也清除掉,否则这里清除后会导致 marker 的 source 计算不正确,Typora 也没有清除) 277 | // this.match_(MarkdownParserLine.spnlReg); 278 | while (char = this.line_[this.offset_]) { 279 | this.offset_++; 280 | // if (char === ' ') { // 连接中不能有空格(CM 规范有,但 Typora 没有遵循),这里注释,因为我们也没有清除前面的空格 281 | // // break; 282 | // } else if (char !== ')') { 283 | // dest += char; 284 | // } else if (char === ')') { 285 | // matched = true; 286 | // break; 287 | // } 288 | if (char !== ')') { 289 | dest += char; 290 | } else { 291 | matched = true; 292 | break; 293 | } 294 | } 295 | } 296 | if (matched) { 297 | const mnnode = isImage ? new ImageNode(opener.mnode.sourceEnd, dest) : new LinkNode(opener.mnode.sourceEnd, dest); 298 | let betweenNode = opener.mnode.next; 299 | let next; 300 | while (betweenNode) { 301 | next = betweenNode.next; 302 | betweenNode.unlink(); 303 | mnnode.appendChild(betweenNode); 304 | betweenNode = next; 305 | } 306 | this.block_.appendChild(mnnode); 307 | // 对 [] 之间的内容进行普通分隔符解析,此时的解析位置是在 ],所以以 [ 之前的节点为栈底即可 308 | this.transNormal_(opener.pre); 309 | mnnode.finalize(this.sourceStart_ + startPos - 1); 310 | this.addMarkerNode_(mnnode, opener.char, '](' + dest + ')'); 311 | if (this.delimiter_ === opener) { 312 | this.delimiter_ = opener.pre; 313 | } 314 | opener.remove(); 315 | opener.mnode.unlink(); 316 | 317 | // 防止链接套链接,将openr以前的括号都从栈中移除 318 | if (!isImage) { 319 | opener = this.delimiter_; 320 | while (opener) { 321 | const pre = opener.pre; 322 | if (opener.char === '[') { 323 | opener.remove(); 324 | } 325 | opener = pre; 326 | } 327 | } 328 | } else { 329 | if (this.delimiter_ === opener) { 330 | this.delimiter_ = opener.pre; 331 | } 332 | opener.remove(); 333 | this.offset_ = startPos; 334 | this.block_.appendChild(new TextNode(this.sourceStart_ + this.offset_ - 1, ']')); 335 | } 336 | } 337 | 338 | parseExclamatory_ () { 339 | this.offset_++; 340 | if (this.line_[this.offset_] === '[') { 341 | const char = '!['; 342 | this.offset_++; 343 | this.stackPush_(char, char, 2, true, false); 344 | } else { 345 | this.block_.appendChild(new TextNode(this.sourceStart_ + this.offset_ - 1, '!')); 346 | } 347 | } 348 | 349 | parseVertical_ () { 350 | if (this.block_.type !== NodeType.TableTr) { 351 | if (this.line_[this.offset_]) { 352 | this.block_.appendChild(new TextNode(this.sourceStart_ + this.offset_, '|')); 353 | this.offset_++; 354 | } 355 | return; 356 | } 357 | if (this.line_[this.offset_]) { 358 | this.offset_++; 359 | } 360 | let opener = this.delimiter_; 361 | while (opener) { 362 | if (opener.char === '|') { 363 | break; 364 | } 365 | opener = opener.pre; 366 | } 367 | // 表格th、td针对缺省最后一个竖线时的兼容处理 368 | // if (checkLastTd && this.line_[this.offset_ - 1] === '|') { 369 | // if (opener) { 370 | // if (this.delimiter_ === opener) { 371 | // this.delimiter_ = opener.pre; 372 | // } 373 | // opener.remove(); 374 | // opener.mnode.unlink(); 375 | // } 376 | // return; 377 | // } 378 | let sourceStart; 379 | let betweenNode; 380 | let matched = false; 381 | if (opener) { 382 | sourceStart = opener.mnode.sourceEnd; 383 | betweenNode = opener.mnode.next; 384 | matched = true; 385 | } else if (this.block_.lastChild) { 386 | sourceStart = (this.block_.firstChild as SubNode).sourceStart; 387 | betweenNode = this.block_.firstChild; 388 | matched = true; 389 | } 390 | if (matched) { 391 | const mnnode = (this.block_ as TableTrNode).isheader ? new TableThNode(sourceStart as number, this.aligns_.shift() || 'left') : new TableTdNode(sourceStart as number, this.aligns_.shift() || 'left'); 392 | let next; 393 | while (betweenNode) { 394 | next = betweenNode.next; 395 | betweenNode.unlink(); 396 | mnnode.appendChild(betweenNode); 397 | betweenNode = next; 398 | } 399 | this.block_.appendChild(mnnode); 400 | // 对 td 的内容进行普通分隔符解析 401 | this.transNormal_(opener); 402 | mnnode.finalize(this.sourceStart_ + this.offset_ - 1); 403 | if (opener) { 404 | // TODO 此判断可封装,待优化 405 | if (this.delimiter_ === opener) { 406 | this.delimiter_ = opener.pre; 407 | } 408 | opener.remove(); 409 | opener.mnode.unlink(); 410 | } 411 | } 412 | if (this.aligns_.length) { 413 | const char = '|'; 414 | this.stackPush_(char, char, 1, true, true); 415 | } else { 416 | this.offset_ = this.line_.length; 417 | } 418 | } 419 | 420 | parseBackslash_ () { 421 | this.offset_++; 422 | const nextChar = this.line_[this.offset_]; 423 | const reg = new RegExp("^[!\"#$%&'()*+,./:;<=>?@[\\\\\\]^_`{|}~-]"); 424 | if (reg.test(nextChar)) { 425 | const textNode = new TextNode(this.sourceStart_ + this.offset_, nextChar); 426 | this.block_.appendChild(textNode); 427 | this.addMarkerNode_(textNode, '\\'); 428 | this.offset_++; 429 | } else { 430 | this.block_.appendChild(new TextNode(this.sourceStart_ + this.offset_ - 1, '\\')); 431 | } 432 | } 433 | 434 | parseHtmlTag_ () { 435 | const match = this.line_.slice(this.offset_).match(reHtmlTag); 436 | if (match) { 437 | const isCloseTag = match[0].indexOf('