├── .gitignore ├── src ├── modules │ ├── indent │ │ └── index.js │ ├── outdent │ │ └── index.js │ ├── align │ │ ├── tab.html │ │ ├── index.js │ │ └── tab.js │ ├── ol │ │ └── index.js │ ├── ul │ │ └── index.js │ ├── image │ │ ├── tab.html │ │ ├── index.js │ │ └── tab.js │ ├── italic │ │ └── index.js │ ├── bold │ │ └── index.js │ ├── underline │ │ └── index.js │ ├── font │ │ ├── index.js │ │ ├── tab.html │ │ ├── config.js │ │ ├── style.styl │ │ └── tab.js │ ├── full-screen │ │ └── index.js │ ├── linethrough │ │ └── index.js │ ├── fore-color │ │ ├── tab.html │ │ ├── index.js │ │ ├── tab.js │ │ └── style.styl │ ├── quote │ │ └── index.js │ ├── todo │ │ └── index.js │ ├── icourt-todo │ │ └── index.js │ └── index.js ├── constant-config.js ├── range-handler │ ├── index.js │ ├── README.md │ ├── instance.js │ ├── assist-methods.js │ └── handle-methods.js ├── commands │ ├── keydown.js │ ├── enter.js │ ├── justifyLeft.js │ ├── justifyCenter.js │ ├── justifyRight.js │ ├── fontSize.js │ ├── paste.js │ ├── bold.js │ ├── italic.js │ ├── underline.js │ ├── strikeThrough.js │ ├── delete.js │ ├── insertImage.js │ ├── todo.js │ ├── quote.js │ └── index.js ├── shortcut │ └── index.js ├── util │ ├── mixin.js │ ├── index.js │ └── polyfill-ie.js ├── module-inspect │ ├── load-module-inspect-exclude-rules.js │ └── index.js ├── i18n │ ├── zh-cn.js │ └── en-us.js ├── editor │ ├── editor.html │ ├── drag-pic.js │ ├── style │ │ ├── reset.styl │ │ └── main.styl │ └── editor.js └── index.js ├── LICENSE ├── package.json ├── webpack.config.js ├── example ├── base │ └── index.html └── editModule │ └── index.html ├── dist └── index.html ├── README_CN.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | # idea config 4 | .idea 5 | *.iml -------------------------------------------------------------------------------- /src/modules/indent/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'indent', 3 | icon: 'fa fa-indent', 4 | type: 'fn', 5 | handler: function (rh) { 6 | rh.editor.execCommand('indent') 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/modules/outdent/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'indent', 3 | icon: 'fa fa-outdent', 4 | type: 'fn', 5 | handler: function (rh) { 6 | rh.editor.execCommand('outdent') 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/modules/align/tab.html: -------------------------------------------------------------------------------- 1 |
  • 2 | 3 |
  • 4 | -------------------------------------------------------------------------------- /src/constant-config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | MAX_INDENT_LEVEL: 10, 3 | INDENT_WIDTH_PERCENT: 8, 4 | INDENT_STYLE_NAME: 'marginLeft', 5 | OUTDENT_STYLE_NAME: 'marginRight', 6 | ROW_TAG: 'p', 7 | ROW_TAG_UPPERCASE: 'P' 8 | } 9 | -------------------------------------------------------------------------------- /src/range-handler/index.js: -------------------------------------------------------------------------------- 1 | import Instance from './instance' 2 | import hMethods from './handle-methods' 3 | import aMethods from './assist-methods' 4 | Object.assign(Instance.prototype, hMethods, aMethods) 5 | export default Instance 6 | -------------------------------------------------------------------------------- /src/modules/ol/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'ol', 3 | icon: 'fa fa-list-ol', 4 | handler: function (rh) { 5 | rh.editor.execCommand('insertOrderedList') 6 | }, 7 | inspect (add) { 8 | add('tag', 'OL') 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/modules/ul/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'ul', 3 | icon: 'fa fa-list-ul', 4 | handler: function (rh) { 5 | rh.editor.execCommand('insertUnorderedList') 6 | }, 7 | inspect (add) { 8 | add('tag', 'UL') 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/modules/image/tab.html: -------------------------------------------------------------------------------- 1 |
  • 2 | 3 | 4 |
  • 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/modules/align/index.js: -------------------------------------------------------------------------------- 1 | import tab from './tab' 2 | 3 | export default { 4 | name: 'align', 5 | icon: 'iui-icon iui-icon-align', 6 | tab, 7 | inspect (add) { 8 | add('style', { 9 | 'textAlign': ['center', 'right'] 10 | }) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/modules/italic/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'italic', 3 | icon: 'fa fa-italic', 4 | handler (rh) { 5 | rh.editor.execCommand('italic') 6 | }, 7 | inspect (add) { 8 | add('tag', 'I').add('style', {'fontStyle': 'italic'}) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/modules/bold/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'bold', 3 | icon: 'fa fa-bold', 4 | handler: function (rh) { 5 | rh.editor.execCommand('bold') 6 | }, 7 | inspect (add) { 8 | add('tag', 'STRONG').add('tag', 'B').add('style',{'font-weight': 'bold'}) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/modules/underline/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'underline', 3 | icon: 'fa fa-underline', 4 | handler (rh) { 5 | rh.editor.execCommand('underline') 6 | }, 7 | inspect (add) { 8 | add('tag', 'U').add('style', {'text-decoration-line': 'underline'}) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/modules/font/index.js: -------------------------------------------------------------------------------- 1 | import tab from './tab' 2 | import config from './config' 3 | 4 | export default { 5 | name: 'font', 6 | tab, 7 | config, 8 | inspect (add) { 9 | add('style', { 10 | fontSize: ['xx-large', 'x-large', 'large', 'medium', 'small'] 11 | }) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/modules/full-screen/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * toggle full screen mode 3 | * Created by peak on 16/8/18. 4 | */ 5 | export default { 6 | name: 'full-screen', 7 | icon: 'fa fa-arrows-alt', 8 | i18n: 'full screen', 9 | handler(rh) { 10 | rh.editor.toggleFullScreen() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/modules/linethrough/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'linethrough', 3 | icon: 'fa fa-strikethrough', 4 | handler: function (rh) { 5 | rh.editor.execCommand('strikeThrough') 6 | }, 7 | inspect (add) { 8 | add('tag', 'STRIKE').add('style', {'text-decoration-line': 'line-through'}) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/modules/font/tab.html: -------------------------------------------------------------------------------- 1 |
  • 2 | {{choosed.name}} 3 | 4 | 7 |
  • 8 | -------------------------------------------------------------------------------- /src/modules/font/config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | // through font tag 3 | 'xx-large': { 4 | fontSize: 6, 5 | name: '标题' 6 | }, 7 | 'x-large': { 8 | fontSize: 5, 9 | name: '副标题' 10 | }, 11 | 'large': { 12 | fontSize: 4, 13 | name: '小标题' 14 | }, 15 | 'medium': { 16 | fontSize: 3, 17 | name: '正文' 18 | }, 19 | default: 'medium' 20 | } 21 | 22 | -------------------------------------------------------------------------------- /src/modules/fore-color/tab.html: -------------------------------------------------------------------------------- 1 |
  • 2 | 3 |
    4 | 7 |
  • 8 | -------------------------------------------------------------------------------- /src/commands/keydown.js: -------------------------------------------------------------------------------- 1 | import commands from './index' 2 | import constant from '../constant-config' 3 | 4 | export default function (rh, e) { 5 | let node = rh.range.commonAncestorContainer 6 | if (node.nodeType === Node.TEXT_NODE) { 7 | 8 | // to keep text wrap by a row 9 | if (node.parentNode === rh.editZone()) { 10 | commands.formatBlock(rh, constant.ROW_TAG) 11 | return 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/modules/image/index.js: -------------------------------------------------------------------------------- 1 | import tab from './tab' 2 | 3 | export default { 4 | name: 'image', 5 | i18n: 'image', 6 | canUploadSameImage: true, 7 | imgOccupyNewRow: false, 8 | maxSize: 5120 * 1024, 9 | compress: { 10 | // max width 11 | width: 1600, 12 | // max height 13 | height: 1600, 14 | // cpmpress quality 0 - 1 15 | quality: 0.8 16 | }, 17 | tab, 18 | inspect (add) { 19 | add('tag', 'img') 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/modules/quote/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'quote', 3 | icon: 'fa fa-quote-right', 4 | show: true, 5 | mounted (editor) { 6 | editor.execCommand('initQuote') 7 | }, 8 | handler: function (rh, module) { 9 | let isInQuote = rh.editor.activeModules.includes(module.name) 10 | rh.editor.execCommand('quote', isInQuote) 11 | }, 12 | inspect (add) { 13 | add('attribute', { 14 | 'data-editor-quote': '' 15 | }) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/todo/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'todo', 3 | icon: 'fa fa-check-square', 4 | exclude: 'ALL_BUT_MYSELF', 5 | mounted (editor) { 6 | editor.execCommand('initTodo') 7 | }, 8 | handler (rh) { 9 | rh.editor.execCommand('todo', { 10 | insertAfter: rh.range.commonAncestorContainer, 11 | placeholder: '待办事项' 12 | }) 13 | }, 14 | inspect (add) { 15 | add('attribute', { 16 | 'data-editor-todo': '' 17 | }) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/modules/icourt-todo/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'itodo', 3 | icon: 'iui-icon iui-icon-checked-line', 4 | exclude: 'ALL_BUT_MYSELF', 5 | mounted (editor) { 6 | editor.execCommand('initiTodo') 7 | }, 8 | handler (rh) { 9 | rh.editor.execCommand('itodo', { 10 | insertAfter: rh.range.commonAncestorContainer, 11 | placeholder: '待办事项' 12 | }) 13 | }, 14 | inspect (add) { 15 | add('attribute', { 16 | 'data-editor-todo': '' 17 | }) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/shortcut/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | outdent: { 3 | keyCode: 9, 4 | shiftKey: true, 5 | handler (editor, e) { 6 | e.preventDefault() 7 | editor.execCommand('outdent') 8 | } 9 | }, 10 | indent: { 11 | keyCode: 9, 12 | handler (editor, e) { 13 | e.preventDefault() 14 | editor.execCommand('indent') 15 | } 16 | }, 17 | delete: { 18 | keyCode: 8, 19 | handler (editor, e) { 20 | editor.execCommand('delete', e, true) 21 | } 22 | }, 23 | enter: { 24 | keyCode: 13, 25 | handler (editor, e) { 26 | editor.execCommand('enter', e, true) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/modules/image/tab.js: -------------------------------------------------------------------------------- 1 | import lrz from 'lrz' 2 | import template from './tab.html' 3 | 4 | export default { 5 | template, 6 | data() { 7 | return { 8 | name: 'tab-image', 9 | curModule: null 10 | } 11 | }, 12 | methods: { 13 | pick() { 14 | if (this.forbidden) return 15 | this.$refs.file.click() 16 | }, 17 | process(e) { 18 | const file = this.$refs.file.files[0] 19 | this.$parent.execCommand('insertImage', file) 20 | if (this.curModule.canUploadSameImage) { 21 | e.target.value = '' 22 | } 23 | } 24 | }, 25 | mounted () { 26 | this.curModule = this.$parent.modulesMap['image'] 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/modules/fore-color/index.js: -------------------------------------------------------------------------------- 1 | import tab from './tab' 2 | 3 | const foreColorConfig = { 4 | name: 'foreColor', 5 | // color bust be lowercase 6 | colors: ['#000000', '#000033', '#000066', '#000099', '#003300', '#003333', '#003366', 7 | '#003399', '#006600', '#006633', '#009900', '#330000', '#330033', '#330066', 8 | '#333300', '#333366', '#660000', '#660033', '#663300', '#666600', '#666633', 9 | '#666666', '#666699', '#990000', '#990033', '#9900cc', '#996600', '#ffcc00', 10 | '#ffcccc', '#ffcc99', '#ffff00', '#fa8919', '#ed6c00', '#ccffff', '#ccff99', '#ffffff'], 11 | default: '#000000', 12 | tab, 13 | inspect (add) { 14 | add('attribute', { 15 | color: foreColorConfig.colors 16 | }) 17 | } 18 | } 19 | 20 | export default foreColorConfig -------------------------------------------------------------------------------- /src/util/mixin.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by peak on 2017/2/24. 3 | */ 4 | /** 5 | * shadow clone 6 | * 7 | * @param source source object 8 | * @param ext extended object 9 | */ 10 | export default function mixin(source = {}, ext = {}) { 11 | Object.keys(ext).forEach((k) => { 12 | // for data function 13 | if (k === 'data') { 14 | const dataSrc = source[k] 15 | const dataDesc = ext[k] 16 | if (typeof dataDesc === 'function') { 17 | if (typeof dataSrc !== 'function') { 18 | source[k] = dataDesc 19 | } else { 20 | source[k] = () => mixin(dataSrc(), dataDesc()) 21 | } 22 | } 23 | } else { 24 | source[k] = ext[k] 25 | } 26 | }) 27 | return source 28 | } -------------------------------------------------------------------------------- /src/modules/index.js: -------------------------------------------------------------------------------- 1 | import align from './align/index' 2 | import font from './font/index' 3 | import fullScreen from './full-screen/index' 4 | import image from './image/index' 5 | import bold from './bold' 6 | import italic from './italic' 7 | import underline from './underline' 8 | import todo from './todo' 9 | import quote from './quote' 10 | import indent from './indent' 11 | import outdent from './outdent' 12 | import ul from './ul' 13 | import ol from './ol' 14 | import linethrough from './linethrough' 15 | import itodo from './icourt-todo' 16 | import foreColor from './fore-color' 17 | 18 | 19 | /** 20 | * build-in moduls 21 | */ 22 | export default [ 23 | font, 24 | align, 25 | image, 26 | fullScreen, 27 | bold, 28 | italic, 29 | underline, 30 | todo, 31 | quote, 32 | indent, 33 | outdent, 34 | ul, 35 | ol, 36 | linethrough, 37 | itodo, 38 | foreColor 39 | ] 40 | -------------------------------------------------------------------------------- /src/util/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * shadow clone 3 | * @param source source object 4 | * @param ext extended object 5 | */ 6 | export const mixin = (source = {}, ext = {}) => { 7 | Object.keys(ext).forEach((k) => { 8 | // for data function 9 | if (k === 'data') { 10 | const dataSrc = source[k] 11 | const dataDesc = ext[k] 12 | if (typeof dataDesc === 'function') { 13 | if (typeof dataSrc !== 'function') { 14 | source[k] = dataDesc 15 | } else { 16 | source[k] = () => mixin(dataSrc(), dataDesc()) 17 | } 18 | } 19 | } else { 20 | source[k] = ext[k] 21 | } 22 | }) 23 | return source 24 | } 25 | 26 | export const isObj = data => { 27 | return Object.prototype.toString.call(data).slice(-7, -1) === 'Object' 28 | } 29 | -------------------------------------------------------------------------------- /src/module-inspect/load-module-inspect-exclude-rules.js: -------------------------------------------------------------------------------- 1 | /* 2 | * load rule keywords of style inspect 3 | **/ 4 | 5 | export default function (curModule, modules) { 6 | let result = [] 7 | let curExclude = curModule.exclude 8 | if (Array.isArray(curExclude)) return curExclude 9 | if (typeof curExclude === 'string') { 10 | let moduleNameList = [] 11 | modules.forEach(m => { 12 | if (m.name) { 13 | moduleNameList.push(m.name) 14 | } 15 | }) 16 | moduleNameList = Array.from(new Set(moduleNameList)) 17 | switch (curExclude) { 18 | // exclude all modules 19 | case 'ALL': 20 | result = moduleNameList 21 | break 22 | // exclude all modules but current module 23 | case 'ALL_BUT_MYSELF': 24 | result = moduleNameList 25 | result.splice(result.indexOf(curModule.name), 1) 26 | break 27 | } 28 | } 29 | return result 30 | } 31 | -------------------------------------------------------------------------------- /src/modules/fore-color/tab.js: -------------------------------------------------------------------------------- 1 | import template from './tab.html' 2 | import './style.styl' 3 | 4 | export default { 5 | template, 6 | watch: { 7 | activeItem (n) { 8 | n = n || this.curModule.default 9 | this.choosed = n 10 | } 11 | }, 12 | data () { 13 | return { 14 | colors: [], 15 | curModule: null, 16 | choosed: {}, 17 | showList: false 18 | } 19 | }, 20 | methods: { 21 | showListFn () { 22 | if (this.$refs.tab.classList.contains('forbidden')) return 23 | this.showList = true 24 | }, 25 | changeColor (color) { 26 | this.choosed = color 27 | this.$parent.execCommand('foreColor', color) 28 | this.showList = false 29 | } 30 | }, 31 | mounted () { 32 | this.curModule = this.$parent.modulesMap['foreColor'] 33 | this.colors = this.curModule.colors 34 | this.choosed = this.curModule.default 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/modules/fore-color/style.styl: -------------------------------------------------------------------------------- 1 | .fore-color-tab 2 | position: relative 3 | font-size: 10px 4 | height: 18px 5 | line-height: 18px 6 | width: 14px 7 | text-align: center 8 | padding: 4px 4px 4px 6px 9 | text-align: left 10 | &:hover 11 | background: #fff 12 | padding: 3px 3px 3px 5px 13 | border: 1px #bcbcbc solid 14 | &.forbidden 15 | cursor: not-allowed 16 | opacity: .3 17 | .icon 18 | position: relative 19 | top: -3px 20 | .choosed 21 | position: relative 22 | left: -.6px 23 | bottom: 3px 24 | width: 12px 25 | height: 4px 26 | .dropdown 27 | padding: 5px 28 | width: 180px 29 | z-index: 1 30 | position: absolute 31 | background: #fff 32 | left: -1px 33 | top: 25px 34 | border: 1px #bcbcbc solid 35 | overflow: hidden 36 | li 37 | margin: 2px 38 | float: left 39 | width: 15px 40 | height: 15px 41 | cursor: pointer 42 | -------------------------------------------------------------------------------- /src/commands/enter.js: -------------------------------------------------------------------------------- 1 | import commands from './index' 2 | 3 | export default function (rh, e) { 4 | let node = rh.range.commonAncestorContainer 5 | if (rh.range.collapsed) { 6 | 7 | // rewrite li enter logic 8 | if (rh.findSpecialAncestor(node, 'li') && rh.isEmptyNode(node)) { 9 | e.preventDefault() 10 | let ulOrOl = rh.findSpecialAncestor(node, 'ul') || rh.findSpecialAncestor(node, 'ol') 11 | if (ulOrOl.nodeName === 'UL') { 12 | commands['insertUnorderedList'](rh, e) 13 | } 14 | if (ulOrOl.nodeName === 'OL') { 15 | commands['insertOrderedList'](rh, e) 16 | } 17 | } 18 | } 19 | afterEnter(rh, e) 20 | } 21 | 22 | function afterEnter(rh, e) { 23 | setTimeout(function () { 24 | let node = rh.getSelection().baseNode 25 | let row = rh.getRow(node) 26 | // clear new row's indent 27 | if (row) { 28 | row.style.marginLeft = '' 29 | row.style.marginRight = '' 30 | } 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /src/modules/font/style.styl: -------------------------------------------------------------------------------- 1 | .font-tab 2 | position: relative 3 | font-size: 10px 4 | height: 18px 5 | line-height: 18px 6 | width: 52px 7 | text-align: center 8 | padding: 4px 4px 4px 6px 9 | text-align: left 10 | &:hover 11 | background: #fff 12 | padding: 3px 3px 3px 5px 13 | border: 1px #bcbcbc solid 14 | &.forbidden 15 | cursor: not-allowed 16 | opacity: .3 17 | .icon 18 | float: right 19 | padding: 3px 20 | .dropdown 21 | z-index: 1 22 | position: absolute 23 | background: #fff 24 | left: -1px 25 | top: 25px 26 | border: 1px #bcbcbc solid 27 | border-top: none 28 | overflow: hidden 29 | li 30 | box-sizing: border-box 31 | padding-left: 4px 32 | font-size: 12px 33 | width: 60px 34 | height: 30px 35 | line-height: 30px 36 | background: #fff 37 | text-align: left 38 | cursor: pointer 39 | &:hover 40 | background: #f0f0f0 41 | -------------------------------------------------------------------------------- /src/commands/justifyLeft.js: -------------------------------------------------------------------------------- 1 | import commands from './index' 2 | 3 | export default function (rh, arg) { 4 | let texts = rh.getAllTextNodesInRange() 5 | if (!texts.length) { 6 | let s = rh.getSelection() 7 | if (s.baseNode && s.baseNode.nodeType === Node.TEXT_NODE) { 8 | texts.push(s.baseNode) 9 | } else { 10 | document.execCommand('insertHTML', false, '​') 11 | if (s.baseNode && s.baseNode.nodeType === Node.TEXT_NODE) { 12 | texts.push(s.baseNode) 13 | } 14 | } 15 | } 16 | texts.forEach(text => { 17 | let curRow = rh.getRow(text) 18 | if (!curRow) { 19 | let newRow = rh.newRow() 20 | newRow.innerText = text.nodeValue 21 | let nextSibling = text.nextSibling 22 | text.parentNode.replaceChild(newRow, text) 23 | if (nextSibling && nextSibling.nodeName === 'BR') { 24 | nextSibling.parentNode.removeChild(nextSibling) 25 | } 26 | } 27 | document.execCommand('justifyLeft', false) 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /src/commands/justifyCenter.js: -------------------------------------------------------------------------------- 1 | import commands from './index' 2 | 3 | export default function (rh, arg) { 4 | let texts = rh.getAllTextNodesInRange() 5 | if (!texts.length) { 6 | let s = rh.getSelection() 7 | if (s.baseNode && s.baseNode.nodeType === Node.TEXT_NODE) { 8 | texts.push(s.baseNode) 9 | } else { 10 | document.execCommand('insertHTML', false, '​') 11 | if (s.baseNode && s.baseNode.nodeType === Node.TEXT_NODE) { 12 | texts.push(s.baseNode) 13 | } 14 | } 15 | } 16 | texts.forEach(text => { 17 | let curRow = rh.getRow(text) 18 | if (!curRow) { 19 | let newRow = rh.newRow() 20 | newRow.innerText = text.nodeValue 21 | let nextSibling = text.nextSibling 22 | text.parentNode.replaceChild(newRow, text) 23 | if (nextSibling && nextSibling.nodeName === 'BR') { 24 | nextSibling.parentNode.removeChild(nextSibling) 25 | } 26 | } 27 | document.execCommand('justifyCenter', false) 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /src/commands/justifyRight.js: -------------------------------------------------------------------------------- 1 | import commands from './index' 2 | 3 | export default function (rh, arg) { 4 | let texts = rh.getAllTextNodesInRange() 5 | if (!texts.length) { 6 | let s = rh.getSelection() 7 | if (s.baseNode && s.baseNode.nodeType === Node.TEXT_NODE) { 8 | texts.push(s.baseNode) 9 | } else { 10 | document.execCommand('insertHTML', false, '​') 11 | if (s.baseNode && s.baseNode.nodeType === Node.TEXT_NODE) { 12 | texts.push(s.baseNode) 13 | } 14 | } 15 | } 16 | texts.forEach(text => { 17 | let curRow = rh.getRow(text) 18 | if (!curRow) { 19 | let newRow = rh.newRow() 20 | newRow.innerText = text.nodeValue 21 | let nextSibling = text.nextSibling 22 | text.parentNode.replaceChild(newRow, text) 23 | if (nextSibling && nextSibling.nodeName === 'BR') { 24 | nextSibling.parentNode.removeChild(nextSibling) 25 | } 26 | } 27 | document.execCommand('justifyRight', false) 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /src/range-handler/README.md: -------------------------------------------------------------------------------- 1 | ### document.execCommand()指令测试结果 2 | 3 | command | IE11 | chrome | firefox 4 | ----: | :---: | :---: | :----- 5 | justifyLeft | Y | Y | Y 6 | justifyCenter | Y | Y | Y 7 | justifyRight | Y | Y | Y 8 | foreColor | Y | Y | Y 9 | backColor | Y | Y | Y 10 | removeFormat | Y | Y | Y 11 | fontName | Y | Y | Y 12 | fontSize | Y | Y | Y 13 | formatBlock | Y | N | N 14 | insertHorizontalRule | Y | Y | Y 15 | insertImage | Y | Y | Y 16 | createLink | Y | Y | Y 17 | insertOrderedList | Y | Y | Y 18 | insertUnorderedList | Y | Y | Y 19 | insertHTML | N | Y | Y 20 | bold | Y | Y | Y 21 | italic | Y | Y | Y 22 | underline | Y | Y | Y 23 | strikeThrough | Y | Y | Y 24 | subscript | Y | Y | Y 25 | superscript | Y | Y | Y 26 | undo | Y | Y | Y 27 | unlink | Y | Y | Y 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/commands/fontSize.js: -------------------------------------------------------------------------------- 1 | import commands from './index' 2 | 3 | export default function (rh, arg) { 4 | if (rh.range.collapsed) { 5 | let s =rh.getSelection() 6 | let node = s.baseNode 7 | let row = rh.getRow(node) 8 | if (row) { 9 | // delete current span element to keep line-height run correct 10 | if (rh.isEmptyNode(node) && node.parentNode.nodeName === 'SPAN') { 11 | document.execCommand('delete', false) 12 | } 13 | commands.insertHTML(rh, '​') 14 | const range = document.createRange() 15 | range.setStart(s.focusNode, s.anchorOffset - 1) 16 | range.setEnd(s.focusNode, s.focusOffset) 17 | s.removeAllRanges() 18 | s.addRange(range) 19 | document.execCommand('styleWithCSS', false, true) 20 | document.execCommand('fontSize', false, arg) 21 | s.collapse(s.focusNode, 1) 22 | return 23 | } 24 | } else { 25 | document.execCommand('styleWithCSS', false, true) 26 | document.execCommand('fontSize', false, arg) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 beta_su 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/commands/paste.js: -------------------------------------------------------------------------------- 1 | export default function (rh, e) { 2 | e.preventDefault() 3 | let text = null 4 | 5 | if(window.clipboardData && clipboardData.setData) { 6 | // IE 7 | text = window.clipboardData.getData('text') 8 | } else { 9 | text = (e.originalEvent || e).clipboardData.getData('text/plain') 10 | } 11 | if (document.body.createTextRange) { 12 | if (document.selection) { 13 | textRange = document.selection.createRange() 14 | } else if (window.getSelection) { 15 | sel = window.getSelection() 16 | var range = sel.getRangeAt(0) 17 | 18 | // 创建临时元素,使得TextRange可以移动到正确的位置 19 | var tempEl = document.createElement("span") 20 | tempEl.innerHTML = "&#FEFF;" 21 | range.deleteContents() 22 | range.insertNode(tempEl) 23 | textRange = document.body.createTextRange() 24 | textRange.moveToElementText(tempEl) 25 | tempEl.parentNode.removeChild(tempEl) 26 | } 27 | textRange.text = text 28 | textRange.collapse(false) 29 | textRange.select() 30 | } else { 31 | // Chrome之类浏览器 32 | document.execCommand("insertText", false, text) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/i18n/zh-cn.js: -------------------------------------------------------------------------------- 1 | export default { 2 | align: '对齐方式', 3 | image: '图片', 4 | list: '列表', 5 | link: '链接', 6 | unlink: '去除链接', 7 | table: '表格', 8 | font: '文字', 9 | 'full screen': '全屏', 10 | text: '排版', 11 | eraser: '格式清除', 12 | info: '关于', 13 | color: '颜色', 14 | 'please enter a url': '请输入地址', 15 | 'create link': '创建链接', 16 | bold: '加粗', 17 | italic: '倾斜', 18 | underline: '下划线', 19 | 'strike through': '删除线', 20 | subscript: '上标', 21 | superscript: '下标', 22 | heading: '标题', 23 | 'font name': '字体', 24 | 'font size': '文字大小', 25 | 'left justify': '左对齐', 26 | 'center justify': '居中', 27 | 'right justify': '右对齐', 28 | 'ordered list': '有序列表', 29 | 'unordered list': '无序列表', 30 | 'fore color': '前景色', 31 | 'background color': '背景色', 32 | 'row count': '行数', 33 | 'column count': '列数', 34 | save: '确定', 35 | upload: '上传', 36 | progress: '进度', 37 | unknown: '未知', 38 | 'please wait': '请稍等', 39 | error: '错误', 40 | abort: '中断', 41 | reset: '重置', 42 | hr: '分隔线', 43 | undo: '撤消', 44 | 'line height': '行高', 45 | 'exceed size limit': '超出大小限制' 46 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-vue-editor", 3 | "version": "1.0.11", 4 | "description": "a rich text editor for VUE 2.x", 5 | "main": "dist/my-vue-editor.js", 6 | "scripts": { 7 | "build": "webpack --config webpack.config.js", 8 | "start": "webpack-dev-server --open" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/BetaSu/my-vue-editor.git" 13 | }, 14 | "keywords": [ 15 | "vue", 16 | "editor" 17 | ], 18 | "author": "betaSu", 19 | "license": "ISC", 20 | "bugs": { 21 | "url": "https://github.com/BetaSu/my-vue-editor/issues" 22 | }, 23 | "homepage": "https://github.com/BetaSu/my-vue-editor#readme", 24 | "devDependencies": { 25 | "babel-core": "^6.26.0", 26 | "babel-loader": "^7.1.2", 27 | "babel-plugin-transform-runtime": "^6.23.0", 28 | "babel-preset-es2015": "^6.24.1", 29 | "clean-webpack-plugin": "^0.1.16", 30 | "css-loader": "^0.28.7", 31 | "html-loader": "^0.5.1", 32 | "html-webpack-plugin": "^2.30.1", 33 | "lrz": "^4.9.40", 34 | "style-loader": "^0.19.0", 35 | "stylus": "^0.54.5", 36 | "stylus-loader": "^3.0.1", 37 | "webpack": "^3.9.1", 38 | "webpack-dev-server": "^2.7.1" 39 | }, 40 | "dependencies": { 41 | "font-awesome": "^4.7.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/editor/editor.html: -------------------------------------------------------------------------------- 1 |
    2 | 18 | 19 |

    21 |

    22 |
    23 | -------------------------------------------------------------------------------- /src/modules/font/tab.js: -------------------------------------------------------------------------------- 1 | import template from './tab.html' 2 | import './style.styl' 3 | 4 | export default { 5 | template, 6 | watch: { 7 | activeItem (n) { 8 | let val = this.fontAttrMap[n] 9 | if (val) { 10 | this.choosed = val 11 | } else { 12 | this.choosed = this.fontAttrMap[this.fontAttrMap['default']] 13 | } 14 | } 15 | }, 16 | data () { 17 | return { 18 | fontAttrMap: {}, 19 | curModule: null, 20 | choosed: {}, 21 | showList: false 22 | } 23 | }, 24 | methods: { 25 | showListFn () { 26 | if (this.$refs.tab.classList.contains('forbidden')) return 27 | this.showList = true 28 | }, 29 | changeAttr (val) { 30 | this.choosed = val 31 | // this.$parent.execCommand('lineHeight', val.lineHeight) 32 | this.$parent.execCommand('fontSize', val.fontSize) 33 | this.showList = false 34 | }, 35 | setFontName (name) { 36 | this.$parent.execCommand('fontName', name) 37 | }, 38 | setHeading (heading) { 39 | this.$parent.execCommand('formatBlock', heading) 40 | } 41 | }, 42 | mounted () { 43 | this.curModule = this.$parent.modulesMap['font'] 44 | this.fontAttrMap = this.curModule.config 45 | this.choosed = this.fontAttrMap[this.fontAttrMap['default']] 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path =require('path') 2 | const HtmlWebpackPlugin =require('html-webpack-plugin') 3 | const CleanWebpackPlugin = require('clean-webpack-plugin'); 4 | const webpack = require('webpack') 5 | 6 | module.exports = { 7 | entry: './src/index.js', 8 | output: { 9 | path: path.resolve(__dirname, 'dist'), 10 | filename: 'my-vue-editor.js', 11 | libraryTarget: 'umd', 12 | library: 'myVueEditor', 13 | umdNamedDefine: true 14 | }, 15 | devtool: 'inline-source-map', 16 | devServer: { 17 | contentBase: './dist', 18 | hot: true 19 | }, 20 | plugins: [ 21 | new CleanWebpackPlugin(['dist']), 22 | new HtmlWebpackPlugin({ 23 | title: 'dev', 24 | filename: 'index.html', 25 | template: './example/base/index.html', 26 | inject: 'head' 27 | }), 28 | new webpack.HotModuleReplacementPlugin() 29 | ], 30 | module: { 31 | loaders: [ 32 | { 33 | test: /\.js$/, 34 | loader: 'babel-loader?presets=es2015', 35 | exclude: /node_modules/ 36 | }, 37 | { 38 | test: /\.html$/, 39 | loader: 'html-loader?exportAsEs6Default', 40 | exclude: /node_modules/ 41 | }, 42 | { 43 | test: /\.(styl|css)$/, 44 | use:[ 'style-loader','css-loader','stylus-loader'], 45 | exclude: /node_modules/ 46 | }, 47 | ] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/i18n/en-us.js: -------------------------------------------------------------------------------- 1 | export default { 2 | align: 'align', 3 | image: 'image', 4 | list: 'list', 5 | link: 'link', 6 | unlink: 'unlink', 7 | table: 'table', 8 | font: 'font', 9 | 'full screen': 'full screen', 10 | text: 'text', 11 | eraser: 'remove format', 12 | info: 'info', 13 | color: 'color', 14 | 'please enter a url': 'please enter a url', 15 | 'create link': 'create link', 16 | bold: 'bold', 17 | italic: 'italic', 18 | underline: 'underline', 19 | 'strike through': 'strike through', 20 | subscript: 'subscript', 21 | superscript: 'superscript', 22 | heading: 'heading', 23 | 'font name': 'font name', 24 | 'font size': 'font size', 25 | 'left justify': 'left justify', 26 | 'center justify': 'center justify', 27 | 'right justify': 'right justify', 28 | 'ordered list': 'ordered list', 29 | 'unordered list': 'unordered list', 30 | 'fore color': 'fore color', 31 | 'background color': 'background color', 32 | 'row count': 'row count', 33 | 'column count': 'column count', 34 | save: 'save', 35 | upload: 'upload', 36 | progress: 'progress', 37 | unknown: 'unknown', 38 | 'please wait': 'please wait', 39 | error: 'error', 40 | abort: 'abort', 41 | reset: 'reset', 42 | hr: 'horizontal rule', 43 | undo: 'undo', 44 | 'line height': 'line height', 45 | 'exceed size limit': 'exceed size limit' 46 | } -------------------------------------------------------------------------------- /src/modules/align/tab.js: -------------------------------------------------------------------------------- 1 | import template from './tab.html' 2 | export default { 3 | template, 4 | watch: { 5 | activeItem (n) { 6 | n = n || 'left' 7 | let map = { 8 | 'left': 2, 9 | 'center': 0, 10 | 'right': 1 11 | } 12 | let index = map[n] 13 | let options = Object.keys(this.alignMap) 14 | let key = options[index] 15 | this.choosed = { 16 | icon: 'align-' + n, 17 | index, 18 | key, 19 | type: this.alignMap[key] 20 | } 21 | } 22 | }, 23 | data () { 24 | return { 25 | alignMap: { 26 | '居中': 'justifyCenter', 27 | '居右': 'justifyRight', 28 | '居左': 'justifyLeft' 29 | }, 30 | choosed: {} 31 | } 32 | }, 33 | methods: { 34 | setAlign (index) { 35 | let options = Object.keys(this.alignMap) 36 | let key = options[index] 37 | this.$parent.execCommand(this.alignMap[key]) 38 | this.$parent.saveCurrentRange() 39 | this.$parent.moduleInspect() 40 | }, 41 | changeAlign () { 42 | if (this.forbidden) return 43 | let pre_index = !isNaN(this.choosed.index) ? this.choosed.index : -1 44 | let len = Object.keys(this.alignMap).length 45 | let target_index 46 | if (pre_index + 1 === len) { 47 | target_index = 0 48 | } else { 49 | target_index = ++pre_index 50 | } 51 | this.setAlign(target_index) 52 | } 53 | } 54 | } 55 | 56 | 57 | -------------------------------------------------------------------------------- /example/base/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Title 6 | 7 | 8 | 9 | 10 | 21 | 22 | 23 |
    24 | 25 |
    26 | 58 | 59 | -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Title 6 | 7 | 8 | 9 | 10 | 21 | 22 | 23 |
    24 | 25 |
    26 | 58 | 59 | -------------------------------------------------------------------------------- /example/editModule/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Title 6 | 7 | 8 | 9 | 20 | 21 | 22 |
    23 | 24 |
    25 | 61 | 62 | -------------------------------------------------------------------------------- /src/commands/bold.js: -------------------------------------------------------------------------------- 1 | import commands from './index' 2 | 3 | export default function (rh, arg) { 4 | let s = rh.getSelection() 5 | if (!s.isCollapsed) { 6 | document.execCommand('styleWithCSS', false, false) 7 | document.execCommand('bold', false, arg) 8 | return 9 | } else { 10 | let node = s.focusNode 11 | let row = rh.getRow(node) 12 | 13 | // the outermost bold tag 14 | let bold = rh.findSpecialAncestor(node, 'strong') || rh.findSpecialAncestor(node, 'b') || rh.findSpecialAncestorByStyle(node, { 15 | 'fontWeight': 'bold' 16 | }) 17 | let existStyle = rh.findExistTagTillBorder(node, ['STRIKE', 'I', 'U'], row) 18 | let fontSize = rh.findSpecialAncestorStyle(node, 'fontSize', true, row) 19 | if (!bold) { 20 | existStyle.push('B') 21 | } 22 | if (existStyle.length) { 23 | let newDOM = rh.createNestDOMThroughList(existStyle) 24 | let v = rh.newRow() 25 | if (fontSize) { 26 | let span = document.createElement('span') 27 | span.style.fontSize = fontSize 28 | v.appendChild(span) 29 | span.appendChild(newDOM.dom) 30 | } else { 31 | v.appendChild(newDOM.dom) 32 | } 33 | commands.insertHTML(rh, v.innerHTML) 34 | let deepestNode = document.getElementById(newDOM.deepestId) 35 | s.collapse(deepestNode, 1) 36 | deepestNode.removeAttribute('id') 37 | } else { 38 | let newText = document.createElement('span') 39 | newText.style.fontSize = fontSize 40 | newText.innerHTML = '​' 41 | rh.insertAfter(newText, bold) 42 | s.collapse(newText, 1) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/commands/italic.js: -------------------------------------------------------------------------------- 1 | import commands from './index' 2 | 3 | export default function (rh, arg) { 4 | let s = rh.getSelection() 5 | if (!s.isCollapsed) { 6 | document.execCommand('styleWithCSS', false, false) 7 | document.execCommand('italic', false, arg) 8 | return 9 | } else { 10 | let node = s.focusNode 11 | let row = rh.getRow(node) 12 | 13 | // the outermost italic tag 14 | let italic = rh.findSpecialAncestor(node, 'i', false, row) || rh.findSpecialAncestorByStyle(node, { 15 | 'fontStyle': 'italic' 16 | }, false, row) 17 | let existStyle = rh.findExistTagTillBorder(node, ['STRIKE', 'U', 'B', 'STRONG'], row) 18 | let fontSize = rh.findSpecialAncestorStyle(node, 'fontSize', true, row) 19 | // is in a italic 20 | if (!italic) { 21 | existStyle.push('I') 22 | } 23 | if (existStyle.length) { 24 | let newDOM = rh.createNestDOMThroughList(existStyle) 25 | let v = rh.newRow() 26 | if (fontSize) { 27 | let span = document.createElement('span') 28 | span.style.fontSize = fontSize 29 | v.appendChild(span) 30 | span.appendChild(newDOM.dom) 31 | } else { 32 | v.appendChild(newDOM.dom) 33 | } 34 | commands.insertHTML(rh, v.innerHTML) 35 | let deepestNode = document.getElementById(newDOM.deepestId) 36 | s.collapse(deepestNode, 1) 37 | deepestNode.removeAttribute('id') 38 | } else { 39 | let newText = document.createElement('span') 40 | newText.style.fontSize = fontSize 41 | newText.innerHTML = '​' 42 | rh.insertAfter(newText, italic) 43 | s.collapse(newText, 1) 44 | } 45 | } 46 | } 47 | 48 | -------------------------------------------------------------------------------- /src/commands/underline.js: -------------------------------------------------------------------------------- 1 | import commands from './index' 2 | 3 | export default function (rh, arg) { 4 | let s = rh.getSelection() 5 | if (!s.isCollapsed) { 6 | document.execCommand('styleWithCSS', false, false) 7 | document.execCommand('underline', false, arg) 8 | return 9 | } else { 10 | let node = s.focusNode 11 | let row = rh.getRow(node) 12 | let nodeCtn = node.innerText || node.nodeValue 13 | 14 | // the outermost underline tag 15 | let underline = rh.findSpecialAncestor(node, 'u', false, row) || rh.findSpecialAncestorByStyle(node, { 16 | 'textDecorationLine': 'underline' 17 | }, false, row) 18 | let existStyle = rh.findExistTagTillBorder(node, ['STRIKE', 'I', 'B', 'STRONG'], row) 19 | let fontSize = rh.findSpecialAncestorStyle(node, 'fontSize', true, row) 20 | if (!underline) { 21 | existStyle.push('U') 22 | } 23 | if (existStyle.length) { 24 | let newDOM = rh.createNestDOMThroughList(existStyle) 25 | let v = rh.newRow() 26 | if (fontSize) { 27 | let span = document.createElement('span') 28 | span.style.fontSize = fontSize 29 | v.appendChild(span) 30 | span.appendChild(newDOM.dom) 31 | } else { 32 | v.appendChild(newDOM.dom) 33 | } 34 | commands.insertHTML(rh, v.innerHTML) 35 | let deepestNode = document.getElementById(newDOM.deepestId) 36 | s.collapse(deepestNode, 1) 37 | deepestNode.removeAttribute('id') 38 | } else { 39 | let newText = document.createElement('span') 40 | newText.style.fontSize = fontSize 41 | newText.innerHTML = '​' 42 | rh.insertAfter(newText, underline) 43 | s.collapse(newText, 1) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/commands/strikeThrough.js: -------------------------------------------------------------------------------- 1 | import commands from './index' 2 | 3 | export default function (rh, arg) { 4 | let s = rh.getSelection() 5 | if (!s.isCollapsed) { 6 | document.execCommand('styleWithCSS', false, false) 7 | document.execCommand('strikeThrough', false, arg) 8 | return 9 | } else { 10 | let node = s.focusNode 11 | let row = rh.getRow(node) 12 | let nodeCtn = node.innerText || node.nodeValue 13 | 14 | // the outermost strikeThrough tag 15 | let strikeThrough = rh.findSpecialAncestor(node, 'STRIKE', false, row) || rh.findSpecialAncestorByStyle(node, { 16 | 'textDecorationLine': 'line-through' 17 | }, false, row) 18 | let existStyle = rh.findExistTagTillBorder(node, ['U', 'I', 'B', 'STRONG'], row) 19 | let fontSize = rh.findSpecialAncestorStyle(node, 'fontSize', true, row) 20 | if (!strikeThrough) { 21 | existStyle.push('STRIKE') 22 | } 23 | if (existStyle.length) { 24 | let newDOM = rh.createNestDOMThroughList(existStyle) 25 | let v = rh.newRow() 26 | if (fontSize) { 27 | let span = document.createElement('span') 28 | span.style.fontSize = fontSize 29 | v.appendChild(span) 30 | span.appendChild(newDOM.dom) 31 | } else { 32 | v.appendChild(newDOM.dom) 33 | } 34 | commands.insertHTML(rh, v.innerHTML) 35 | let deepestNode = document.getElementById(newDOM.deepestId) 36 | s.collapse(deepestNode, 1) 37 | deepestNode.removeAttribute('id') 38 | } else { 39 | let newText = document.createElement('span') 40 | newText.style.fontSize = fontSize 41 | newText.innerHTML = '​' 42 | rh.insertAfter(newText, strikeThrough) 43 | s.collapse(newText, 1) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/editor/drag-pic.js: -------------------------------------------------------------------------------- 1 | /* 2 | * directive drag and drop pic 3 | **/ 4 | 5 | export default { 6 | bind (el, binding, vnode) { 7 | let editor = vnode.context 8 | let onDragOver = e => { 9 | e.preventDefault() 10 | const selection = window.getSelection ? window.getSelection() : document.getSelection() 11 | try { 12 | selection.collapse(e.target, 1) 13 | } catch (e) { 14 | selection.collapse(e.target, 0) 15 | } 16 | editor.saveCurrentRange() 17 | editor.moduleInspect() 18 | } 19 | let onDragLeave = e => { 20 | e.preventDefault() 21 | } 22 | 23 | let onDrop = e => { 24 | const selection = window.getSelection ? window.getSelection() : document.getSelection() 25 | if (e.dataTransfer && e.dataTransfer.files) { 26 | e.preventDefault() 27 | console.log(e.target) 28 | let files = e.dataTransfer.files 29 | for (let i = 0; i< files.length; i ++) { 30 | let curFile = files[i] 31 | if (curFile.size && curFile.type.includes('image')) { 32 | binding.value(curFile) 33 | } 34 | } 35 | } 36 | } 37 | 38 | // el.addEventListener('dragenter', onDragEnter, false) 39 | el.addEventListener('dragover', onDragOver, false) 40 | el.addEventListener('dragleave', onDragLeave, false) 41 | el.addEventListener('drop', onDrop, false) 42 | 43 | el.__dragOverHandler = onDragOver 44 | el.__dragLeaveHandler = onDragLeave 45 | el.__dropHandler = onDrop 46 | }, 47 | unbind (el, binding) { 48 | el.removeEventListener('dragover', el.__dragOverHandler) 49 | el.removeEventListener('dragleave', el.__dragLeaveHandler) 50 | el.removeEventListener('drop', el.__dropHandler) 51 | delete el.__dragOverHandler 52 | delete el.__dragLeaveHandler 53 | delete el.__dropHandler 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/range-handler/instance.js: -------------------------------------------------------------------------------- 1 | import commands from '../commands' 2 | 3 | // for IE 11 4 | if (!Text.prototype.contains) { 5 | Text.prototype.contains = function contains(otherNode) { 6 | return this === otherNode 7 | } 8 | } 9 | 10 | export default class RangeHandler { 11 | /** 12 | * build range handler 13 | * @param {Range} range 14 | * @param {editor} current editor 15 | */ 16 | constructor(range, editor) { 17 | if (!range || !(range instanceof Range)) { 18 | throw new TypeError('cant\'t resolve range') 19 | } 20 | this.range = range 21 | this.editor = editor 22 | } 23 | 24 | /** 25 | * execute edit command 26 | * @param {String} command 27 | * @param arg 28 | */ 29 | execCommand(command, arg) { 30 | RangeHandler.beforeList.forEach(fn => { 31 | fn.call(this, command, this, arg) 32 | }) 33 | const existCommand = commands[command] 34 | const customCommand = this.editor.commands ? this.editor.commands[command] : null 35 | if (existCommand) { 36 | existCommand(this, arg) 37 | } 38 | else if (customCommand) { 39 | customCommand(this, arg) 40 | } else { 41 | document.execCommand(command, false, arg) 42 | } 43 | RangeHandler.afterList.forEach(fn => { 44 | fn.call(this, command, this, arg) 45 | }) 46 | } 47 | 48 | /* 49 | * run fn before exec command 50 | **/ 51 | before (fn) { 52 | if (typeof fn === 'function') { 53 | RangeHandler.beforeList.push(fn) 54 | } 55 | } 56 | 57 | clearBeforeList () { 58 | RangeHandler.beforeList = [] 59 | } 60 | 61 | /* 62 | * run fn after exec command 63 | **/ 64 | after (fn) { 65 | if (typeof fn === 'function') { 66 | RangeHandler.afterList.push(fn) 67 | } 68 | } 69 | 70 | clearAfterList () { 71 | RangeHandler.afterList = [] 72 | } 73 | } 74 | 75 | RangeHandler.beforeList = [] 76 | RangeHandler.afterList = [] 77 | -------------------------------------------------------------------------------- /src/editor/style/reset.styl: -------------------------------------------------------------------------------- 1 | /* 2 | html5doctor.com Reset Stylesheet 3 | v1.4.1 4 | 2010-03-01 5 | Author: Richard Clark - http://richclarkdesign.com 6 | */ 7 | 8 | html, body, div, span, object, iframe, 9 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 10 | abbr, address, cite, code, 11 | del, dfn, em, img, ins, kbd, q, samp, 12 | small, strong, sub, sup, var, 13 | b, i, 14 | dl, dt, dd, ol, ul, li, 15 | fieldset, form, label, legend, 16 | table, caption, tbody, tfoot, thead, tr, th, td, 17 | article, aside, canvas, details, figcaption, figure, 18 | footer, header, hgroup, menu, nav, section, summary, 19 | time, mark, audio, video { 20 | margin:0; 21 | padding:0; 22 | border:0; 23 | outline:0; 24 | font-size:100%; 25 | vertical-align:baseline; 26 | background:transparent; 27 | } 28 | 29 | body { 30 | line-height:1; 31 | } 32 | 33 | :focus { 34 | outline: 1; 35 | } 36 | 37 | article,aside,canvas,details,figcaption,figure, 38 | footer,header,hgroup,menu,nav,section,summary { 39 | display:block; 40 | } 41 | 42 | nav ul { 43 | list-style:none; 44 | } 45 | 46 | blockquote, q { 47 | quotes:none; 48 | } 49 | 50 | blockquote:before, blockquote:after, 51 | q:before, q:after { 52 | content:''; 53 | content:none; 54 | } 55 | 56 | a { 57 | margin:0; 58 | padding:0; 59 | border:0; 60 | font-size:100%; 61 | vertical-align:baseline; 62 | background:transparent; 63 | } 64 | 65 | ins { 66 | background-color:#ff9; 67 | color:#000; 68 | text-decoration:none; 69 | } 70 | 71 | mark { 72 | background-color:#ff9; 73 | color:#000; 74 | font-style:italic; 75 | font-weight:bold; 76 | } 77 | 78 | del { 79 | text-decoration: line-through; 80 | } 81 | 82 | abbr[title], dfn[title] { 83 | border-bottom:1px dotted #000; 84 | cursor:help; 85 | } 86 | 87 | table { 88 | border-collapse:collapse; 89 | border-spacing:0; 90 | } 91 | 92 | hr { 93 | display:block; 94 | height:1px; 95 | border:0; 96 | border-top:1px solid #cccccc; 97 | margin:1em 0; 98 | padding:0; 99 | } 100 | 101 | input, select { 102 | vertical-align:middle; 103 | } -------------------------------------------------------------------------------- /src/util/polyfill-ie.js: -------------------------------------------------------------------------------- 1 | export default () => { 2 | // https://tc39.github.io/ecma262/#sec-array.prototype.includes 3 | if (!Array.prototype.includes) { 4 | Object.defineProperty(Array.prototype, 'includes', { 5 | value(searchElement, fromIndex) { 6 | // 1. Let O be ? ToObject(this value). 7 | if (this == null) { 8 | throw new TypeError('"this" is null or not defined') 9 | } 10 | 11 | const o = Object(this) 12 | 13 | // 2. Let len be ? ToLength(? Get(O, "length")). 14 | const len = o.length >>> 0 15 | 16 | // 3. If len is 0, return false. 17 | if (len === 0) { 18 | return false 19 | } 20 | 21 | // 4. Let n be ? ToInteger(fromIndex). 22 | // (If fromIndex is undefined, this step produces the value 0.) 23 | const n = fromIndex | 0 24 | 25 | // 5. If n ≥ 0, then 26 | // a. Let k be n. 27 | // 6. Else n < 0, 28 | // a. Let k be len + n. 29 | // b. If k < 0, let k be 0. 30 | let k = Math.max(n >= 0 ? n : len - Math.abs(n), 0) 31 | 32 | // 7. Repeat, while k < len 33 | while (k < len) { 34 | // a. Let elementK be the result of ? Get(O, ! ToString(k)). 35 | // b. If SameValueZero(searchElement, elementK) is true, return true. 36 | // c. Increase k by 1. 37 | // NOTE: === provides the correct "SameValueZero" comparison needed here. 38 | if (o[k] === searchElement) { 39 | return true 40 | } 41 | k++ 42 | } 43 | 44 | // 8. Return false 45 | return false 46 | } 47 | }) 48 | } 49 | // text.contains() 50 | if (!Text.prototype.contains) { 51 | Text.prototype.contains = function contains(node) { 52 | return this === node 53 | } 54 | } 55 | } 56 | 57 | -------------------------------------------------------------------------------- /src/range-handler/assist-methods.js: -------------------------------------------------------------------------------- 1 | import constant from '../constant-config' 2 | const methods = { 3 | /* 4 | * func create a new row element 5 | * @param {obj} data 6 | * tag {str} 7 | * contenteditable {boolean} default: true 8 | * id {boolean} whether need a id default: false 9 | * br {boolean} whether need a br element after current row element default: false 10 | * @return {node} 11 | **/ 12 | newRow (data = {}) { 13 | const row = document.createElement(data.tag || constant.ROW_TAG) 14 | if (data.id) { 15 | row.dataset.editorRow = methods.createRandomId('row') 16 | } 17 | if (data.br) { 18 | const br = document.createElement('br') 19 | row.appendChild(br) 20 | } 21 | row.setAttribute('contenteditable', data.contenteditable !== false) 22 | return row 23 | }, 24 | // get selection 25 | getSelection () { 26 | return window.getSelection ? window.getSelection() : document.getSelection() 27 | }, 28 | /* 29 | * func insert a element after target element 30 | * @param newElement {node} 31 | * @param targetElement {node} 32 | **/ 33 | insertAfter (newElement, targetElement) { 34 | var parent = targetElement.parentNode 35 | if (parent.lastChild === targetElement) { 36 | parent.appendChild(newElement) 37 | } else { 38 | parent.insertBefore(newElement, targetElement.nextSibling) 39 | } 40 | }, 41 | /* 42 | * func create a random id 43 | * @param preffix {str} id's preffix 44 | * @return id 45 | **/ 46 | createRandomId (preffix) { 47 | return `${preffix || 'id'}-${Math.random() * 1000 + new Date().getTime()}` 48 | }, 49 | /* 50 | * return edit zone element 51 | **/ 52 | editZone () { 53 | if (methods.editZone_cache) { 54 | return methods.editZone_cache 55 | } 56 | methods.editZone_cache = document.querySelector('[data-editor="content"]') 57 | return methods.editZone_cache 58 | }, 59 | /* 60 | * set range at target node 61 | **/ 62 | setRangeAt (node, checkAll) { 63 | const range = document.createRange() 64 | if (checkAll) { 65 | range.setStart(node, 0) 66 | let end = node.childNodes.length ? node.childNodes.length : (node.length ? node.length : 0) 67 | range.setEnd(node, end) 68 | } else { 69 | range.setStart(node, 0) 70 | range.setEnd(node, 0) 71 | } 72 | let selection = methods.getSelection() 73 | selection.removeAllRanges() 74 | selection.addRange(range) 75 | }, 76 | getRange () { 77 | let s = methods.getSelection() 78 | if (s.rangeCount) { 79 | return s.getRangeAt(0) 80 | } 81 | return 82 | } 83 | } 84 | 85 | export default methods 86 | -------------------------------------------------------------------------------- /src/editor/style/main.styl: -------------------------------------------------------------------------------- 1 | @import "reset.styl" 2 | 3 | .my-vue-editor 4 | display: block 5 | border: 1px solid #d1d1d1 6 | padding: 0 7 | img 8 | max-width: 98% 9 | .toolbar 10 | height: auto 11 | border-bottom: 1px solid #d1d1d1 12 | background: #f8f8f8 13 | padding: 6px 8px 14 | white-space: normal 15 | .tabs 16 | &:after 17 | content: '' 18 | display: block 19 | clear: both 20 | .tab 21 | margin: 0 2px 22 | float: left 23 | .icon 24 | vertical-align: sub 25 | .btn 26 | display: inline-block 27 | list-style: none 28 | height: 18px 29 | line-height: 18px 30 | padding: 4px 6px 31 | border: 0 32 | position: relative 33 | font: normal normal normal 12px Arial,Helvetica,Tahoma,Verdana,Sans-Serif 34 | color: #000 35 | text-align: center 36 | white-space: nowrap 37 | text-decoration: none 38 | &:hover 39 | padding: 3px 5px 40 | background: #f0f0f0 41 | border: 1px #bcbcbc solid 42 | &.highLight 43 | padding: 3px 5px 44 | background: #e5e5e5 45 | border: 1px #bcbcbc solid 46 | &.forbidden 47 | cursor: not-allowed 48 | opacity: .3 49 | .content 50 | box-sizing: border-box 51 | width: 100% 52 | clear both 53 | outline: none 54 | padding: 20px 55 | font-size: 16px 56 | line-height: 24px 57 | word-wrap: break-word 58 | blockquote 59 | min-width: 38px 60 | [data-editor-quote] 61 | div:first-child 62 | margin-top: -21px 63 | div 64 | margin-left: 2px 65 | outline: none 66 | color: rgb(115, 115, 115) 67 | font-size: 14px 68 | margin: 14px 0 14px 35px 69 | &::before 70 | position: relative 71 | top: 2px 72 | left: 0 73 | margin: 0px 4px 0 -24px 74 | color: #b7b7b7 75 | font-size: 20px 76 | content: "\f10e" 77 | display: inline-block 78 | font: normal normal normal 14px/1 FontAwesome 79 | text-rendering: auto 80 | -webkit-font-smoothing: antialiased 81 | transform: rotateY(180deg) 82 | ul 83 | ul 84 | margin-left: 10% 85 | li 86 | margin-left: 5.5% 87 | [data-editor-todo] 88 | display: inline-block 89 | width: 90% 90 | label 91 | cursor: pointer 92 | position: relative 93 | top: 3.5px 94 | left: 2.5px 95 | font-size: 16px 96 | color: #cdc9c5 97 | input[type=text] 98 | border: none 99 | outline: none 100 | width: 90% 101 | font-size: 14px -------------------------------------------------------------------------------- /src/commands/delete.js: -------------------------------------------------------------------------------- 1 | import commands from './index' 2 | import constant from '../constant-config' 3 | 4 | export default function (rh, e) { 5 | // restore first row 6 | let s = rh.getSelection() 7 | let node = s.baseNode 8 | let value = node.nodeValue || node.innerText 9 | // console.log('delete', node, e) 10 | let curRange = rh.getRange() || rh.range 11 | 12 | // cancel list when li is empty 13 | if ((rh.findSpecialAncestor(node, 'li')) && rh.range.startOffset === 0) { 14 | e.preventDefault() 15 | let ulOrOl = rh.findSpecialAncestor(node, 'ul') || rh.findSpecialAncestor(node, 'ol') 16 | if (ulOrOl.nodeName === 'UL') { 17 | commands['insertUnorderedList'](rh, e) 18 | } 19 | if (ulOrOl.nodeName === 'OL') { 20 | commands['insertOrderedList'](rh, e) 21 | } 22 | return 23 | } 24 | let row = rh.getRow(node) 25 | 26 | // node is edit zone 27 | if (!row) { 28 | e.preventDefault() 29 | return afterDelete(rh) 30 | } 31 | 32 | // empty row 33 | if (rh.range.collapsed && ((node === row && rh.range.startOffset === 0) || (row.innerHTML.replace(/
    /g, '') === '' && rh.range.startOffset === 1))) { 34 | let firstRow = rh.editZone().firstElementChild 35 | 36 | // first row cancel outdent 37 | if (firstRow === row) { 38 | commands.outdent(rh, null) 39 | e.preventDefault() 40 | return 41 | } 42 | } 43 | 44 | // row has content, cursor is at at start of the node, do outdent 45 | if (rh.range.collapsed && value && rh.range.startOffset === 0 && (node === row.fistElementChild || node === row.firstChild)) { 46 | let outdentResult = commands.outdent(rh, null) 47 | if (outdentResult === 'NO_NEED_OUTDENT') { 48 | return 49 | } 50 | e.preventDefault() 51 | return 52 | } 53 | 54 | // empty row 55 | if (row.innerHTML.replace(/
    /g, '') === '') { 56 | // get previous row with content 57 | let preRow = rh.getPreviousRow(row) 58 | 59 | // cursor focus on previous row's input if previous row is todo 60 | if (preRow && preRow.dataset && preRow.dataset.editorTodo) { 61 | row.parentNode.removeChild(row) 62 | let input = preRow.querySelector('input[type="text"]') 63 | if (input) { 64 | e.preventDefault() 65 | input.focus() 66 | } 67 | e.preventDefault() 68 | return 69 | } 70 | } 71 | e.preventDefault() 72 | return afterDelete(rh) 73 | } 74 | 75 | // handle ​ after delete 76 | function afterDelete(rh) { 77 | let deleteInterval = window.setInterval(function () { 78 | let s = rh.getSelection() 79 | let focusNode = s.focusNode 80 | let ctn = typeof focusNode.innerText === 'string' ? focusNode.innerText : focusNode.nodeValue 81 | if (typeof ctn === 'string' && /\u200B/.test(ctn) && ctn.replace(/\u200B/g, '') === '') { 82 | document.execCommand('delete', false) 83 | } else { 84 | document.execCommand('delete', false) 85 | window.clearInterval(deleteInterval) 86 | } 87 | }) 88 | 89 | // if edit zone is empty, create a row 90 | if (rh.isEmptyNode(rh.editZone()) && !rh.getRows().length) { 91 | let row = rh.newRow({br: true}) 92 | rh.editZone().appendChild(row) 93 | rh.getSelection().collapse(row, 1) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/commands/insertImage.js: -------------------------------------------------------------------------------- 1 | import lrz from 'lrz' 2 | 3 | const insertImage = function (rh, arg) { 4 | // forbidden logic 5 | let forbidden = false 6 | let actives = rh.editor.activeModules 7 | actives.forEach(name => { 8 | let module = rh.editor.modulesMap[name] 9 | if (module && module.exclude && module.exclude.includes('image')) { 10 | forbidden = true 11 | } 12 | }) 13 | if (forbidden) return 14 | 15 | let returnData = { 16 | origin: arg 17 | } 18 | let editor = rh.editor 19 | let config = editor.modulesMap['image'] 20 | if (!config) { 21 | throw new Error('image config load fail') 22 | } 23 | if (arg instanceof File) { 24 | handleFile(arg) 25 | } 26 | if (typeof arg === 'string') { 27 | document.execCommand('insertImage', false, arg) 28 | } 29 | 30 | function handleFile(file) { 31 | if (config.compress) { 32 | config.compress.fieldName = config.fieldName || 'image' 33 | lrz(file, config.compress).then(rst => { 34 | if (rst.file.size > config.maxSize) { 35 | Object.assign(returnData, { 36 | status: 'exceed size limit, after compress', 37 | statusCode: 3 38 | }, rst) 39 | editor.$emit('imageUpload', returnData) 40 | } else { 41 | Object.assign(returnData, rst) 42 | let imgId = rh.createRandomId('img') 43 | insertBase64(returnData.base64, imgId) 44 | } 45 | }).catch(err => { 46 | Object.assign(returnData, { 47 | status: err, 48 | statusCode: 2 49 | }) 50 | editor.$emit('imageUpload', returnData) 51 | }) 52 | } else { 53 | if (file.size > config.maxSize) { 54 | editor.$emit('imageUpload', Object.assign(returnData, { 55 | status: 'exceed size limit, without compress', 56 | statusCode: 3 57 | })) 58 | } else { 59 | let formData = new FormData() 60 | formData.append(config.fieldName, file, file.name) 61 | returnData.formData = formData 62 | const reader = new FileReader() 63 | reader.onload = (e) => { 64 | let imgId = rh.createRandomId('img') 65 | insertBase64(e.target.result, imgId) 66 | } 67 | reader.readAsDataURL(file) 68 | } 69 | } 70 | } 71 | 72 | function insertBase64 (base64, id) { 73 | let dom = `` 74 | if (config.imgOccupyNewRow) { 75 | let node = rh.range.commonAncestorContainer 76 | let curRow = rh.forceGetRow(node) 77 | if (curRow) { 78 | let sibling = curRow.nextElementSibling 79 | let newRow = rh.newRow({contenteditable: false}) 80 | newRow.innerHTML = dom 81 | rh.insertAfter(newRow, curRow) 82 | if (!sibling) { 83 | sibling = rh.newRow({br: true}) 84 | rh.insertAfter(sibling, newRow) 85 | } 86 | try { 87 | rh.getSelection().collapse(sibling, 1) 88 | } catch (e) { 89 | rh.getSelection().collapse(sibling, 0) 90 | } 91 | } 92 | } else { 93 | editor.execCommand('insertHTML', dom, true) 94 | } 95 | editor.saveCurrentRange() 96 | editor.$emit('imageUpload', Object.assign(returnData, { 97 | status: 'everything fine', 98 | statusCode: 2, 99 | base64, 100 | replaceSrcAfterUploadFinish: replaceImg(id), 101 | deleteImgWhenUploadFail: deleteImg(id) 102 | })) 103 | } 104 | 105 | // replace image's base64 src to url after upload finish 106 | function replaceImg (id) { 107 | return function (src) { 108 | let target = document.querySelector(`img[data-editor-img='${id}']`) 109 | if (target) { 110 | target.setAttribute('src', src) 111 | target.removeAttribute('data-editor-img') 112 | editor.$emit('change', editor.$refs.content.innerHTML) 113 | } 114 | } 115 | } 116 | 117 | // delete image after upload fail 118 | function deleteImg (id) { 119 | return function () { 120 | let target = document.querySelector(`img[data-editor-img='${id}']`) 121 | if (target) { 122 | target.parentNode.removeChild(target) 123 | editor.$emit('change', editor.$refs.content.innerHTML) 124 | } 125 | } 126 | } 127 | } 128 | 129 | export default insertImage 130 | -------------------------------------------------------------------------------- /src/commands/todo.js: -------------------------------------------------------------------------------- 1 | import commands from './index' 2 | import constant from '../constant-config' 3 | 4 | const t = { 5 | 'todo' (rh, data) { 6 | let row = rh.newRow({ 7 | br: true 8 | }) 9 | let curRow = rh.getRow(rh.range.commonAncestorContainer) 10 | 11 | // a empty row without row element, create a row element 12 | if (!curRow) { 13 | let v = rh.newRow() 14 | let newRow = rh.newRow({br: true}) 15 | v.appendChild(newRow) 16 | commands.insertHTML(rh, newRow.outerHTML) 17 | let s = rh.getSelection() 18 | curRow = s.focusNode 19 | } 20 | 21 | // insert todo after this row 22 | let afterWhich = rh.getRow(data.insertAfter) 23 | 24 | // is afterWhich is a empty row, just insert todo at current row 25 | if (afterWhich && rh.isEmptyRow(afterWhich)) { 26 | afterWhich = null 27 | } 28 | if (afterWhich) { 29 | let targetIndex 30 | let startIndex 31 | let list = afterWhich.parentNode.childNodes 32 | list.forEach((child, index) => { 33 | if (child === afterWhich) { 34 | startIndex = index 35 | if (startIndex === list.length - 1) { 36 | targetIndex = list.length 37 | } 38 | return 39 | } 40 | if (startIndex !== undefined && targetIndex === undefined) { 41 | if (child && child.getAttribute('data-editor-todo')) { 42 | targetIndex = index 43 | } 44 | } 45 | }) 46 | targetIndex = targetIndex === undefined ? startIndex + 1 : targetIndex 47 | afterWhich.parentNode.insertBefore(row, list[targetIndex]) 48 | rh.getSelection().collapse(row, 0) 49 | } else { 50 | 51 | // insert todo at current row if it is empty 52 | if (rh.isEmptyRow(curRow)) { 53 | rh.collapseAtRow(curRow) 54 | row = curRow 55 | } else { 56 | rh.range.commonAncestorContainer.appendChild(row, 0) 57 | rh.getSelection().collapse(row, 0) 58 | } 59 | } 60 | let todoId = rh.createRandomId('todo') 61 | commands['insertHTML'](rh, `<${constant.ROW_TAG} data-editor-todo=${todoId} contenteditable="false">
    `) 62 | document.querySelector(`[data-editor-todo='${todoId}'] input[type=text]`).focus() 63 | row.parentNode.removeChild(row) 64 | t['initTodo'](rh, data) 65 | }, 66 | // init todo logic 67 | 'initTodo' (rh, data) { 68 | const checkboxs = document.querySelectorAll('[data-editor-todo]') 69 | checkboxs.forEach((c, index) => { 70 | const btn = c.querySelector('[type=checkbox]') 71 | const ctn = c.querySelector('[type=text]') 72 | if (c.init) return 73 | ctnCheckedLogic() 74 | 75 | function ctnCheckedLogic() { 76 | ctn.value = ctn.value || ctn.getAttribute('data-editor-value') 77 | ctn.setAttribute('data-editor-value', ctn.value) 78 | if (btn.checked) { 79 | ctn.style.textDecoration = 'line-through' 80 | btn.setAttribute('checked', '') 81 | } else { 82 | ctn.style.textDecoration = 'none' 83 | btn.removeAttribute('checked') 84 | } 85 | 86 | } 87 | 88 | btn.onchange = e => { 89 | ctnCheckedLogic() 90 | if (rh.editor && rh.editor.$refs && rh.editor.$refs.content) { 91 | rh.editor.$emit('change', rh.editor.$refs.content.innerHTML) 92 | } 93 | } 94 | ctn.oninput = e => { 95 | ctn.setAttribute('data-editor-value', e.target.value) 96 | } 97 | ctn.onkeydown = ctn.onkeydown || (e => { 98 | if (![13, 8].includes(e.keyCode)) return 99 | let row = c.nextSibling 100 | if (e.keyCode === 13) { 101 | if (ctn.value === '') { 102 | e.preventDefault() 103 | return deleteTodo() 104 | } 105 | t['todo'](rh, { 106 | insertAfter: c, 107 | placeholder: data.placeholder 108 | }) 109 | } else if (e.keyCode === 8) { 110 | if (ctn.value === '') { 111 | e.preventDefault() 112 | e.stopPropagation() 113 | deleteTodo() 114 | } 115 | } 116 | 117 | function deleteTodo() { 118 | let newRow = rh.newRow({br: true}) 119 | c.parentNode.replaceChild(newRow, c) 120 | rh.getSelection().collapse(newRow, 1) 121 | } 122 | }) 123 | c.init = true 124 | }) 125 | } 126 | } 127 | 128 | export default t 129 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import polyfill from './util/polyfill-ie' 2 | import buildInModules from './modules/index' 3 | import buildInShortcut from './shortcut' 4 | import constantConfig from './constant-config' 5 | import editor from './editor/editor' 6 | import buildInCommands from './commands' 7 | import initExcludeRule from './module-inspect/load-module-inspect-exclude-rules' 8 | import Inspector from './module-inspect' 9 | import i18nZhCn from './i18n/zh-cn' 10 | import i18nEnUs from './i18n/en-us' 11 | import { 12 | mixin, 13 | isObj 14 | } from './util' 15 | 16 | polyfill() 17 | 18 | class Editor { 19 | 20 | constructor(options = {}) { 21 | let modules = [...buildInModules] 22 | let reservedNames = {} 23 | modules.forEach(m => { 24 | if (m.name) { 25 | reservedNames[m.name] = true 26 | } 27 | }) 28 | const components = {} 29 | const modulesMap = {} 30 | 31 | // extended modules 32 | if (Array.isArray(options.extendModules)) { 33 | options.extendModules.forEach(module => { 34 | if (module.name && !reservedNames[module.name]) { 35 | modules.push(module) 36 | } else { 37 | throw new Error('extended module must have a name and should not be the same as buildIn module') 38 | } 39 | }) 40 | } 41 | 42 | // modules in use 43 | if (Array.isArray(options.modules)) { 44 | let m = [] 45 | options.modules.forEach(name => { 46 | if (typeof name !== 'string') { 47 | throw new Error('modules\'s item must be string') 48 | } 49 | modules.forEach(module => { 50 | if (module.name === name) { 51 | m.push(module) 52 | } 53 | }) 54 | }) 55 | modules = m 56 | } 57 | 58 | modules.forEach(module => { 59 | // config 60 | let curConfig = options[module.name] 61 | let moduleConfig = module 62 | if (isObj(curConfig) && isObj(moduleConfig)) { 63 | Object.assign(moduleConfig, curConfig) 64 | } 65 | 66 | module.moduleInspectResult = null 67 | module.forbidden = null 68 | if (typeof module.inspect === 'function') { 69 | let inspector = new Inspector(module.name) 70 | module.inspect(inspector.add.bind(inspector)) 71 | } else { 72 | module.type = 'fn' 73 | } 74 | module.exclude = initExcludeRule(module, modules) 75 | 76 | if (module.tab) { 77 | module.tab.module = module 78 | 79 | // add activeItem prop 80 | module.tab.props = module.tab.props ? Object.assign(module.tab.props, {activeItem: [String, Boolean], forbidden: Boolean}) : {activeItem: [String, Boolean], forbidden: Boolean} 81 | module.tabName = `tab-${module.name}` 82 | components[module.tabName] = module.tab 83 | } 84 | if (options.icons && options.icons[module.name]) { 85 | module.icon = options.icons[module.name] 86 | } 87 | module.hasTab = !!module.tab 88 | 89 | // prevent vue sync 90 | delete module.tab 91 | 92 | modulesMap[module.name] = module 93 | }) 94 | 95 | // i18n 96 | const i18n = {'zh-cn': i18nZhCn, 'en-us': i18nEnUs} 97 | const customI18n = options.i18n || {} 98 | Object.keys(customI18n).forEach((key) => { 99 | i18n[key] = i18n[key] ? Object.assign(i18n[key], customI18n[key]) : customI18n[key] 100 | }) 101 | const language = options.language || 'en-us' 102 | const locale = i18n[language] 103 | 104 | // shortcut 105 | options.shortcut = Object.assign(buildInShortcut, options.shortcut || {}) 106 | const shortcut = {} 107 | Object.keys(options.shortcut).forEach(key => { 108 | let item = options.shortcut[key] 109 | let keyCode = item.keyCode 110 | shortcut[keyCode] = shortcut[keyCode] || [] 111 | shortcut[keyCode].push(item) 112 | item.name = key 113 | }) 114 | 115 | // merge commands 116 | if (isObj(options.commands)) { 117 | Object.assign(buildInCommands, options.commands) 118 | } 119 | 120 | // spellcheck 121 | const spellcheck = options.spellcheck || false 122 | 123 | const compo = mixin(editor, { 124 | data () { 125 | return {modules, locale, shortcut, modulesMap, spellcheck, constantConfig} 126 | }, 127 | components 128 | }) 129 | Object.assign(this, compo) 130 | } 131 | 132 | /** 133 | * global install 134 | * @param Vue 135 | * @param options 136 | */ 137 | static install(Vue, options = {}) { 138 | Vue.component(options.name || 'my-vue-editor', new Editor(options)) 139 | } 140 | } 141 | 142 | export default Editor 143 | 144 | // to change Babel6 export's result 145 | module.exports = Editor 146 | 147 | -------------------------------------------------------------------------------- /src/module-inspect/index.js: -------------------------------------------------------------------------------- 1 | import { isObj } from '../util' 2 | import RH from '../range-handler' 3 | /* 4 | * Inspect and highlight module 5 | **/ 6 | class Inspector { 7 | constructor (moduleName) { 8 | this.moduleName = moduleName 9 | } 10 | 11 | add (type, param) { 12 | let moduleName = this.moduleName 13 | if (typeof moduleName !== 'string') { 14 | throw new Error('moduleName must be string') 15 | } 16 | switch (type) { 17 | case 'tag': 18 | if (typeof param !== 'string') { 19 | throw new Error('inspector for tag can only receive a string param which stand for tag name') 20 | } 21 | Inspector.tagMap[param.toUpperCase()] = moduleName 22 | break 23 | case 'style': 24 | if (!isObj(param)) { 25 | throw new Error('inspector for style can only receive a object param') 26 | } 27 | Inspector.styles[moduleName] = param 28 | break 29 | case 'attribute': 30 | if (!isObj(param)) { 31 | throw new Error('inspector for attribute can only receive a object param') 32 | } 33 | Inspector.attributes[moduleName] = param 34 | break 35 | default: 36 | throw new Error('unknown inspector type') 37 | } 38 | return this 39 | } 40 | 41 | inspect_tag (node) { 42 | let result = [] 43 | while (node && node !== RH.prototype.editZone()) { 44 | let inspectResult = Inspector.tagMap[node.nodeName] 45 | if (inspectResult && !result.includes(inspectResult)) { 46 | result.push(inspectResult) 47 | } 48 | node = node.parentNode 49 | } 50 | return result 51 | } 52 | 53 | inspect_style (node) { 54 | let result = [] 55 | while (node && node !== RH.prototype.editZone()) { 56 | if (!node.style) break 57 | Object.keys(Inspector.styles).forEach(moduleName => { 58 | let moduleStyle = Inspector.styles[moduleName] 59 | Object.keys(moduleStyle).forEach(item => { 60 | let curValue = moduleStyle[item] 61 | if (typeof curValue === 'string' && node.style[item] === curValue) { 62 | if (!result.includes(moduleName)) { 63 | result.push(moduleName) 64 | Inspector.activeItems[moduleName] = curValue 65 | } 66 | } 67 | if (Array.isArray(curValue)) { 68 | curValue.forEach(val => { 69 | if (node.style[item] === val) { 70 | if (!result.includes(moduleName)) { 71 | result.push(moduleName) 72 | Inspector.activeItems[moduleName] = val 73 | } 74 | } 75 | }) 76 | } 77 | }) 78 | }) 79 | node = node.parentNode 80 | } 81 | return result 82 | } 83 | 84 | inspect_attribute (node) { 85 | let result = [] 86 | while (node && node !== RH.prototype.editZone()) { 87 | if (!node.getAttribute) break 88 | Object.keys(Inspector.attributes).forEach(moduleName => { 89 | let moduleAttr = Inspector.attributes[moduleName] 90 | Object.keys(moduleAttr).forEach(item => { 91 | let value = moduleAttr[item] 92 | let nodeVal = node.getAttribute(item) 93 | if (Array.isArray(value)) { 94 | value.forEach(val => { 95 | if (nodeVal === val) { 96 | if (!result.includes(moduleName)) { 97 | result.push(moduleName) 98 | Inspector.activeItems[moduleName] = val 99 | } 100 | } 101 | }) 102 | } 103 | if (typeof value === 'string' && nodeVal === value || nodeVal !== null) { 104 | if (!result.includes(moduleName)) { 105 | result.push(moduleName) 106 | Inspector.activeItems[moduleName] = value 107 | } 108 | } 109 | }) 110 | }) 111 | node = node.parentNode 112 | } 113 | return result 114 | } 115 | } 116 | 117 | Inspector.tagMap = {} 118 | Inspector.styles = {} 119 | Inspector.attributes = {} 120 | Inspector.activeItems = {} 121 | 122 | Inspector.run = (type, nodeList) => { 123 | let fn = Inspector.prototype['inspect_' + type] 124 | let result = [] 125 | if (typeof fn === 'function' && Array.isArray(nodeList)) { 126 | nodeList.forEach(node => { 127 | result.push(fn(node)) 128 | }) 129 | } 130 | return result 131 | } 132 | 133 | Inspector.removeDuplate = function (list) { 134 | // merge same module inspect result 135 | let sameStyleMap = {} 136 | list.forEach(m => { 137 | if (typeof m === 'string') { 138 | sameStyleMap[m] = sameStyleMap[m] ? sameStyleMap[m] + 1 : 1 139 | } 140 | if (Array.isArray(m)) { 141 | m = Array.from(new Set(m)) 142 | m.forEach(am => { 143 | sameStyleMap[am] = sameStyleMap[am] ? sameStyleMap[am] + 1 : 1 144 | }) 145 | } 146 | }) 147 | let mergedStyle = [] 148 | Object.keys(sameStyleMap).forEach(m => { 149 | if (sameStyleMap[m] === list.length) { 150 | mergedStyle.push(m) 151 | } 152 | }) 153 | return mergedStyle 154 | } 155 | 156 | 157 | export default Inspector 158 | -------------------------------------------------------------------------------- /README_CN.md: -------------------------------------------------------------------------------- 1 | # my-vue-editor 2 | [![Travis](https://img.shields.io/travis/rust-lang/rust.svg)](https://github.com/BetaSu/my-vue-editor) 3 | [![Packagist](https://img.shields.io/packagist/l/doctrine/orm.svg)](https://github.com/BetaSu/my-vue-editor) 4 | [![Plugin on redmine.org](https://img.shields.io/redmine/plugin/stars/redmine_xlsx_format_issue_exporter.svg)](https://github.com/BetaSu/my-vue-editor) 5 | 6 | 基于Vue2.x的富文本编辑器
    7 | For English
    8 | ## Demo 9 | 点击这里查看demo
    10 | 更多demo请参考example目录 11 | ## 简介 12 | 我们的编辑器基于vue-html5-editor二次开发。感谢其作者PeakTai提供了一个简洁的富文本编辑器插件,我们在其基础上重写了原生的方法,并扩展了功能。 13 | 14 | > 注意:项目依赖`font-awesome`,请自行安装 15 | 16 | ## 安装 17 | ```javascript 18 | npm install my-vue-editor 19 | ``` 20 | 作为插件引入 21 | ```javascript 22 | import Vue from 'vue' 23 | import myVueEditor from 'my-vue-editor' 24 | Vue.use(myVueEditor, options) 25 | ``` 26 | 全局引入 27 | ```html 28 | 29 | 30 | ``` 31 | 通过全局变量myVueEditor安装 32 | ```javascript 33 | Vue.use(myVueEditor, options) 34 | ``` 35 | 注意:项目依赖 `font-awesome`,如需使用内置图标请手动安装 `font-awesome`。 36 | 37 | 使用 38 | ```html 39 | 40 | ``` 41 | ## 配置 42 | 43 | | 配置项       | 参数类型           | 说明 | 44 | | ------------- |:-------------:| :-----| 45 | | name     | String | 自定义组件名,默认为my-vue-editor | 46 | | modules     | Array | 需要使用的模块 | 47 | | icons   | Object     | 覆盖指定模块的Icon | 48 | | commands | Object     | 自定义指令 | 49 | | shortcut | Object     | 自定义快捷键 | 50 | | extendModules | Array     | 自定义模块 | 51 | | 任何内置模块名 | Object     | 覆盖对应内置模块的属性 | 52 | ### 一个例子 53 | ```javascript 54 | Vue.use(myVueEditor, { 55 |  // 覆盖内置模块的图标 56 |  icons: { 57 | image: 'iui-icon iui-icon-pic', 58 | indent: 'iui-icon iui-icon-insert' 59 | }, 60 |  // 使用的模块 61 |  modules: [ 62 | 'font', 63 | 'bold', 64 | 'italic', 65 | 'underline', 66 | 'linethrough', 67 | 'ul', 68 | 'indent', 69 | 'align', 70 | 'image', 71 | 'quote', 72 | 'todo', 73 |    // 这是一个自定义的模块 74 |    'customSave' 75 | ], 76 |  // 覆盖image模块的相关配置 77 |  image: { 78 | maxSize: 5120 * 1024, 79 | imgOccupyNewRow: true, 80 | compress: { 81 | width: 1600, 82 | height: 1600, 83 | quality: 0.8 84 | } 85 | }, 86 |  // 覆盖font模块的相关配置 87 |  font: { 88 | config: { 89 | 'xx-large': { 90 | fontSize: 6, 91 | name: '大号' 92 | }, 93 | 'medium': { 94 | fontSize: 3, 95 | name: '中号' 96 | }, 97 | 'small': { 98 | fontSize: 2, 99 | name: '小号' 100 | }, 101 | default: 'medium' 102 | }, 103 |    // 将font模块的模块检测机制修改为通过style的模块检测 104 |    inspect (add) { 105 | add('style', { 106 | fontSize: ['xx-large', 'x-large', 'large', 'medium', 'small'] 107 | }).add('tag', 'font') 108 | } 109 | }, 110 |  // 覆盖ul模块的相关配置 111 | ul: { 112 |    // 当ul模块被检测到时,使除了他自己以外其他所有有模块检测机制的模块被禁用 113 |    exclude: 'ALL_BUT_MYSELF', 114 |    // 当ul模块被点击时,执行如下方法 115 |    handler (rh) { 116 | console.log('i am ul!') 117 | rh.editor.execCommand('insertUnorderedList')   118 |    } 119 |  }, 120 |  // 当quote模块被检测到时,使image todo ul 这3个模块禁用 121 |  quote: { 122 | exclude: ['image', 'todo', 'ul'] 123 | }, 124 |  // 自定义一个名为getAllTexts的指令,执行时会打印出当前range对象下的所有文本节点 125 |  commands: { 126 | getAllTexts (rh, arg) { 127 | console.log(rh.getAllTextNodeInRange()) 128 | } 129 | }, 130 | shortcut: { 131 |    // 自定义一个保存快捷键,当按下command + s 时,执行save函数 132 |    saveNote: { 133 | metaKey: true, 134 | keyCode: 83, 135 | handler (editor) { 136 | save() 137 | } 138 | } 139 | }, 140 |  // 自定义一个模块,当点击该模块图标时会弹出一个窗口 141 |  extendModules: [{ 142 | name: 'smile', 143 | icon: 'iui iui-icon-smile' 144 | handler (rh) { 145 | alert('smile~~') 146 | } 147 | }] 148 | }) 149 | ``` 150 | 151 | ## 事件 152 | | 事件名       | 说明          | 153 | | ------------- |:-------------| 154 | | change     | 当编辑器内容变化时触发,参数为最新内容 | 155 | | imageUpload | 上传图片时触发,参数包括图片相应数据,
    replaceSrcAfterUploadFinish函数(用于当上传成功时将img的src属性由base64格式替换为服务器返回的url)
    deleteImgWhenUploadFail函数(用于当上传失败时候调用删除当前图片)| 156 | ## 修改内置模块 157 | 在配置项中添加以内置模块名(所有内置模块及他们的配置项请在源码src/modules目录下查看)为key的参数,将覆盖内置模块的原有属性 158 | ### 以image模块为例 159 | ```javascript 160 | Vue.use(myVueEditor, { 161 | image: { 162 |    // 修改image模块的图标 163 |    icon: 'iui-pic', 164 |    // 覆盖原有的压缩参数,使图片上传时不进行压缩 165 |    compress: null, 166 |    // 不能重复上传同一张图片 167 |    canUploadSameImage: false 168 | } 169 | }) 170 | ``` 171 | ## 自定义模块 172 | 通过extendModules配置项扩展模块 173 | 我们提供一些通用的模块配置项 174 | 175 | | 配置项       | 类型          | 说明   | 176 | | ------------- |:-------------|:------------| 177 | | name     | String | 模块的名称| 178 | | icon     | String | 模块图标的className,默认使用fontAwesome图标| 179 | | exclude     | String Array | 当模块被检测到时,需要禁用的模块
    值为'ALL'表示禁用所有模块,包括自己
    值为'ALL_BUT_MYSELF'表示禁用除自己以外的所有模块
    值为Array时,传入需要禁用的模块名| 180 | | inspect     | Function | 模块检测,当光标处在列表中时,列表模块高亮,即列表模块被检测到,这是通过其UL标签作为检测依据
    函数的第一个参数为add方法,通过调用add方法来增加模块的检测依据,当有多个检测依据时请链式调用add
    add方法第一个参数标示通过什么途径检测,可选'tag' 'style' 'attribute'
    当参数1为'tag'时,参数2请传入一个标签名字符串
    当参数1为'style'时,参数2为以styleName为key,styleValue为value的对象。注意styleName请使用驼峰形式(如:fontSize),当styleValue有多个时请使用Array的形式
    当参数1为'attribute'时,参数2为以attribute名为key,attribute值为value的对象,注意若希望值为任何值都满足时传入''(如:add('attribute', {'data-todo': ''}))| 181 | | handler     | Function | 点击模块时执行的操作
    参数1为range-handler实例,通过实例可以取得当前编辑器的Vue实例以及操作range的方法
    我们并不推荐直接在handler中操作range,而是应该将range操作封装为command,在handler中通过编辑器实例下的execCommand方法调用command| 182 | | tab     | Vue instance | 常规的模块使用handler来处理点击,如果你希望实现UI(font模块的下拉列表)或者逻辑更复杂(align模块的左中右切换)的模块,请使用tab| 183 | | init     | Function |对应编辑器组件生命周期的created,参数1为编辑器实例
    注意这时execCommand方法还不能使用| 184 | | mounted     | Function | 对应编辑器组件生命周期的mounted| 185 | | updated     | Function | 对应编辑器组件生命周期的updated| 186 | | destroyed     | Function | 对应编辑器组件生命周期的beforeDestroy| 187 | -------------------------------------------------------------------------------- /src/commands/quote.js: -------------------------------------------------------------------------------- 1 | import commands from './index' 2 | 3 | const q = { 4 | // only set contenteditable:false in parent node can child node trigger keydown listener 5 | 'quote' (rh, isInQuote) { 6 | let node = rh.range.commonAncestorContainer 7 | if (isInQuote) { 8 | node = node.nodeType === Node.TEXT_NODE ? node.parentNode : node 9 | let quote = rh.findSpecialAncestor(node, '[data-editor-quote]') 10 | if (quote) { 11 | let texts = rh.getDescendantTextNodes(quote) 12 | let quoteRows = [] 13 | let rows = Array.from(quote.querySelector('[data-editor-quote-block').children) 14 | texts.forEach(text => { 15 | // find row in current quote row 16 | // let row = rh.findSpecialAncestor(text, constant.ROW_TAG, false, quote) 17 | let row 18 | rows.forEach(curRow => { 19 | if (curRow.contains(text)) { 20 | row = curRow 21 | } 22 | }) 23 | if (!quoteRows.includes(row)) { 24 | quoteRows.push(row) 25 | } 26 | }) 27 | quoteRows.forEach((qr, index) => { 28 | if (index === 0) { 29 | quote.parentNode.replaceChild(qr, quote) 30 | } else { 31 | rh.insertAfter(qr, quoteRows[index - 1]) 32 | } 33 | }) 34 | let s = rh.getSelection() 35 | if (quoteRows.length) { 36 | const range = document.createRange() 37 | range.setStart(quoteRows[0], 0) 38 | range.setEnd(quoteRows[quoteRows.length - 1], 1) 39 | s.removeAllRanges() 40 | s.addRange(range) 41 | } else { 42 | // it's a empty quote 43 | let newRow = rh.newRow({br: true}) 44 | quote.parentNode.replaceChild(newRow, quote) 45 | s.collapse(newRow, 1) 46 | } 47 | } 48 | return 49 | } 50 | const texts = rh.getAllTextNodesInRange() 51 | let curRow = rh.getRow(node) 52 | 53 | // is at a empty row without row element, then create a row 54 | // or texts has no common parent row 55 | if (!curRow && !texts.length) { 56 | let v = rh.newRow() 57 | let newRow = rh.newRow({br: true}) 58 | v.appendChild(newRow) 59 | commands.insertHTML(rh, newRow.outerHTML) 60 | let s = rh.getSelection() 61 | texts.push(s.focusNode) 62 | } 63 | if (!texts.length) { 64 | texts.push(curRow) 65 | } 66 | 67 | let container = rh.newRow() 68 | let quote = document.createElement('section') 69 | let quoteBlock = rh.newRow({tag: 'div'}) 70 | quoteBlock.setAttribute('data-editor-quote-block', rh.createRandomId('quoteblock')) 71 | quote.appendChild(quoteBlock) 72 | let id = rh.createRandomId('quote') 73 | quote.setAttribute('data-editor-quote', id) 74 | quote.setAttribute('contenteditable', 'false') 75 | let quoteRows = [] 76 | texts.forEach((text, index) => { 77 | let curRow = rh.getRow(text) 78 | 79 | // create a row for text without row 80 | if (!curRow && text.nodeValue) { 81 | curRow = rh.newRow() 82 | curRow.appendChild(text) 83 | } 84 | if (curRow && !quoteRows.includes(curRow)) { 85 | quoteRows.push(curRow) 86 | } 87 | }) 88 | let anchorRow 89 | quoteRows.forEach((qr, index) => { 90 | if (index !== quoteRows.length - 1) { 91 | quoteBlock.appendChild(qr) 92 | return 93 | } 94 | quoteBlock.appendChild(qr.cloneNode(true)) 95 | anchorRow = qr 96 | }) 97 | 98 | if (anchorRow.parentNode) { 99 | anchorRow.parentNode.replaceChild(quote, anchorRow) 100 | } else { 101 | // current row is created and has no parent 102 | let v = rh.newRow() 103 | v.appendChild(quote) 104 | rh.range.deleteContents() 105 | commands['insertHTML'](rh, v.innerHTML) 106 | } 107 | const curQuote = document.querySelector(`[data-editor-quote='${id}']`) 108 | if (!curQuote.lastElementChild) return 109 | rh.getSelection().collapse(curQuote.lastElementChild, curQuote.lastElementChild.innerText ? 1 : 0) 110 | }, 111 | 'initQuote' (rh, arg) { 112 | document.addEventListener('keydown', e => { 113 | let quote = rh.findSpecialAncestor(e.target, '[data-editor-quote]') 114 | if (quote) { 115 | let s = rh.getSelection() 116 | let node = s.anchorNode 117 | let ctn = node.innerText || node.nodeValue 118 | if (e.keyCode === 13) { 119 | if (ctn.replace('\n', '') === '') { 120 | e.preventDefault() 121 | let newRow = rh.newRow({br: true}) 122 | rh.insertAfter(newRow, quote) 123 | if (node.parentNode.children.length > 1) { 124 | node.parentNode.removeChild(node) 125 | } 126 | rh.getSelection().collapse(newRow, 0) 127 | return 128 | } 129 | } 130 | if (e.keyCode === 8) { 131 | 132 | // cursor may at row or at quote block , so there are two judgement conditions 133 | if (s.isCollapsed && (s.focusOffset === 0 || (node.contains(s.baseNode) && (rh.isEmptyNode(s.baseNode)) && s.focusOffset === 1))) { 134 | let rows = Array.from(quote.querySelector('[data-editor-quote-block]').children) 135 | 136 | // empty quote 137 | if (!rows.length) { 138 | e.preventDefault() 139 | let newRow = rh.newRow({br: true}) 140 | quote.parentNode.replaceChild(newRow, quote) 141 | rh.getSelection().collapse(newRow, 1) 142 | return 143 | } 144 | 145 | rows.forEach((row, index) => { 146 | 147 | // row and node has father-child relationship 148 | if ((row === node || row.contains(node) || node.contains(row)) && index === 0) { 149 | 150 | // only have one empty row in quote,then delete the quote 151 | if (rows.length === 1 && rh.isEmptyNode(row)) { 152 | e.preventDefault() 153 | let newRow = rh.newRow({br: true}) 154 | quote.parentNode.replaceChild(newRow, quote) 155 | rh.getSelection().collapse(newRow, 1) 156 | return 157 | } else { 158 | 159 | // first row have content and previous element exist, then move cursor to previous element 160 | let preRow = rh.getPreviousRow(quote) 161 | if (preRow) { 162 | e.preventDefault() 163 | 164 | // previous row is a quote 165 | if (preRow.getAttribute('data-editor-quote')) { 166 | let lastEle = Array.from(preRow.querySelector('[data-editor-quote-block]').children).pop() 167 | try { 168 | rh.getSelection().collapse(lastEle, 1) 169 | } catch (e) { 170 | rh.getSelection().collapse(lastEle, 0) 171 | } 172 | return 173 | } 174 | 175 | // previous row is a todo 176 | if (preRow.getAttribute('data-editor-todo')) { 177 | let input = preRow.querySelector('[type="text"]') 178 | if (input) { 179 | e.preventDefault() 180 | input.focus() 181 | } 182 | return 183 | } 184 | 185 | // previous row is a row 186 | try { 187 | rh.getSelection().collapse(preRow, 1) 188 | } catch (e) { 189 | rh.getSelection().collapse(preRow, 0) 190 | } 191 | return 192 | } 193 | } 194 | } 195 | }) 196 | } 197 | } 198 | } 199 | }) 200 | } 201 | } 202 | 203 | export default q 204 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # my-vue-editor 2 | [![Travis](https://img.shields.io/travis/rust-lang/rust.svg)](https://github.com/BetaSu/my-vue-editor) 3 | [![Packagist](https://img.shields.io/packagist/l/doctrine/orm.svg)](https://github.com/BetaSu/my-vue-editor) 4 | [![Plugin on redmine.org](https://img.shields.io/redmine/plugin/stars/redmine_xlsx_format_issue_exporter.svg)](https://github.com/BetaSu/my-vue-editor) 5 | 6 | A rich text editor based on Vue2.x
    7 | 中文文档
    8 | ## Demo 9 | click here to see demo
    10 | More demo please refer to the example directory 11 | ## Introduction 12 | Our editor is based on vue-html5-editor secondary development. Thanks to its author PeakTai for providing a concise rich text editor plug-in, on the basis of which we have rewritten the native method and extended the functionality. 13 | ## Install 14 | ```javascript 15 | npm install my-vue-editor 16 | ``` 17 | Introduced as a plug-in 18 | ```javascript 19 | import Vue from 'vue' 20 | import myVueEditor from 'my-vue-editor' 21 | Vue.use(myVueEditor, options) 22 | ``` 23 | Global introduction 24 | ```html 25 | 26 | 27 | ``` 28 | Installed by the global variable myVueEditor 29 | ```javascript 30 | Vue.use(myVueEditor, options) 31 | ``` 32 | Use 33 | ```html 34 | 35 | ``` 36 | ## Configuration 37 | 38 | | Items       | Type           | Description | 39 | | ------------- |:-------------:|:-----| 40 | | name     | String | Custom component name, the default is my-vue-editor | 41 | | modules     | Array | Modules need to use | 42 | | icons   | Object     | Covering the specified module's icon | 43 | | commands | Object     | Custom command | 44 | | shortcut | Object     | Custom shortcut | 45 | | extendModules | Array     | Custom module | 46 | | Any built-in module name | Object     | Overwrite the properties of the corresponding built-in module | 47 | ### example 48 | ```javascript 49 | Vue.use(myVueEditor, { 50 |  // Overlay built-in module's icon 51 |  icons: { 52 | image: 'iui-icon iui-icon-pic', 53 | indent: 'iui-icon iui-icon-insert' 54 | }, 55 |  // Modules in use 56 |  modules: [ 57 | 'font', 58 | 'bold', 59 | 'italic', 60 | 'underline', 61 | 'linethrough', 62 | 'ul', 63 | 'indent', 64 | 'align', 65 | 'image', 66 | 'quote', 67 | 'todo', 68 |    // This is a custom module 69 |    'customSave' 70 | ], 71 |  // Overlay image module's configuration 72 |  image: { 73 | maxSize: 5120 * 1024, 74 | imgOccupyNewRow: true, 75 | compress: { 76 | width: 1600, 77 | height: 1600, 78 | quality: 0.8 79 | } 80 | }, 81 |  // Overlay font module's configuration 82 |  font: { 83 | config: { 84 | 'xx-large': { 85 | fontSize: 6, 86 | name: 'H1' 87 | }, 88 | 'medium': { 89 | fontSize: 3, 90 | name: 'H2' 91 | }, 92 | 'small': { 93 | fontSize: 2, 94 |        name: 'H3' 95 | }, 96 | default: 'medium' 97 | }, 98 |    // Modify the font module's module inspect mechanism to inspect via style and tag name 99 |    inspect (add) { 100 | add('style', { 101 | fontSize: ['xx-large', 'x-large', 'large', 'medium', 'small'] 102 | }).add('tag', 'font') 103 | } 104 | }, 105 |  // Overlay ul module's configuration 106 | ul: { 107 |    // When the ul module is inspected, disabled all but itself 108 |    exclude: 'ALL_BUT_MYSELF', 109 |    // When the ul module is clicked, execute the following method 110 |    handler (rh) { 111 | console.log('i am ul!') 112 | rh.editor.execCommand('insertUnorderedList')   113 |    } 114 |  }, 115 |  // When the ul module is inspected, disabled image, todo and ul module 116 |  quote: { 117 | exclude: ['image', 'todo', 'ul'] 118 | }, 119 |  // Customize an command named getAllTexts that prints out all the text nodes under the current range object 120 |  commands: { 121 | getAllTexts (rh, arg) { 122 | console.log(rh.getAllTextNodeInRange()) 123 | } 124 | }, 125 | shortcut: { 126 |    // Custom a shortcut key, when you press the command + s, execute the save function 127 |    saveNote: { 128 | metaKey: true, 129 | keyCode: 83, 130 | handler (editor) { 131 | save() 132 | } 133 | } 134 | }, 135 |  // Customize a module, a alert pops up when you click on the module icon 136 |  extendModules: [{ 137 | name: 'smile', 138 | icon: 'iui iui-icon-smile' 139 | handler (rh) { 140 | alert('smile~~') 141 | } 142 | }] 143 | }) 144 | ``` 145 | 146 | ## Event 147 | | Event name       | Description          | 148 | | ------------- |:-------------| 149 | | change     | Trigger when editor content changes, parameter is up-to-date content data | 150 | | imageUpload | Trigger when uploading images, parameters include the corresponding data of the image,
    replaceSrcAfterUploadFinish:Used to replace the src attribute of img from the base64 format to the url returned by the server when the upload is successful)
    deleteImgWhenUploadFail:Used to delete the current picture when the upload fails| 151 | ## Modify the built-in module 152 | Add a parameter with a built-in module's name as it's key, will cover the built-in module's original properties(See all built-in modules and their configuration items in src/modules directory) 153 | ### Take the image module as an example 154 | ```javascript 155 | Vue.user(myVueEditor, { 156 | image: { 157 |    // Modify the image module's icon 158 |    icon: 'iui-pic', 159 |    // Cover the original compression parameters, so that the image is not compressed when uploaded 160 |    compress: null, 161 |    // Can not upload the same image repeatedly 162 |    canUploadSameImage: false 163 | } 164 | }) 165 | ``` 166 | ## Cutom module 167 | Extend the module with the extendModules configuration item 168 | We provide some common module configuration items 169 | 170 | | Item       | Type          | Description   | 171 | | ------------- |:-------------|:------------| 172 | | name     | String | module's name| 173 | | icon     | String | module icon's className,The fontAwesome icon is used by default| 174 | | exclude     | String Array | disabled modules When current module is inspected
    When value is 'ALL' means disable all modules include current module
    When value is'ALL_BUT_MYSELF', means disabled all modules but current module
    When value is type of Array, Input module name to be disabled| 175 | | inspect     | Function | module inspect,When the cursor is in the list, the list module is highlighted, that is, the list module is inspected, which is based on its UL label as a test basis
    The first argument to the function is a method named add, which called to add the module's inspection basis. When there are multiple inspection bases, please call chaining
    The first parameter of the add method indicates what path to test. The optional is 'tag' 'style' and 'attribute'
    When parameter 1 is 'tag', parameter 2 passes in a tag name string
    When parameter 1 is 'style', parameter 2 is an object with styleName as key and styleValue as value. Note styleName use the hump form(ex:fontSize),When there are multiple styleValue please use the form of Array
    When parameter 1 is 'attribute', parameter 2 is an object whose key is attribute name and attribute value is value, and note that if any value is desired, pass in ''(ex:add('attribute', {'data-todo': ''}))| 176 | | handler     | Function | What to do when you click the module
    Parameter 1 is range-handler instance, through which can you get the Vue instance of the current editor and the method to operate range
    We do not recommend operating range directly in the handler, but should encapsulate the range action as a command, calling the command via the execCommand method under the editor instance in the handler| 177 | | tab     | Vue instance | Conventional modules use handlers for handling clicks, and if you want to implement UI (drop-down list of font modules) or more complex logic (left-to-right switching of align modules), use tab| 178 | | init     | Function |Corresponds to the editor component life cycle created, the parameter 1 is the editor instance. Note that the execCommand method can not be used at this time| 179 | | mounted     | Function | Corresponds to the editor component life cycle mounted, the parameter 1 is the editor instance.| 180 | | updated     | Function | Corresponds to the editor component life cycle updated, the parameter 1 is the editor instance.| 181 | | destroyed     | Function | Corresponds to the editor component life cycle beforeDestroy, the parameter 1 is the editor instance.| 182 | -------------------------------------------------------------------------------- /src/editor/editor.js: -------------------------------------------------------------------------------- 1 | import RH from '../range-handler' 2 | import './style/main.styl' 3 | import template from './editor.html' 4 | import dragPic from './drag-pic' 5 | import Inspector from '../module-inspect' 6 | 7 | export default { 8 | template, 9 | props: { 10 | content: { 11 | type: String, 12 | required: true, 13 | default: '' 14 | }, 15 | height: { 16 | type: Number, 17 | default: 300, 18 | validator(val){ 19 | return val >= 100 20 | } 21 | }, 22 | zIndex: { 23 | type: Number, 24 | default: 1000 25 | }, 26 | autoHeight: { 27 | type: Boolean, 28 | default: true 29 | } 30 | }, 31 | directives: { 32 | dragPic 33 | }, 34 | data(){ 35 | return { 36 | modules: {}, 37 | activeModules: [], 38 | allActiveModules: [], 39 | fullScreen: false 40 | } 41 | }, 42 | watch: { 43 | content(val) { 44 | const content = this.$refs.content.innerHTML 45 | if (val !== content) { 46 | this.$refs.content.innerHTML = val 47 | } 48 | }, 49 | fullScreen(val){ 50 | const component = this 51 | if (val) { 52 | component.parentEl = component.$el.parentNode 53 | component.nextEl = component.$el.nextSibling 54 | document.body.appendChild(component.$el) 55 | return 56 | } 57 | if (component.nextEl) { 58 | component.parentEl.insertBefore(component.$el, component.nextEl) 59 | return 60 | } 61 | component.parentEl.appendChild(component.$el) 62 | } 63 | }, 64 | computed: { 65 | contentStyle(){ 66 | const style = {} 67 | if (this.fullScreen) { 68 | style.height = `${window.innerHeight - this.$refs.toolbar.clientHeight - 1}px` 69 | return style 70 | } 71 | if (!this.autoHeight) { 72 | style.height = `${this.height}px` 73 | return style 74 | } 75 | style['min-height'] = `${this.height}px` 76 | return style 77 | } 78 | }, 79 | methods: { 80 | getCurActiveModuleItem () { 81 | return Inspector.activeItems 82 | }, 83 | clearActiveModuleItem () { 84 | Inspector.activeItems = {} 85 | }, 86 | handleDragPic (file) { 87 | if ((this.modulesMap['image'] && this.modulesMap['image'].drag !== false) || !this.modulesMap['image']) { 88 | this.saveCurrentRange() 89 | this.execCommand('insertImage', file) 90 | } 91 | }, 92 | toggleFullScreen(){ 93 | this.fullScreen = !this.fullScreen 94 | }, 95 | enableFullScreen(){ 96 | this.fullScreen = true 97 | }, 98 | exitFullScreen(){ 99 | this.fullScreen = false 100 | }, 101 | focus(){ 102 | this.$refs.content.focus() 103 | }, 104 | blur(){ 105 | this.$refs.content.blur() 106 | }, 107 | execCommand(command, arg, execOnly){ 108 | if (!execOnly) { 109 | this.restoreSelection() 110 | } 111 | if (this.range) { 112 | new RH(this.range, this).execCommand(command, arg) 113 | } 114 | this.$emit('change', this.$refs.content.innerHTML) 115 | }, 116 | saveCurrentRange(){ 117 | const selection = window.getSelection ? window.getSelection() : document.getSelection() 118 | const content = this.$refs.content 119 | if (!selection.rangeCount || !content) { 120 | return 121 | } 122 | for (let i = 0; i < selection.rangeCount; i++) { 123 | const range = selection.getRangeAt(0) 124 | let start = range.startContainer 125 | let end = range.endContainer 126 | // for IE11 : node.contains(textNode) always return false 127 | start = start.nodeType === Node.TEXT_NODE ? start.parentNode : start 128 | end = end.nodeType === Node.TEXT_NODE ? end.parentNode : end 129 | if (content.contains(start) && content.contains(end)) { 130 | this.range = range 131 | break 132 | } 133 | } 134 | }, 135 | restoreSelection(){ 136 | const selection = window.getSelection ? window.getSelection() : document.getSelection() 137 | selection.removeAllRanges() 138 | if (this.range) { 139 | selection.addRange(this.range) 140 | } else { 141 | const content = this.$refs.content 142 | const row = RH.prototype.newRow({br: true}) 143 | const range = document.createRange() 144 | content.appendChild(row) 145 | range.setStart(row, 0) 146 | range.setEnd(row, 0) 147 | selection.addRange(range) 148 | this.range = range 149 | } 150 | }, 151 | activeModule(module){ 152 | if (module.forbidden) return 153 | if (typeof module.handler === 'function') { 154 | module.handler(new RH(this.range, this), module) 155 | this.$nextTick(() => { 156 | this.saveCurrentRange() 157 | this.moduleInspect() 158 | }) 159 | return 160 | } 161 | }, 162 | moduleInspect () { 163 | if (this.range) { 164 | this.clearActiveModuleItem() 165 | this.activeModules = [] 166 | this.allActiveModules = [] 167 | let rh = new RH(this.range, this) 168 | let texts = rh.getAllTextNodesInRange() 169 | if (texts.length === 0 && this.range.collapsed) { 170 | texts.push(this.range.commonAncestorContainer) 171 | } 172 | // texts duplicate removal 173 | let textAftetDR = [] 174 | texts.forEach(text => { 175 | if (text.nodeType === Node.TEXT_NODE && text.parentNode !== rh.editZone()) { 176 | text = text.parentNode 177 | } 178 | if (!textAftetDR.includes(text)) { 179 | textAftetDR.push(text) 180 | } 181 | }) 182 | 183 | let tagResult = Inspector.run('tag', textAftetDR) 184 | let tagResultRD = Inspector.removeDuplate(tagResult) 185 | 186 | let styleResult = Inspector.run('style', textAftetDR) 187 | let styleResultRD = Inspector.removeDuplate(styleResult) 188 | 189 | let attributeResult = Inspector.run('attribute', textAftetDR) 190 | let attributeResultRD = Inspector.removeDuplate(attributeResult) 191 | 192 | this.allActiveModules = tagResult.concat(styleResult, attributeResult) 193 | this.activeModules = Array.from(new Set(tagResultRD.concat(styleResultRD, attributeResultRD))) 194 | 195 | // reset 196 | this.modules.forEach(module => { 197 | module.forbidden = false 198 | module.moduleInspectResult = false 199 | }) 200 | 201 | // handle forbidden logic 202 | if (this.allActiveModules.length) { 203 | let excludeList = [] 204 | this.allActiveModules.forEach(m => { 205 | if (Array.isArray(m)) { 206 | m.forEach(moduleName => { 207 | let curModule = this.modulesMap[moduleName] 208 | excludeList = excludeList.concat(curModule.exclude) 209 | }) 210 | } 211 | }) 212 | excludeList = Array.from(new Set(excludeList)) 213 | excludeList.forEach(exc => { 214 | let excModule = this.modulesMap[exc] 215 | if (excModule && excModule.type !== 'fn') { 216 | excModule.forbidden = true 217 | } 218 | }) 219 | } 220 | 221 | // handle highlight logic 222 | if (this.activeModules.length) { 223 | this.modules.forEach(module => { 224 | module.moduleInspectResult = false 225 | let moduleName = module.name 226 | if (this.activeModules.includes(moduleName)) { 227 | module.moduleInspectResult = true 228 | let curModuleActiveItem = this.getCurActiveModuleItem()[moduleName] 229 | if (typeof curModuleActiveItem === 'string') { 230 | module.moduleInspectResult = curModuleActiveItem || 'ALL' 231 | } 232 | } 233 | }) 234 | } 235 | } 236 | } 237 | }, 238 | created(){ 239 | this.modules.forEach((module) => { 240 | if (typeof module.init === 'function') { 241 | module.init(this) 242 | } 243 | }) 244 | }, 245 | mounted(){ 246 | const content = this.$refs.content 247 | const toolbar = this.$refs.toolbar 248 | content.innerHTML = this.content 249 | // add eventListener at document to handle selection 250 | document.addEventListener('mouseup', e => { 251 | this.saveCurrentRange() 252 | this.moduleInspect() 253 | }, false) 254 | // toolbar.addEventListener('mousedown', this.saveCurrentRange, false) 255 | content.addEventListener('keyup', e => { 256 | this.$emit('change', content.innerHTML) 257 | this.saveCurrentRange() 258 | this.moduleInspect() 259 | }, false) 260 | content.addEventListener('mouseout', e => { 261 | this.saveCurrentRange() 262 | }, false) 263 | content.addEventListener('paste', e => { 264 | this.execCommand('paste', e, true) 265 | let common = this.range.commonAncestorContainer 266 | if (common) { 267 | if (common.nodeType === Node.TEXT_NODE) { 268 | common = common.parentNode 269 | } 270 | if (common.scrollIntoView) { 271 | common.scrollIntoView(false) 272 | } 273 | } 274 | }) 275 | this.touchHandler = (e) => { 276 | if (content.contains(e.target)) { 277 | this.saveCurrentRange() 278 | this.moduleInspect() 279 | } 280 | } 281 | window.addEventListener('touchend', this.touchHandler, false) 282 | 283 | // before exec command 284 | // let text be a row 285 | RH.prototype.before((command, rh, arg) => { 286 | let node = rh.range.commonAncestorContainer 287 | // handle editor with no content 288 | if (rh.isEmptyNode(node) && node === rh.editZone()) { 289 | let firstChild = node.firstElementChild 290 | if (firstChild && firstChild.nodeName === 'BR') { 291 | node.removeChild(firstChild) 292 | } 293 | let newRow = rh.newRow({br: true}) 294 | node.appendChild(newRow) 295 | rh.getSelection().collapse(newRow, 1) 296 | return 297 | } 298 | let texts = rh.getAllTextNodesInRange() 299 | texts.forEach(text => { 300 | if (!rh.isEmptyNode(text)) { 301 | rh.textToRow(text) 302 | } 303 | }) 304 | if (texts.length) { 305 | rh.editor.saveCurrentRange() 306 | } 307 | }) 308 | 309 | // handle shortcut 310 | content.addEventListener('keydown', e => { 311 | this.execCommand('keydown', e, true) 312 | let item = this.shortcut[e.keyCode] 313 | if (item && item.length) { 314 | item.forEach(s => { 315 | if (e.keyCode === s.keyCode && e.altKey === !!s.altKey && e.ctrlKey === !!s.ctrlKey && e.metaKey === !!s.metaKey && e.shiftKey === !!s.shiftKey) { 316 | if (typeof s.handler === 'function') { 317 | this.saveCurrentRange() 318 | s.handler(this, e) 319 | } 320 | } 321 | }) 322 | } 323 | }, false) 324 | 325 | this.$nextTick(() => { 326 | this.modules.forEach((module) => { 327 | if (typeof module.mounted === 'function') { 328 | module.mounted(this) 329 | } 330 | }) 331 | }) 332 | }, 333 | updated(){ 334 | this.modules.forEach((module) => { 335 | if (typeof module.updated === 'function') { 336 | module.updated(this) 337 | } 338 | }) 339 | }, 340 | beforeDestroy(){ 341 | window.removeEventListener('touchend', this.touchHandler) 342 | this.modules.forEach((module) => { 343 | if (typeof module.destroyed === 'function') { 344 | module.destroyed(this) 345 | } 346 | }) 347 | } 348 | } 349 | -------------------------------------------------------------------------------- /src/commands/index.js: -------------------------------------------------------------------------------- 1 | import insertImage from './insertImage' 2 | import fontSize from './fontSize' 3 | import paste from './paste' 4 | import enter from './enter' 5 | import underline from './underline' 6 | import strikeThrough from './strikeThrough' 7 | import italic from './italic' 8 | import bold from './bold' 9 | import quote from './quote' 10 | import todo from './todo' 11 | import keydown from './keydown' 12 | import deleteModule from './delete' 13 | import justifyRight from './justifyRight' 14 | import justifyLeft from './justifyLeft' 15 | import justifyCenter from './justifyCenter' 16 | import {isObj} from '../util' 17 | import constant from '../constant-config' 18 | 19 | const commands = { 20 | /* 21 | * add a style attribute in range(have bug) 22 | * @param {obj} arg include 23 | * key: style name 24 | * value: style value 25 | **/ 26 | addStyle (rh, arg) { 27 | function doAdd(node) { 28 | Object.keys(arg).forEach(styleName => { 29 | node.style[styleName] = arg[styleName] 30 | }) 31 | } 32 | 33 | if (!isObj(arg)) return 34 | const textNodes = rh.getAllTextNodesInRange() 35 | if (!textNodes.length) { 36 | if (rh.range.collapsed) { 37 | let node = rh.range.commonAncestorContainer 38 | if (node.nodeType === Node.ELEMENT_NODE) { 39 | doAdd(node) 40 | return 41 | } 42 | } 43 | } 44 | if (rh.range.collapsed && textNodes.length === 1) { 45 | let node = textNodes[0].parentNode 46 | if (node) { 47 | if (node === rh.editZone()) { 48 | let newRow = rh.newRow({tag: 'p'}) 49 | newRow.innerText = textNodes[0].nodeValue 50 | node.replaceChild(newRow, textNodes[0]) 51 | doAdd(newRow) 52 | return 53 | } 54 | doAdd(node) 55 | return 56 | } 57 | } 58 | if (textNodes.length === 1 && textNodes[0] === rh.range.startContainer 59 | && textNodes[0] === rh.range.endContainer) { 60 | const textNode = textNodes[0] 61 | if (rh.range.startOffset === 0 62 | && rh.range.endOffset === textNode.textContent.length) { 63 | if (textNode.parentNode.childNodes.length === 1 64 | && rh.isInlineElement(textNode.parentNode)) { 65 | doAdd(textNode.parentNode) 66 | return 67 | } 68 | const span = document.createElement('span') 69 | doAdd(span) 70 | textNode.parentNode.insertBefore(span, textNode) 71 | span.appendChild(textNode) 72 | return 73 | } 74 | const span = document.createElement('span') 75 | span.innerText = textNode.textContent.substring( 76 | rh.range.startOffset, rh.range.endOffset) 77 | doAdd(span) 78 | const frontPart = document.createTextNode( 79 | textNode.textContent.substring(0, rh.range.startOffset)) 80 | textNode.parentNode.insertBefore(frontPart, textNode) 81 | textNode.parentNode.insertBefore(span, textNode) 82 | textNode.textContent = textNode.textContent.substring(rh.range.endOffset) 83 | rh.range.setStart(span, 0) 84 | rh.range.setEnd(span, 1) 85 | return 86 | } 87 | 88 | textNodes.forEach((textNode) => { 89 | if (textNode === rh.range.startContainer) { 90 | if (rh.range.startOffset === 0) { 91 | if (textNode.parentNode.childNodes.length === 1 92 | && rh.isInlineElement(textNode.parentNode)) { 93 | doAdd(textNode.parentNode) 94 | } else { 95 | const span = document.createElement('span') 96 | doAdd(span) 97 | textNode.parentNode.insertBefore(span, textNode) 98 | span.appendChild(textNode) 99 | } 100 | return 101 | } 102 | const span = document.createElement('span') 103 | textNode.textContent = textNode.textContent.substring( 104 | 0, rh.range.startOffset) 105 | doAdd(span) 106 | textNode.parentNode.insertBefore(span, textNode) 107 | rh.range.setStart(textNode, 0) 108 | return 109 | } 110 | if (textNode === rh.range.endContainer) { 111 | if (rh.range.endOffset === textNode.textContent.length) { 112 | if (textNode.parentNode.childNodes.length === 1 113 | && rh.isInlineElement(textNode.parentNode)) { 114 | doAdd(textNode.parentNode) 115 | } else { 116 | const span = document.createElement('span') 117 | doAdd(span) 118 | textNode.parentNode.insertBefore(span, textNode) 119 | span.appendChild(textNode) 120 | } 121 | return 122 | } 123 | const span = document.createElement('span') 124 | textNode.textContent = textNode.textContent.substring(rh.range.endOffset) 125 | doAdd(span) 126 | textNode.parentNode.insertBefore(span, textNode) 127 | span.appendChild(textNode) 128 | rh.range.setStart(textNode, textNode.textContent.length) 129 | return 130 | } 131 | if (textNode.parentNode.childNodes.length === 1 132 | && rh.isInlineElement(textNode.parentNode)) { 133 | doAdd(textNode.parentNode) 134 | return 135 | } 136 | 137 | const span = document.createElement('span') 138 | doAdd(span) 139 | textNode.parentNode.insertBefore(span, textNode) 140 | span.appendChild(textNode) 141 | }) 142 | return 143 | }, 144 | 'formatBlock' (rh, arg) { 145 | if (document.execCommand('formatBlock', false, arg)) { 146 | return 147 | } 148 | // hack 149 | const element = document.createElement(arg) 150 | rh.range.surroundContents(element) 151 | return 152 | }, 153 | 'lineHeight' (rh, arg) { 154 | const textNodes = rh.getAllTextNodesInRange() 155 | textNodes.forEach((textNode) => { 156 | const parentBlock = rh.getParentBlockNode(textNode) 157 | if (parentBlock) { 158 | parentBlock.style.lineHeight = arg 159 | } 160 | }) 161 | return 162 | }, 163 | 'insertHTML' (rh, arg) { 164 | if (document.execCommand('insertHTML', false, arg)) { 165 | return 166 | } 167 | commands['forceInsertHTML'](rh, arg) 168 | }, 169 | /* 170 | * insertHTML would insert DOM as row's child 171 | * forceInsertHTML would insert DOM as anchorNode of range 172 | **/ 173 | 'forceInsertHTML' (rh, arg) { 174 | let v = rh.newRow() 175 | let s = rh.getSelection() 176 | v.innerHTML = arg 177 | if (v.hasChildNodes()) { 178 | for (let i = 0; i < v.childNodes.length; i++) { 179 | let curNode = v.childNodes[i] 180 | rh.range.deleteContents() 181 | rh.range.insertNode(curNode) 182 | s.collapse(curNode, 1) 183 | } 184 | } 185 | return 186 | }, 187 | 'indent' (rh, arg) { 188 | let nodeList = [] 189 | if (rh.range.collapsed) { 190 | weighting(rh.range.commonAncestorContainer) 191 | } else { 192 | let texts = rh.getAllTextNodesInRange() 193 | texts.forEach(text => { 194 | weighting(text) 195 | }) 196 | } 197 | 198 | nodeList.forEach(node => { 199 | // cancel todo indent 200 | if (node.getAttribute('data-editor-todo')) { 201 | return 202 | } 203 | doIndent(node.nodeName, node) 204 | }) 205 | 206 | function weighting(text) { 207 | let node = rh.findSpecialAncestor(text, 'li') || rh.findSpecialAncestor(text, constant.ROW_TAG) 208 | if (node && !nodeList.includes(node)) { 209 | nodeList.push(node) 210 | } 211 | } 212 | 213 | function doIndent(type, node) { 214 | switch (type) { 215 | case 'LI': 216 | let curLevel = rh.howManyNestAncestorSameTag(node, 'UL') || rh.howManyNestAncestorSameTag(node, 'OL') 217 | if (curLevel >= constant.MAX_INDENT_LEVEL) break 218 | document.execCommand('indent', false, arg) 219 | break 220 | case constant.ROW_TAG_UPPERCASE: 221 | let curPercent = node.style[constant.INDENT_STYLE_NAME] || '0' 222 | curPercent = Number(curPercent.replace('%', '')) 223 | node.style[constant.INDENT_STYLE_NAME] = '' 224 | node.style[constant.OUTDENT_STYLE_NAME] = '' 225 | if (curPercent / constant.INDENT_WIDTH_PERCENT >= constant.MAX_INDENT_LEVEL) { 226 | node.style[constant.INDENT_STYLE_NAME] = curPercent + '%' 227 | return 228 | } 229 | node.style[constant.INDENT_STYLE_NAME] = curPercent + constant.INDENT_WIDTH_PERCENT + '%' 230 | } 231 | } 232 | }, 233 | 'outdent' (rh, arg) { 234 | let nodeList = [] 235 | if (rh.range.collapsed) { 236 | weighting(rh.range.commonAncestorContainer) 237 | } else { 238 | let texts = rh.getAllTextNodesInRange() 239 | texts.forEach(text => { 240 | weighting(text) 241 | }) 242 | } 243 | 244 | let outdentResult 245 | nodeList.forEach(node => { 246 | outdentResult = doOutdent(node.nodeName, node) 247 | }) 248 | return outdentResult 249 | 250 | function weighting(text) { 251 | let node = rh.findSpecialAncestor(text, 'li') || rh.findSpecialAncestor(text, constant.ROW_TAG) 252 | if (node && !nodeList.includes(node)) { 253 | nodeList.push(node) 254 | } 255 | } 256 | 257 | function doOutdent(type, node) { 258 | switch (type) { 259 | case 'LI': 260 | document.execCommand('outdent', false, arg) 261 | break 262 | case constant.ROW_TAG_UPPERCASE: 263 | let curPercent = node.style[constant.INDENT_STYLE_NAME] || '0' 264 | curPercent = Number(curPercent.replace('%', '')) 265 | if (curPercent === 0) return 'NO_NEED_OUTDENT' 266 | node.style[constant.INDENT_STYLE_NAME] = '' 267 | node.style[constant.OUTDENT_STYLE_NAME] = '' 268 | let targetIndent = curPercent - constant.INDENT_WIDTH_PERCENT 269 | if (targetIndent < 0) { 270 | node.style[constant.INDENT_STYLE_NAME] = '' 271 | } else { 272 | node.style[constant.INDENT_STYLE_NAME] = targetIndent + '%' 273 | } 274 | } 275 | } 276 | }, 277 | 'insertUnorderedList' (rh, arg) { 278 | // do not insert ul into a row 279 | document.execCommand('insertUnorderedList', false, null) 280 | let startNode = rh.getSelection().anchorNode 281 | let row = rh.getRow(startNode) 282 | let s = rh.getSelection() 283 | 284 | // startNode is edit zone 285 | if (!row) return 286 | 287 | row = rh.createWrapperForInline(row, constant.ROW_TAG) 288 | 289 | if (row) { 290 | // let ul be a row 291 | let maybeIsUl = row.firstElementChild 292 | if (maybeIsUl && maybeIsUl.nodeName === 'UL' && row.nodeName !== 'UL') { 293 | row.parentNode.replaceChild(maybeIsUl, row) 294 | row = maybeIsUl 295 | } 296 | 297 | // remove br 298 | if (row.nextSibling && row.nextSibling.nodeName === 'BR') { 299 | row.nextSibling.parentNode.removeChild(row.nextSibling) 300 | } 301 | 302 | // special treatment for ul>li, to let module inspect run 303 | // if ul and ol is bind into a module's tab, this should be change 304 | if (s.isCollapsed && !rh.editor.modulesMap['ul'].moduleInspectResult) { 305 | commands['insertHTML'](rh, '​') 306 | } 307 | return 308 | } else { 309 | let startNode = rh.getSelection().anchorNode 310 | if (startNode === rh.editZone()) { 311 | row = rh.newRow({br: true}) 312 | commands['insertHTML'](rh, row.outerHTML) 313 | } 314 | } 315 | }, 316 | 'insertOrderedList' (rh, arg) { 317 | // do not insert ul into a row 318 | document.execCommand('insertOrderedList', false, null) 319 | let s = rh.getSelection() 320 | let startNode = rh.getSelection().anchorNode 321 | let row = rh.getRow(startNode) 322 | 323 | // startNode is edit zone 324 | if (!row) return 325 | 326 | row = rh.createWrapperForInline(row, constant.ROW_TAG) 327 | 328 | if (row) { 329 | // let ul be a row 330 | let maybeIsUl = row.firstElementChild 331 | if (maybeIsUl && maybeIsUl.nodeName === 'OL' && row.nodeName !== 'OL') { 332 | row.parentNode.replaceChild(maybeIsUl, row) 333 | row = maybeIsUl 334 | } 335 | 336 | // remove br 337 | if (row.nextSibling && row.nextSibling.nodeName === 'BR') { 338 | row.nextSibling.parentNode.removeChild(row.nextSibling) 339 | } 340 | 341 | // special treatment for ul>li, to let module inspect run 342 | // if ul and ol is bind into a module's tab, this should be change 343 | if (s.isCollapsed && !rh.editor.modulesMap['ol'].moduleInspectResult) { 344 | commands['insertHTML'](rh, '​') 345 | } 346 | return 347 | } else { 348 | let startNode = rh.getSelection().anchorNode 349 | if (startNode === rh.editZone()) { 350 | row = rh.newRow({br: true}) 351 | commands['insertHTML'](rh, row.outerHTML) 352 | } 353 | } 354 | } 355 | } 356 | commands.insertImage = insertImage 357 | commands.fontSize = fontSize 358 | commands.delete = deleteModule 359 | commands.paste = paste 360 | commands.enter = enter 361 | commands.keydown = keydown 362 | commands.underline = underline 363 | commands.strikeThrough = strikeThrough 364 | commands.bold = bold 365 | commands.italic = italic 366 | commands.justifyLeft = justifyLeft 367 | commands.justifyCenter = justifyCenter 368 | commands.justifyRight = justifyRight 369 | Object.assign(commands, quote, todo) 370 | 371 | export default commands 372 | -------------------------------------------------------------------------------- /src/range-handler/handle-methods.js: -------------------------------------------------------------------------------- 1 | import am from './assist-methods' 2 | import constant from '../constant-config' 3 | 4 | const m = { 5 | /** 6 | * func add every elements of extArr to sourceArr. 7 | * @param sourceArr 8 | * @param extArr 9 | */ 10 | mergeArray (sourceArr, extArr) { 11 | // note: Array.prototype.push.apply(arr1,arr2) is unreliable 12 | extArr.forEach((el) => { 13 | sourceArr.push(el) 14 | }) 15 | }, 16 | /** 17 | * find all the descendant text nodes of a element 18 | * @param ancestor 19 | */ 20 | getDescendantTextNodes (ancestor) { 21 | if (ancestor.nodeType === Node.TEXT_NODE) { 22 | return [ancestor] 23 | } 24 | const textNodes = [] 25 | if (!ancestor.hasChildNodes()) { 26 | return textNodes 27 | } 28 | const childNodes = ancestor.childNodes 29 | for (let i = 0; i < childNodes.length; i++) { 30 | const node = childNodes[i] 31 | if (node.nodeType === Node.TEXT_NODE) { 32 | textNodes.push(node) 33 | } else if (node.nodeType === Node.ELEMENT_NODE) { 34 | m.mergeArray(textNodes, m.getDescendantTextNodes(node)) 35 | } 36 | } 37 | return textNodes 38 | }, 39 | /** 40 | * func find all the descendant text nodes of an ancestor element that before the specify end element, 41 | * the ancestor element must contains the end element. 42 | * @param ancestor 43 | * @param endEl 44 | */ 45 | getBeforeEndDescendantTextNodes (ancestor, endEl) { 46 | const textNodes = [] 47 | let endIndex = 0 48 | for (let i = 0; i < ancestor.childNodes.length; i++) { 49 | if (ancestor.childNodes[i].contains(endEl)) { 50 | endIndex = i 51 | break 52 | } 53 | } 54 | 55 | for (let i = 0; i <= endIndex; i++) { 56 | const node = ancestor.childNodes[i] 57 | if (node === endEl) { 58 | m.mergeArray(textNodes, m.getDescendantTextNodes(node)) 59 | } else if (i === endIndex) { 60 | if (node.nodeType === Node.TEXT_NODE) { 61 | textNodes.push(node) 62 | } else if (node.nodeType === Node.ELEMENT_NODE) { 63 | m.mergeArray(textNodes, m.getBeforeEndDescendantTextNodes(node, endEl)) 64 | } 65 | } else if (node.nodeType === Node.TEXT_NODE) { 66 | textNodes.push(node) 67 | } else if (node.nodeType === Node.ELEMENT_NODE) { 68 | m.mergeArray(textNodes, m.getDescendantTextNodes(node)) 69 | } 70 | } 71 | return textNodes 72 | }, 73 | /** 74 | * func find all the descendant text nodes of an ancestor element that after the specify start element, 75 | * the ancestor element must contains the start element. 76 | * @param ancestor 77 | * @param startEl 78 | */ 79 | getAfterStartDescendantTextNodes (ancestor, startEl) { 80 | const textNodes = [] 81 | let startIndex = 0 82 | for (let i = 0; i < ancestor.childNodes.length; i++) { 83 | if (ancestor.childNodes[i].contains(startEl)) { 84 | startIndex = i 85 | break 86 | } 87 | } 88 | 89 | for (let i = startIndex; i < ancestor.childNodes.length; i++) { 90 | const node = ancestor.childNodes[i] 91 | if (node === startEl) { 92 | m.mergeArray(textNodes, m.getDescendantTextNodes(node)) 93 | } else if (i === startIndex) { 94 | if (node.nodeType === Node.TEXT_NODE) { 95 | textNodes.push(node) 96 | } else if (node.nodeType === Node.ELEMENT_NODE) { 97 | m.mergeArray(textNodes, 98 | m.getAfterStartDescendantTextNodes(node, startEl)) 99 | } 100 | } else if (node.nodeType === Node.TEXT_NODE) { 101 | textNodes.push(node) 102 | } else if (node.nodeType === Node.ELEMENT_NODE) { 103 | m.mergeArray(textNodes, 104 | m.getDescendantTextNodes(node)) 105 | } 106 | } 107 | return textNodes 108 | }, 109 | /** 110 | * func get the closest parent block node of a text node. 111 | * @param node 112 | * @return {Node} 113 | */ 114 | getParentBlockNode (node) { 115 | const blockNodeNames = ['DIV', 'P', 'SECTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 116 | 'OL', 'UL', 'LI', 'TR', 'TD', 'TH', 'TBODY', 'THEAD', 'TABLE', 'ARTICLE', 'HEADER', 'FOOTER', 'BLOCKQUOTE'] 117 | let container = node 118 | while (container) { 119 | if (blockNodeNames.includes(container.nodeName)) { 120 | break 121 | } 122 | container = container.parentNode 123 | } 124 | return container 125 | }, 126 | isInlineElement (node) { 127 | const inlineNodeNames = ['A', 'ABBR', 'ACRONYM', 'B', 'CITE', 'CODE', 'EM', 'I', 128 | 'FONT', 'IMG', 'S', 'SMALL', 'SPAN', 'STRIKE', 'STRONG', 'U', 'SUB', 'SUP'] 129 | return inlineNodeNames.includes(node.nodeName) 130 | }, 131 | isInlineOrText (node) { 132 | let isInline = m.isInlineElement(node) 133 | let isText = node.nodeType === Node.TEXT_NODE 134 | return isInline || isText 135 | }, 136 | /* 137 | * find all specify nodes in an ancestor through search opinions(unique attributes) 138 | * @param node 139 | * @param {obj} 140 | * must have key 'tagName' 141 | * @return {arr} 142 | **/ 143 | getAllSpecifyNode (ancestor, searchOpinion) { 144 | const targetTagName = searchOpinion.tagName 145 | delete searchOpinion.tagName 146 | const tags = ancestor.querySelectorAll(targetTagName) 147 | const result = [] 148 | tags.forEach(tag => { 149 | const opinionKeys = Object.keys(searchOpinion) 150 | let pass = true 151 | opinionKeys.forEach(opinion => { 152 | var a = tag.getAttribute(opinion) 153 | if (tag.getAttribute(opinion) !== searchOpinion[opinion]) { 154 | pass = false 155 | } 156 | }) 157 | if (pass) { 158 | result.push(tag) 159 | } 160 | }) 161 | return result 162 | }, 163 | /* 164 | * func find the number of nesting ancestor which has same node name 165 | * @param {node} current node 166 | * @param {str} ancestor's tag name 167 | * @return {num} number 168 | **/ 169 | howManyNestAncestorSameTag (node, ancestorNodeName) { 170 | let num = 0 171 | ancestorNodeName = ancestorNodeName.toUpperCase() 172 | while (node && (node !== am.editZone())) { 173 | if (node.nodeName === ancestorNodeName) { 174 | num++ 175 | } 176 | node = node.parentNode 177 | } 178 | return num 179 | }, 180 | 181 | /* 182 | * find an ancestor element through selector 183 | * @param {node} start at node 184 | * @param {str} ancestor element's selector 185 | * @param {boolean} either return first eligible element or last eligible element 186 | * default: true 187 | * @param {node} searching stop at the border element 188 | * default: editor's content zone 189 | * @return target ancestor element 190 | **/ 191 | findSpecialAncestor (node, selector, firstOne = true, border) { 192 | let result 193 | let contentZone = am.editZone() 194 | border = border || contentZone 195 | while (node && (firstOne ? !result : true) && (node !== border)) { 196 | if (!border || !border.contains(node)) return 197 | let ancestors = Array.from(node.parentNode.querySelectorAll(selector)) 198 | if (ancestors.length) { 199 | if (ancestors.includes(node)) { 200 | result = node 201 | } 202 | node = node.parentNode 203 | } else { 204 | node = node.parentNode 205 | } 206 | } 207 | return result 208 | }, 209 | /* 210 | * find target style 211 | **/ 212 | findSpecialAncestorStyle (node, styleName, firstOne = true, border) { 213 | let result 214 | let contentZone = am.editZone() 215 | border = border || contentZone 216 | while (node && (firstOne ? !result : true) && (node !== border)) { 217 | if (!border || !border.contains(node)) return 218 | if (node.style && node.style[styleName]) { 219 | result = node.style[styleName] 220 | } 221 | node = node.parentNode 222 | } 223 | return result 224 | }, 225 | /* 226 | * find an ancestor element through style name and style value 227 | * @param style {obj} styleName: styleValue 228 | * @return 229 | **/ 230 | findSpecialAncestorByStyle (node, style, firstOne = true, border) { 231 | let result 232 | let contentZone = am.editZone() 233 | border = border || contentZone 234 | while (node && (firstOne ? !result : true) && (node !== border)) { 235 | if (!border || !border.contains(node)) return 236 | let parent = node.parentNode 237 | let isTarget = true 238 | Object.keys(style).forEach(styleName => { 239 | if (style[styleName] !== parent.style[styleName]) { 240 | isTarget = false 241 | } 242 | }) 243 | if (isTarget) { 244 | result = parent 245 | node = parent 246 | } else { 247 | node = parent 248 | } 249 | } 250 | return result 251 | }, 252 | 253 | getNodeNum (ancestor, nodeName) { 254 | return ancestor.querySelectorAll(nodeName).length 255 | }, 256 | /** 257 | * find all the text nodes in range 258 | */ 259 | getAllTextNodesInRange() { 260 | const startContainer = this.range.startContainer 261 | const endContainer = this.range.endContainer 262 | const rootEl = this.range.commonAncestorContainer 263 | const textNodes = [] 264 | 265 | if (startContainer === endContainer) { 266 | if (startContainer.nodeType === Node.TEXT_NODE) { 267 | return [startContainer] 268 | } 269 | const childNodes = startContainer.childNodes 270 | for (let i = this.range.startOffset; i < this.range.endOffset; i++) { 271 | m.mergeArray(textNodes, m.getDescendantTextNodes(childNodes[i])) 272 | } 273 | return textNodes 274 | } 275 | 276 | let startIndex = 0 277 | let endIndex = 0 278 | for (let i = 0; i < rootEl.childNodes.length; i++) { 279 | const node = rootEl.childNodes[i] 280 | if (node.contains(startContainer)) { 281 | startIndex = i 282 | } 283 | if (node.contains(endContainer)) { 284 | endIndex = i 285 | } 286 | } 287 | 288 | for (let i = startIndex; i <= endIndex; i++) { 289 | const node = rootEl.childNodes[i] 290 | if (i === startIndex) { 291 | if (node.nodeType === Node.TEXT_NODE) { 292 | textNodes.push(node) 293 | } else if (node.nodeType === Node.ELEMENT_NODE) { 294 | m.mergeArray(textNodes, m.getAfterStartDescendantTextNodes(node, startContainer)) 295 | } 296 | } else if (i === endIndex) { 297 | if (node.nodeType === Node.TEXT_NODE) { 298 | textNodes.push(node) 299 | } else if (node.nodeType === Node.ELEMENT_NODE) { 300 | m.mergeArray(textNodes, m.getBeforeEndDescendantTextNodes(node, endContainer)) 301 | } 302 | } else if (node.nodeType === Node.TEXT_NODE) { 303 | textNodes.push(node) 304 | } else if (node.nodeType === Node.ELEMENT_NODE) { 305 | m.mergeArray(textNodes, m.getDescendantTextNodes(node)) 306 | } 307 | } 308 | return textNodes 309 | }, 310 | /* 311 | * get the row which contains target element 312 | * @param {node} target element 313 | * @return {node} row 314 | **/ 315 | getRow (node) { 316 | let rows = Array.from(am.editZone().children) 317 | let result 318 | rows.forEach(row => { 319 | if (row.contains(node) || row === node) { 320 | result = row 321 | } 322 | }) 323 | return result 324 | }, 325 | textToRow (node) { 326 | if (node.parentNode === am.editZone() && node.nodeType === Node.TEXT_NODE) { 327 | document.execCommand('formatBlock', false, constant.ROW_TAG_UPPERCASE) 328 | } 329 | return node 330 | }, 331 | /* 332 | * get row, if there's not, create one 333 | **/ 334 | forceGetRow (node) { 335 | node = m.textToRow(node) 336 | return m.getRow(node) 337 | }, 338 | /* 339 | * return all rows 340 | **/ 341 | getRows () { 342 | return Array.from(am.editZone().children) 343 | }, 344 | /* 345 | * whether current node is a row 346 | **/ 347 | isRow (node) { 348 | let rows = Array.from(am.editZone().children) 349 | return rows.includes(node) 350 | }, 351 | /* 352 | * create a wrapper for inline element in same row 353 | **/ 354 | createWrapperForInline (node, wrapperNodeName, seperateByBr = true) { 355 | if (!m.isInlineOrText(node)) return node 356 | let elements = [node] 357 | searchLeft() 358 | searchRight() 359 | let newRow = document.createElement(wrapperNodeName) 360 | elements.forEach((ele, index) => { 361 | if (index !== elements.length - 1) { 362 | newRow.appendChild(ele) 363 | return 364 | } 365 | let lastOne = ele.cloneNode(true) 366 | newRow.appendChild(lastOne) 367 | ele.parentNode.replaceChild(newRow, ele) 368 | }) 369 | 370 | if (seperateByBr) { 371 | handlerBr(newRow.previousSibling, true) 372 | handlerBr(newRow.nextSibling, false) 373 | } 374 | return newRow 375 | 376 | function handlerBr(node, direction) { 377 | if (node && node.nodeName === 'BR') { 378 | let nextDir = direction ? 'previousSibling' : 'nextSibling' 379 | let targetNode = node[nextDir] 380 | if (!targetNode) return 381 | if (targetNode.nodeName === 'BR') { 382 | return handlerBr(targetNode, direction) 383 | } 384 | m.createWrapperForInline(targetNode, wrapperNodeName, seperateByBr) 385 | } 386 | } 387 | function searchLeft() { 388 | while (elements[0].previousSibling && m.isInlineOrText(elements[0].previousSibling)) { 389 | elements.unshift(elements[0].previousSibling) 390 | } 391 | } 392 | function searchRight() { 393 | while (elements[elements.length - 1].nextSibling && m.isInlineOrText(elements[elements.length - 1].nextSibling)) { 394 | elements.push(elements[elements.length - 1].nextSibling) 395 | } 396 | } 397 | }, 398 | /* 399 | * get node's previous row which has content 400 | **/ 401 | getPreviousRow (node) { 402 | let row = m.getRow(node) 403 | let preRow 404 | let rows = m.getRows() 405 | let rowIndex = null 406 | rows.forEach((curRow, index) => { 407 | if (curRow === row) { 408 | rowIndex = index 409 | } 410 | if (rowIndex === null) { 411 | if (curRow.innerHTML !== '') { 412 | preRow = curRow 413 | } 414 | } 415 | }) 416 | return preRow 417 | }, 418 | /* 419 | * whether target row is empty 420 | **/ 421 | isEmptyRow (node) { 422 | let row = m.isRow(node) ? node : m.getRow(node) 423 | if (row.getAttribute) { 424 | if (typeof row.getAttribute('data-editor-todo') === 'string' || typeof row.getAttribute('data-editor-quote') === 'string') { 425 | return false 426 | } 427 | } 428 | return row.innerText.replace('\n', '').replace(/\u200B/g, '') === '' 429 | }, 430 | /* 431 | * whether target node is empty 432 | **/ 433 | isEmptyNode (node) { 434 | let ctn = typeof node.innerText === 'string' ? node.innerText : node.nodeValue 435 | if (typeof ctn !== 'string') return 436 | return ctn.replace('\n', '').replace(/\u200B/g, '') === '' 437 | }, 438 | /* 439 | * try to collapse at target row 440 | **/ 441 | collapseAtRow (node) { 442 | let row = m.isRow(node) ? node : m.getRow(node) 443 | let s = am.getSelection() 444 | try { 445 | s.collapse(row, 1) 446 | } catch (e) { 447 | s.collapse(row, 0) 448 | } 449 | }, 450 | /* 451 | * searching nested ancestors till border to find the specified tagName 452 | * @param {node} from which node 453 | * @param {arr} includes tag names of target tag 454 | * @param {node} search border 455 | * @return {arr} 456 | **/ 457 | findExistTagTillBorder (node, tagNamelist, border) { 458 | let result = [] 459 | let contentZone = am.editZone() 460 | border = border || contentZone 461 | while (node && node !== border) { 462 | if (!border || !border.contains(node)) return 463 | let nodeName = node.nodeName 464 | if (nodeName && tagNamelist.includes(nodeName)) { 465 | if (!result.includes(nodeName)) { 466 | result.push(nodeName) 467 | } 468 | } 469 | node = node.parentNode 470 | } 471 | return result 472 | }, 473 | /* 474 | * return a nested DOM data through a tag name list 475 | **/ 476 | createNestDOMThroughList (list) { 477 | let result = { 478 | dom: null, 479 | deepestId: null, 480 | deepest: null 481 | } 482 | list.forEach((tag, index) => { 483 | let ele = document.createElement(tag) 484 | result[index] = ele 485 | let parent = result[index - 1] 486 | if (parent) { 487 | parent.appendChild(ele) 488 | } 489 | if (index === list.length - 1) { 490 | result.deepest = ele 491 | result.deepestId = am.createRandomId('deepest') 492 | result.dom = result['0'] 493 | ele.id = result.deepestId 494 | ele.innerHTML = '​' 495 | } 496 | }) 497 | return result 498 | } 499 | } 500 | 501 | export default m 502 | --------------------------------------------------------------------------------