marktext/src/muya/lib/contentState/updateCtrl.js
2018-10-06 14:15:08 +08:00

475 lines
16 KiB
JavaScript

import selection from '../selection'
import { tokenizer } from '../parser/parse'
import { conflict, getPositionReference } from '../utils'
import { getTextContent } from '../utils/domManipulate'
import { CLASS_OR_ID, EVENT_KEYS, DEFAULT_TURNDOWN_CONFIG } from '../config'
import { beginRules } from '../parser/rules'
const INLINE_UPDATE_FRAGMENTS = [
'^([*+-]\\s)', // Bullet list
'^(\\[[x\\s]{1}\\]\\s)', // Task list
'^(\\d+\\.\\s)', // Order list
'^\\s{0,3}(#{1,6})(?=\\s{1,}|$)', // ATX heading
'^(>).+', // Block quote
'^\\s{0,3}((?:\\*\\s*\\*\\s*\\*|-\\s*-\\s*-|_\\s*_\\s*_)[\\s\\*\\-\\_]*)$' // Thematic break
]
const INLINE_UPDATE_REG = new RegExp(INLINE_UPDATE_FRAGMENTS.join('|'), 'i')
let lastCursor = null
const updateCtrl = ContentState => {
// handle task list item checkbox click
ContentState.prototype.listItemCheckBoxClick = function (checkbox) {
const { checked, id } = checkbox
const block = this.getBlock(id)
block.checked = checked
checkbox.classList.toggle(CLASS_OR_ID['AG_CHECKBOX_CHECKED'])
// this.render()
}
ContentState.prototype.checkSameLooseType = function (list, isLooseType) {
return list.children[0].isLooseListItem === isLooseType
}
ContentState.prototype.checkNeedRender = function (block) {
const { start: cStart, end: cEnd } = this.cursor
const startOffset = cStart.offset
const endOffset = cEnd.offset
const tokens = tokenizer(block.text)
const textLen = block.text.length
for (const token of tokens) {
if (token.type === 'text') continue
const { start, end } = token.range
if (
conflict([Math.max(0, start - 1), Math.min(textLen, end + 1)], [startOffset, startOffset]) ||
conflict([Math.max(0, start - 1), Math.min(textLen, end + 1)], [endOffset, endOffset])
) {
return true
}
}
return false
}
ContentState.prototype.checkInlineUpdate = function (block) {
// table cell can not have blocks in it
if (/th|td|figure/.test(block.type)) return false
// only first line block can update to other block
if (block.type === 'span' && block.preSibling) return false
if (block.type === 'span') {
block = this.getParent(block)
}
const parent = this.getParent(block)
const text = block.type === 'p' ? block.children.map(child => child.text).join('\n') : block.text
const [match, bullet, tasklist, order, header, blockquote, hr] = text.match(INLINE_UPDATE_REG) || []
switch (true) {
case (hr && new Set(hr.split('').filter(i => /\S/.test(i))).size === 1):
return this.updateHr(block, hr)
case !!bullet:
return this.updateList(block, 'bullet', bullet)
// only `bullet` list item can be update to `task` list item
case !!tasklist && parent && parent.listItemType === 'bullet':
return this.updateTaskListItem(block, 'tasklist', tasklist)
case !!order:
return this.updateList(block, 'order', order)
case !!header:
return this.updateHeader(block, header, text)
case !!blockquote:
return this.updateBlockQuote(block)
case !match:
default:
return this.updateToParagraph(block)
}
}
// Input @ to quick insert paragraph
ContentState.prototype.checkQuickInsert = function (block) {
const { type, text } = block
if (type !== 'span') return false
return /^@[a-zA-Z\d]*$/.test(text)
}
// thematic break
ContentState.prototype.updateHr = function (block, marker) {
if (block.type !== 'hr') {
block.type = 'hr'
block.text = marker
block.children.length = 0
const { key } = block
this.cursor.start.key = this.cursor.end.key = key
return block
}
return null
}
ContentState.prototype.updateList = function (block, type, marker = '') {
if (block.type === 'span') {
block = this.getParent(block)
}
const { preferLooseListItem } = this
const parent = this.getParent(block)
const preSibling = this.getPreSibling(block)
const nextSibling = this.getNextSibling(block)
const wrapperTag = type === 'order' ? 'ol' : 'ul' // `bullet` => `ul` and `order` => `ol`
const { start, end } = this.cursor
const startOffset = start.offset
const endOffset = end.offset
const newBlock = this.createBlock('li')
block.children[0].text = block.children[0].text.substring(marker.length)
newBlock.listItemType = type
newBlock.isLooseListItem = preferLooseListItem
if (type === 'task' || type === 'bullet') {
const { bulletListMarker } = this
const bulletListItemMarker = marker ? marker.charAt(0) : bulletListMarker
newBlock.bulletListItemMarker = bulletListItemMarker
}
if (
preSibling && preSibling.listType === type && this.checkSameLooseType(preSibling, preferLooseListItem) &&
nextSibling && nextSibling.listType === type && this.checkSameLooseType(nextSibling, preferLooseListItem)
) {
this.appendChild(preSibling, newBlock)
const partChildren = nextSibling.children.splice(0)
partChildren.forEach(b => this.appendChild(preSibling, b))
this.removeBlock(nextSibling)
this.removeBlock(block)
} else if (preSibling && preSibling.type === wrapperTag && this.checkSameLooseType(preSibling, preferLooseListItem)) {
this.appendChild(preSibling, newBlock)
this.removeBlock(block)
} else if (nextSibling && nextSibling.listType === type && this.checkSameLooseType(nextSibling, preferLooseListItem)) {
this.insertBefore(newBlock, nextSibling.children[0])
this.removeBlock(block)
} else if (parent && parent.listType === type && this.checkSameLooseType(parent, preferLooseListItem)) {
this.insertBefore(newBlock, block)
this.removeBlock(block)
} else {
const listBlock = this.createBlock(wrapperTag)
listBlock.listType = type
if (wrapperTag === 'ol') {
const start = marker.split('.')[0]
listBlock.start = /^\d+$/.test(start) ? start : 1
}
this.appendChild(listBlock, newBlock)
this.insertBefore(listBlock, block)
this.removeBlock(block)
}
this.appendChild(newBlock, block)
const key = block.children[0].key
this.cursor = {
start: {
key,
offset: Math.max(0, startOffset - marker.length)
},
end: {
key,
offset: Math.max(0, endOffset - marker.length)
}
}
return block
}
ContentState.prototype.updateTaskListItem = function (block, type, marker = '') {
if (block.type === 'span') {
block = this.getParent(block)
}
const { preferLooseListItem } = this
const parent = this.getParent(block)
const grandpa = this.getParent(parent)
const checked = /\[x\]\s/i.test(marker) // use `i` flag to ignore upper case or lower case
const checkbox = this.createBlock('input')
const { start, end } = this.cursor
checkbox.checked = checked
this.insertBefore(checkbox, block)
block.children[0].text = block.children[0].text.substring(marker.length)
parent.listItemType = 'task'
parent.isLooseListItem = preferLooseListItem
let taskListWrapper
if (this.isOnlyChild(parent)) {
grandpa.listType = 'task'
} else if (this.isFirstChild(parent) || this.isLastChild(parent)) {
taskListWrapper = this.createBlock('ul')
taskListWrapper.listType = 'task'
this.isFirstChild(parent) ? this.insertBefore(taskListWrapper, grandpa) : this.insertAfter(taskListWrapper, grandpa)
this.removeBlock(parent)
this.appendChild(taskListWrapper, parent)
} else {
taskListWrapper = this.createBlock('ul')
taskListWrapper.listType = 'task'
const bulletListWrapper = this.createBlock('ul')
bulletListWrapper.listType = 'bullet'
let preSibling = this.getPreSibling(parent)
while (preSibling) {
this.removeBlock(preSibling)
if (bulletListWrapper.children.length) {
const firstChild = bulletListWrapper.children[0]
this.insertBefore(preSibling, firstChild)
} else {
this.appendChild(bulletListWrapper, preSibling)
}
preSibling = this.getPreSibling(preSibling)
}
this.removeBlock(parent)
this.appendChild(taskListWrapper, parent)
this.insertBefore(taskListWrapper, grandpa)
this.insertBefore(bulletListWrapper, taskListWrapper)
}
this.cursor = {
start: {
key: start.key,
offset: Math.max(0, start.offset - marker.length)
},
end: {
key: end.key,
offset: Math.max(0, end.offset - marker.length)
}
}
return taskListWrapper || grandpa
}
ContentState.prototype.updateHeader = function (block, header, text) {
const newType = `h${header.length}`
if (block.type !== newType) {
block.headingStyle = DEFAULT_TURNDOWN_CONFIG.headingStyle
block.type = newType
block.text = text
block.children.length = 0
this.cursor.start.key = this.cursor.end.key = block.key
return block
}
return null
}
ContentState.prototype.updateBlockQuote = function (block) {
block.children[0].text = block.children[0].text.substring(1).trim()
const quoteBlock = this.createBlock('blockquote')
this.insertBefore(quoteBlock, block)
this.removeBlock(block)
this.appendChild(quoteBlock, block)
const { start, end } = this.cursor
this.cursor = {
start: {
key: start.key,
offset: start.offset - 1
},
end: {
key: end.key,
offset: end.offset - 1
}
}
return quoteBlock
}
ContentState.prototype.updateToParagraph = function (block) {
const newType = 'p'
if (block.type !== newType) {
block.type = newType // updateP
const newLine = this.createBlock('span', block.text)
this.appendChild(block, newLine)
block.text = ''
this.cursor.start.key = this.cursor.end.key = newLine.key
return block
}
return null
}
ContentState.prototype.updateMathContent = function (block) {
const preBlock = this.getParent(block)
const mathPreview = this.getNextSibling(preBlock)
const math = preBlock.children.map(line => line.text).join('\n')
mathPreview.math = math
}
ContentState.prototype.updateState = function (event) {
const { floatBox } = this
const { start, end } = selection.getCursorRange()
const key = start.key
const block = this.getBlock(key)
// bugfix: #67 problem 1
if (block && block.icon) return event.preventDefault()
// if (isMetaKey(event)) {
// return
// }
if (event.type === 'keyup' && (event.key === EVENT_KEYS.ArrowUp || event.key === EVENT_KEYS.ArrowDown) && floatBox.show) {
return
}
if (event.type === 'click' && start.key !== end.key) {
setTimeout(() => {
this.updateState(event)
})
}
const { start: oldStart, end: oldEnd } = this.cursor
if (event.type === 'input' && oldStart.key !== oldEnd.key) {
const startBlock = this.getBlock(oldStart.key)
const endBlock = this.getBlock(oldEnd.key)
this.removeBlocks(startBlock, endBlock)
// there still has little bug, when the oldstart block is `pre`, the input value will be ignored.
// and act as `backspace`
if (startBlock.type === 'pre' && /code|html/.test(startBlock.functionType)) {
event.preventDefault()
const startRemainText = startBlock.type === 'pre'
? startBlock.text.substring(0, oldStart.offset - 1)
: startBlock.text.substring(0, oldStart.offset)
const endRemainText = endBlock.type === 'pre'
? endBlock.text.substring(oldEnd.offset - 1)
: endBlock.text.substring(oldEnd.offset)
startBlock.text = startRemainText + endRemainText
const key = oldStart.key
const offset = oldStart.offset
this.cursor = {
start: { key, offset },
end: { key, offset }
}
return this.partialRender()
}
}
if (start.key !== end.key) {
if (
start.key !== oldStart.key ||
end.key !== oldEnd.key ||
start.offset !== oldStart.offset ||
end.offset !== oldEnd.offset
) {
this.cursor = { start, end }
return this.partialRender()
}
}
const oldKey = lastCursor ? lastCursor.start.key : null
const paragraph = document.querySelector(`#${key}`)
let text = getTextContent(paragraph, [ CLASS_OR_ID['AG_MATH_RENDER'] ])
let needRender = false
let needRenderAll = false
if (event.type === 'click' && block.type === 'figure' && block.functionType === 'table') {
// first cell in thead
const cursorBlock = block.children[1].children[0].children[0].children[0]
const offset = cursorBlock.text.length
const key = cursorBlock.key
this.cursor = {
start: { key, offset },
end: { key, offset }
}
return this.partialRender()
}
// update '```xxx' to code block when you click other place or use press arrow key.
if (block && key !== oldKey) {
const oldBlock = this.getBlock(oldKey)
if (oldBlock) this.codeBlockUpdate(oldBlock)
}
if (block && block.type === 'pre' && /code|html/.test(block.functionType)) {
if (block.key !== oldKey) {
this.cursor = lastCursor = { start, end }
if (event.type === 'click' && oldKey) {
this.partialRender()
}
}
return
}
// auto pair (not need to auto pair in math block)
if (block && block.text !== text) {
const BRACKET_HASH = {
'{': '}',
'[': ']',
'(': ')',
'*': '*',
'_': '_',
'"': '"',
'\'': '\''
}
if (start.key === end.key && start.offset === end.offset && event.type === 'input' && block.functionType !== 'multiplemath') {
const { offset } = start
const { autoPairBracket, autoPairMarkdownSyntax, autoPairQuote } = this
const inputChar = text.charAt(+offset - 1)
const preInputChar = text.charAt(+offset - 2)
const postInputChar = text.charAt(+offset)
/* eslint-disable no-useless-escape */
if (
(event.inputType.indexOf('delete') === -1) &&
(inputChar === postInputChar) &&
(
(autoPairQuote && /[']{1}/.test(inputChar)) ||
(autoPairQuote && /["]{1}/.test(inputChar)) ||
(autoPairBracket && /[\}\]\)]{1}/.test(inputChar)) ||
(autoPairMarkdownSyntax && /[*_]{1}/.test(inputChar))
)
) {
text = text.substring(0, offset) + text.substring(offset + 1)
} else {
/* eslint-disable no-useless-escape */
// Not Unicode aware, since things like \p{Alphabetic} or \p{L} are not supported yet
if (
(autoPairQuote && /[']{1}/.test(inputChar) && !(/[a-zA-Z\d]{1}/.test(preInputChar))) ||
(autoPairQuote && /["]{1}/.test(inputChar)) ||
(autoPairBracket && /[\{\[\(]{1}/.test(inputChar)) ||
(autoPairMarkdownSyntax && /[*_]{1}/.test(inputChar))
) {
text = BRACKET_HASH[event.data]
? text.substring(0, offset) + BRACKET_HASH[inputChar] + text.substring(offset)
: text
}
/* eslint-enable no-useless-escape */
if (/\s/.test(event.data) && preInputChar === '*' && postInputChar === '*') {
text = text.substring(0, offset) + text.substring(offset + 1)
}
}
}
block.text = text
if (beginRules['reference_definition'].test(text)) {
needRenderAll = true
}
}
// Update preview content of math block
if (block && block.type === 'span' && block.functionType === 'multiplemath') {
this.updateMathContent(block)
}
if (oldKey !== key || oldStart.offset !== start.offset || oldEnd.offset !== end.offset) {
needRender = true
}
this.cursor = lastCursor = { start, end }
const checkMarkedUpdate = this.checkNeedRender(block)
const checkQuickInsert = this.checkQuickInsert(block)
const inlineUpdatedBlock = this.isCollapse() && !/frontmatter|multiplemath/.test(block.functionType) && this.checkInlineUpdate(block)
const reference = getPositionReference(paragraph)
this.eventCenter.dispatch('muya-quick-insert', reference, block.text, checkQuickInsert)
if (checkMarkedUpdate || inlineUpdatedBlock || needRender) {
needRenderAll ? this.render() : this.partialRender()
}
}
}
export default updateCtrl