marktext/src/muya/lib/index.js
2018-09-22 13:01:21 +08:00

670 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import ContentState from './contentState'
import selection from './selection'
import EventCenter from './event'
import { LOWERCASE_TAGS, EVENT_KEYS, CLASS_OR_ID, codeMirrorConfig } from './config'
import { throttle, wordCount } from './utils'
import { search } from './codeMirror'
import { checkEditLanguage } from './codeMirror/language'
import Emoji, { checkEditEmoji, setInlineEmoji } from './emojis'
import FloatBox from './floatBox'
import { findNearestParagraph, operateClassName, isInElement } from './utils/domManipulate'
import ExportMarkdown from './utils/exportMarkdown'
import ExportHtml from './utils/exportHtml'
import { checkEditImage } from './utils/checkEditImage'
import TablePicker from './tablePicker'
import './assets/symbolIcon' // import symbol icons
import './assets/symbolIcon/index.css'
import './assets/styles/index.css'
class Muya {
constructor (container, options) {
const {
focusMode = false, theme = 'light', markdown = '', preferLooseListItem = true,
autoPairBracket = true, autoPairMarkdownSyntax = true, autoPairQuote = true,
bulletListMarker = '-', tabSize = 4
} = options
this.container = container
const eventCenter = this.eventCenter = new EventCenter()
const floatBox = this.floatBox = new FloatBox(eventCenter)
const tablePicker = this.tablePicker = new TablePicker(eventCenter)
this.contentState = new ContentState({
eventCenter,
floatBox,
tablePicker,
preferLooseListItem,
autoPairBracket,
autoPairMarkdownSyntax,
autoPairQuote,
bulletListMarker,
tabSize
})
this.emoji = new Emoji() // emoji instance: has search(text) clear() methods.
this.focusMode = focusMode
this.theme = theme
this.markdown = markdown
this.fontSize = 16
this.lineHeight = 1.6
this.preferLooseListItem = preferLooseListItem
// private property
this._isEditChinese = false // true or false
this._copyType = 'normal' // `normal` or `copyAsMarkdown` or `copyAsHtml`
this._pasteType = 'normal' // `normal` or `pasteAsPlainText`
this.init()
}
init () {
this.ensureContainerDiv()
const { container, contentState, eventCenter } = this
contentState.stateRender.setContainer(container.children[0])
eventCenter.subscribe('editEmoji', throttle(this.subscribeEditEmoji.bind(this), 200))
this.dispatchEditEmoji()
eventCenter.subscribe('editLanguage', throttle(this.subscribeEditLanguage.bind(this)))
this.dispatchEditLanguage()
eventCenter.subscribe('hideFloatBox', this.subscribeHideFloatBox.bind(this))
this.dispatchHideFloatBox()
eventCenter.subscribe('stateChange', this.dispatchChange.bind(this))
eventCenter.attachDOMEvent(container, 'paste', event => {
contentState.pasteHandler(event, this._pasteType)
this._pasteType = 'normal'
})
eventCenter.attachDOMEvent(container, 'contextmenu', event => {
event.preventDefault()
event.stopPropagation()
const sectionChanges = this.contentState.selectionChange(undefined, undefined, this.contentState.cursor)
eventCenter.dispatch('contextmenu', event, sectionChanges)
})
this.recordEditChinese()
this.imageClick()
this.listItemCheckBoxClick()
this.dispatchArrow()
this.dispatchBackspace()
this.dispatchEnter()
this.dispatchSelection()
this.dispatchUpdateState()
this.dispatchCopyCut()
this.dispatchTableToolBar()
this.dispatchCodeBlockClick()
this.htmlPreviewClick()
this.mathPreviewClick()
contentState.listenForPathChange()
const { theme, focusMode, markdown } = this
this.setTheme(theme)
this.setMarkdown(markdown)
this.setFocusMode(focusMode)
}
/**
* [ensureContainerDiv ensure container element is div]
*/
ensureContainerDiv () {
const { container } = this
const div = document.createElement(LOWERCASE_TAGS.div)
const rootDom = document.createElement(LOWERCASE_TAGS.div)
const attrs = container.attributes
const parentNode = container.parentNode
// copy attrs from origin container to new div element
Array.from(attrs).forEach(attr => {
div.setAttribute(attr.name, attr.value)
})
div.setAttribute('contenteditable', true)
div.classList.add('mousetrap')
div.appendChild(rootDom)
parentNode.insertBefore(div, container)
parentNode.removeChild(container)
this.container = div
}
dispatchChange () {
const { eventCenter } = this
const markdown = this.markdown = this.getMarkdown()
const wordCount = this.getWordCount(markdown)
const cursor = this.getCursor()
const history = this.getHistory()
eventCenter.dispatch('change', { markdown, wordCount, cursor, history })
}
dispatchCopyCut () {
const { container, eventCenter, contentState } = this
const handler = event => {
contentState.copyHandler(event, this._copyType)
if (event.type === 'cut') {
// when user use `cut` function, the dom has been deleted by default.
// But should update content state manually.
contentState.cutHandler()
}
this._copyType = 'normal'
}
eventCenter.attachDOMEvent(container, 'cut', handler)
eventCenter.attachDOMEvent(container, 'copy', handler)
}
/**
* dispatchEditEmoji
*/
dispatchEditEmoji () {
const { container, eventCenter } = this
const changeHandler = event => {
const node = selection.getSelectionStart()
const emojiNode = checkEditEmoji(node)
if (emojiNode && event.key !== EVENT_KEYS.Enter) {
eventCenter.dispatch('editEmoji', emojiNode)
}
}
eventCenter.attachDOMEvent(container, 'keyup', changeHandler) // don't listen `input` event
}
subscribeEditEmoji (emojiNode) {
const text = emojiNode.textContent.trim()
if (text) {
const list = this.emoji.search(text).map(l => {
return Object.assign(l, { text: l.aliases[0] })
})
const cb = item => {
setInlineEmoji(emojiNode, item, selection)
this.floatBox.hideIfNeeded()
}
if (list.length) {
this.floatBox.showIfNeeded(emojiNode, cb)
this.floatBox.setOptions(list)
} else {
this.floatBox.hideIfNeeded()
}
}
}
dispatchHideFloatBox () {
const { container, eventCenter } = this
let cacheTop = null
const handler = event => {
if (event.type === 'scroll') {
const scrollTop = container.scrollTop
if (cacheTop && Math.abs(scrollTop - cacheTop) > 10) {
cacheTop = null
return eventCenter.dispatch('hideFloatBox')
} else {
cacheTop = scrollTop
return
}
}
if (event.target && event.target.classList.contains(CLASS_OR_ID['AG_LANGUAGE_INPUT'])) {
return
}
if (event.type === 'click') return eventCenter.dispatch('hideFloatBox')
const node = selection.getSelectionStart()
const paragraph = findNearestParagraph(node)
const selectionState = selection.exportSelection(paragraph)
const lang = checkEditLanguage(paragraph, selectionState)
const emojiNode = node && checkEditEmoji(node)
const editImage = checkEditImage()
if (!emojiNode && !lang && !editImage) {
eventCenter.dispatch('hideFloatBox')
}
}
eventCenter.attachDOMEvent(container, 'click', handler)
eventCenter.attachDOMEvent(container, 'keyup', handler)
eventCenter.attachDOMEvent(container, 'scroll', throttle(handler, 200))
}
subscribeHideFloatBox () {
this.floatBox.hideIfNeeded()
}
/**
* dispatchIsEditLanguage
*/
dispatchEditLanguage () {
const { container, eventCenter } = this
const inputHandler = event => {
const node = selection.getSelectionStart()
const paragraph = findNearestParagraph(node)
const selectionState = selection.exportSelection(paragraph)
const lang = checkEditLanguage(paragraph, selectionState)
if (lang) {
eventCenter.dispatch('editLanguage', paragraph, lang)
}
}
eventCenter.attachDOMEvent(container, 'input', inputHandler)
}
subscribeEditLanguage (paragraph, lang, cb) {
const modes = search(lang).map(mode => {
return Object.assign(mode, { text: mode.name })
})
const callback = item => {
this.contentState.selectLanguage(paragraph, item.name)
}
if (modes.length) {
this.floatBox.showIfNeeded(paragraph, cb || callback)
this.floatBox.setOptions(modes)
} else {
this.floatBox.hideIfNeeded()
}
}
dispatchBackspace () {
const { container, eventCenter } = this
const handler = event => {
if (event.key === EVENT_KEYS.Backspace) {
this.contentState.backspaceHandler(event)
} else if (event.key === EVENT_KEYS.Delete) {
this.contentState.deleteHandler(event)
}
}
eventCenter.attachDOMEvent(container, 'keydown', handler)
}
recordEditChinese () {
const { container, eventCenter } = this
const handler = event => {
if (event.type === 'compositionstart') {
this._isEditChinese = true
} else if (event.type === 'compositionend') {
this._isEditChinese = false
}
}
eventCenter.attachDOMEvent(container, 'compositionend', handler)
eventCenter.attachDOMEvent(container, 'compositionstart', handler)
}
dispatchEnter (event) {
const { container, eventCenter } = this
const handler = event => {
if (event.key === EVENT_KEYS.Enter && !this._isEditChinese) {
this.contentState.enterHandler(event)
}
}
eventCenter.attachDOMEvent(container, 'keydown', handler)
}
dispatchSelection (event) {
const { container, eventCenter } = this
const handler = event => {
if (event.ctrlKey && event.key === 'a') {
this.contentState.tableCellHandler(event)
}
}
eventCenter.attachDOMEvent(container, 'keydown', handler)
}
// dispatch arrow event
dispatchArrow () {
const { container, eventCenter } = this
const handler = event => {
if (this._isEditChinese) return
switch (event.key) {
case EVENT_KEYS.ArrowUp: // fallthrough
case EVENT_KEYS.ArrowDown: // fallthrough
case EVENT_KEYS.ArrowLeft: // fallthrough
case EVENT_KEYS.ArrowRight: // fallthrough
this.contentState.arrowHandler(event)
break
case EVENT_KEYS.Tab:
this.contentState.tabHandler(event)
break
}
}
eventCenter.attachDOMEvent(container, 'keydown', handler)
}
dispatchCodeBlockClick () {
const { container, eventCenter } = this
const handler = event => {
const target = event.target
if (target.tagName === 'PRE' && target.classList.contains(CLASS_OR_ID['AG_CODE_BLOCK'])) {
this.contentState.focusCodeBlock(event)
}
}
eventCenter.attachDOMEvent(container, 'click', handler)
}
dispatchTableToolBar () {
const { container, eventCenter } = this
const getToolItem = target => {
// poor implement fix me @jocs
const parent = target.parentNode
const grandPa = parent && parent.parentNode
if (target.hasAttribute('data-label')) return target
if (parent && parent.hasAttribute('data-label')) return parent
if (grandPa && grandPa.hasAttribute('data-label')) return grandPa
return null
}
const handler = event => {
const target = event.target
const toolItem = getToolItem(target)
if (toolItem) {
event.preventDefault()
event.stopPropagation()
const type = toolItem.getAttribute('data-label')
const grandPa = toolItem.parentNode.parentNode
if (grandPa.classList.contains('ag-tool-table')) {
this.contentState.tableToolBarClick(type)
} else if (grandPa.classList.contains('ag-tool-html')) {
this.contentState.htmlToolBarClick(type)
}
}
}
eventCenter.attachDOMEvent(container, 'click', handler)
}
dispatchUpdateState () {
const { container, eventCenter } = this
let timer = null
const changeHandler = event => {
const target = event.target
if (event.type === 'click' && target.classList.contains(CLASS_OR_ID['AG_FUNCTION_HTML'])) return
if (!this._isEditChinese) {
this.contentState.updateState(event)
}
if (event.type === 'click' || event.type === 'keyup') {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
const selectionChanges = this.getSelection()
const { formats } = this.contentState.selectionFormats()
eventCenter.dispatch('selectionChange', selectionChanges)
eventCenter.dispatch('selectionFormats', formats)
this.dispatchChange()
})
}
}
eventCenter.attachDOMEvent(container, 'click', changeHandler)
eventCenter.attachDOMEvent(container, 'keyup', changeHandler)
eventCenter.attachDOMEvent(container, 'input', changeHandler)
}
imageClick () {
const { container, eventCenter } = this
const selectionText = node => {
const textLen = node.textContent.length
operateClassName(node, 'remove', CLASS_OR_ID['AG_HIDE'])
operateClassName(node, 'add', CLASS_OR_ID['AG_GRAY'])
selection.importSelection({
start: textLen,
end: textLen
}, node)
}
const handler = event => {
const target = event.target
const markedImageText = target.previousElementSibling
const mathRender = isInElement(target, CLASS_OR_ID['AG_MATH_RENDER'])
const mathText = mathRender && mathRender.previousElementSibling
if (markedImageText && markedImageText.classList.contains(CLASS_OR_ID['AG_IMAGE_MARKED_TEXT'])) {
selectionText(markedImageText)
} else if (mathText) {
selectionText(mathText)
}
}
eventCenter.attachDOMEvent(container, 'click', handler)
}
htmlPreviewClick () {
const { eventCenter, container } = this
const handler = event => {
const target = event.target
const htmlPreview = isInElement(target, 'ag-function-html')
if (htmlPreview && !htmlPreview.classList.contains(CLASS_OR_ID['AG_ACTIVE'])) {
event.preventDefault()
event.stopPropagation()
this.contentState.handleHtmlBlockClick(htmlPreview)
}
}
eventCenter.attachDOMEvent(container, 'click', handler)
}
mathPreviewClick () {
const { eventCenter, container } = this
const handler = event => {
const target = event.target
const mathFigure = isInElement(target, 'ag-multiple-math-block')
if (mathFigure && !mathFigure.classList.contains(CLASS_OR_ID['AG_ACTIVE'])) {
event.preventDefault()
event.stopPropagation()
this.contentState.handleMathBlockClick(mathFigure)
}
}
eventCenter.attachDOMEvent(container, 'click', handler)
}
listItemCheckBoxClick () {
const { container, eventCenter } = this
const handler = event => {
const target = event.target
if (target.tagName === 'INPUT' && target.classList.contains(CLASS_OR_ID['AG_TASK_LIST_ITEM_CHECKBOX'])) {
this.contentState.listItemCheckBoxClick(target)
}
}
eventCenter.attachDOMEvent(container, 'click', handler)
}
getMarkdown () {
const blocks = this.contentState.getBlocks()
return new ExportMarkdown(blocks).generate()
}
getHistory () {
return this.contentState.getHistory()
}
setHistory (history) {
return this.contentState.setHistory(history)
}
exportStyledHTML (filename) {
const { markdown } = this
return new ExportHtml(markdown).generate(filename)
}
exportHtml () {
const { markdown } = this
return new ExportHtml(markdown).renderHtml()
}
getWordCount (markdown) {
return wordCount(markdown)
}
getCursor () {
return this.contentState.getCodeMirrorCursor()
}
clearHistory () {
this.contentState.history.clearHistory()
}
setMarkdown (markdown, cursor, isRenderCursor = true) {
let newMarkdown = markdown
if (cursor) {
newMarkdown = this.contentState.addCursorToMarkdown(markdown, cursor)
}
this.contentState.importMarkdown(newMarkdown)
this.contentState.importCursor(cursor)
this.contentState.render(isRenderCursor)
this.dispatchChange()
}
createTable (tableChecker) {
const { eventCenter } = this
this.contentState.createFigure(tableChecker)
const selectionChanges = this.getSelection()
eventCenter.dispatch('selectionChange', selectionChanges)
}
getSelection () {
const { fontSize, lineHeight } = this
return this.contentState.selectionChange(fontSize, lineHeight)
}
setFocusMode (bool) {
const { container, focusMode } = this
if (bool && !focusMode) {
container.classList.add(CLASS_OR_ID['AG_FOCUS_MODE'])
} else {
container.classList.remove(CLASS_OR_ID['AG_FOCUS_MODE'])
}
this.focusMode = bool
}
setTheme (name) {
if (!name) return
if (name === 'dark') {
codeMirrorConfig.theme = 'railscasts'
} else {
delete codeMirrorConfig.theme
}
this.theme = name
// Render cursor and refresh code block
this.contentState.render(true, true)
}
setFont ({ fontSize, lineHeight }) {
if (fontSize) this.fontSize = parseInt(fontSize, 10)
if (lineHeight) this.lineHeight = lineHeight
}
setListItemPreference (preferLooseListItem) {
this.preferLooseListItem = preferLooseListItem
this.contentState.preferLooseListItem = preferLooseListItem
}
setTabSize (tabSize) {
if (!tabSize || typeof tabSize !== 'number') {
tabSize = 4
} else if (tabSize < 1) {
tabSize = 1
}
this.tabSize = tabSize
this.contentState.tabSize = tabSize
}
updateParagraph (type) {
this.contentState.updateParagraph(type)
}
insertParagraph (location) {
this.contentState.insertParagraph(location)
}
editTable (data) {
this.contentState.editTable(data)
}
blur () {
this.container.blur()
}
showAutoImagePath (files) {
const list = files.map(f => {
const iconClass = f.type === 'directory' ? 'icon-folder' : 'icon-image'
return Object.assign(f, { iconClass, text: f.file + (f.type === 'directory' ? '/' : '') })
})
this.contentState.showAutoImagePath(list)
}
format (type) {
this.contentState.format(type)
}
insertImage (url) {
this.contentState.insertImage(url)
}
search (value, opt) {
const { selectHighlight } = opt
this.contentState.search(value, opt)
this.contentState.render(!!selectHighlight)
return this.contentState.searchMatches
}
replace (value, opt) {
this.contentState.replace(value, opt)
this.contentState.render(false)
return this.contentState.searchMatches
}
find (action/* pre or next */) {
this.contentState.find(action)
this.contentState.render(false)
return this.contentState.searchMatches
}
on (event, listener) {
this.eventCenter.subscribe(event, listener)
}
undo () {
this.contentState.history.undo()
}
redo () {
this.contentState.history.redo()
}
copyAsMarkdown () {
this._copyType = 'copyAsMarkdown'
document.execCommand('copy')
}
copyAsHtml () {
this._copyType = 'copyAsHtml'
document.execCommand('copy')
}
pasteAsPlainText () {
this._pasteType = 'pasteAsPlainText'
document.execCommand('paste')
}
copy (name) {
switch (name) {
case 'table':
this._copyType = 'copyTable'
document.execCommand('copy')
break
default:
break
}
}
destroy () {
this.emoji.clear() // clear emoji cache for memory recycle
this.contentState.clear()
this.floatBox.destroy()
this.tablePicker.destroy()
this.container = null
this.contentState = null
this.emoji = null
this.floatBox = null
this.tablePicker = null
this.eventCenter.detachAllDomEvents()
this.eventCenter = null
}
}
export default Muya