mirror of
https://github.com/marktext/marktext.git
synced 2025-05-10 22:12:29 +08:00
475 lines
16 KiB
JavaScript
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
|