marktext/src/muya/lib/contentState/tabCtrl.js
2022-06-09 02:34:42 -05:00

430 lines
13 KiB
JavaScript

import selection from '../selection'
import { HTML_TAGS, VOID_HTML_TAGS, HAS_TEXT_BLOCK_REG } from '../config'
import { tokenizer } from '../parser'
/**
* parseSelector
* div#id.className => {tag: 'div', id: 'id', className: 'className', isVoid: false}
*/
const parseSelector = (str = '') => {
const REG_EXP = /(#|\.)([^#.]+)/
let tag = ''
let id = ''
let className = ''
let isVoid = false
let cap
for (const tagName of HTML_TAGS) {
if (str.startsWith(tagName) && (!str[tagName.length] || /#|\./.test(str[tagName.length]))) {
tag = tagName
if (VOID_HTML_TAGS.indexOf(tagName) > -1) isVoid = true
str = str.substring(tagName.length)
}
}
if (tag !== '') {
cap = REG_EXP.exec(str)
while (cap && str.length) {
if (cap[1] === '#') {
id = cap[2]
} else {
className = cap[2]
}
str = str.substring(cap[0].length)
cap = REG_EXP.exec(str)
}
}
return { tag, id, className, isVoid }
}
const BOTH_SIDES_FORMATS = ['strong', 'em', 'inline_code', 'image', 'link', 'reference_image', 'reference_link', 'emoji', 'del', 'html_tag', 'inline_math']
const tabCtrl = ContentState => {
ContentState.prototype.findNextCell = function (block) {
if (block.functionType !== 'cellContent') {
throw new Error('only th and td can have next cell')
}
const cellBlock = this.getParent(block)
const nextSibling = this.getBlock(cellBlock.nextSibling)
const rowBlock = this.getBlock(cellBlock.parent)
const tbOrTh = this.getBlock(rowBlock.parent)
if (nextSibling) {
return this.firstInDescendant(nextSibling)
} else {
if (rowBlock.nextSibling) {
const nextRow = this.getBlock(rowBlock.nextSibling)
return this.firstInDescendant(nextRow)
} else if (tbOrTh.type === 'thead') {
const tBody = this.getBlock(tbOrTh.nextSibling)
if (tBody && tBody.children.length) {
return this.firstInDescendant(tBody)
}
}
}
return false
}
ContentState.prototype.findPreviousCell = function (block) {
if (block.functionType !== 'cellContent') {
throw new Error('only th and td can have previous cell')
}
const cellBlock = this.getParent(block)
const previousSibling = this.getBlock(cellBlock.preSibling)
const rowBlock = this.getBlock(cellBlock.parent)
const tbOrTh = this.getBlock(rowBlock.parent)
if (previousSibling) {
return this.firstInDescendant(previousSibling)
} else {
if (rowBlock.preSibling) {
const previousRow = this.getBlock(rowBlock.preSibling)
return this.lastInDescendant(previousRow)
} else if (tbOrTh.type === 'tbody') {
const tHead = this.getBlock(tbOrTh.preSibling)
if (tHead && tHead.children.length) {
return this.lastInDescendant(tHead)
}
}
}
return block
}
ContentState.prototype.isUnindentableListItem = function (block) {
const parent = this.getParent(block)
const listItem = this.getParent(parent)
const list = this.getParent(listItem)
const listParent = this.getParent(list)
if (!this.isCollapse()) return false
if (listParent && listParent.type === 'li') {
return !list.preSibling ? 'REPLACEMENT' : 'INDENT'
}
return false
}
ContentState.prototype.isIndentableListItem = function () {
const { start, end } = this.cursor
const startBlock = this.getBlock(start.key)
const parent = this.getParent(startBlock)
if (parent.type !== 'p' || !parent.parent) {
return false
}
const listItem = this.getParent(parent)
if (listItem.type !== 'li' || start.key !== end.key || start.offset !== end.offset) {
return false
}
// Now we know it's a list item. Check whether we can indent the list item.
const list = this.getParent(listItem)
return list && /ol|ul/.test(list.type) && listItem.preSibling
}
ContentState.prototype.unindentListItem = function (block, type) {
const pBlock = this.getParent(block)
const listItem = this.getParent(pBlock)
const list = this.getParent(listItem)
const listParent = this.getParent(list)
if (type === 'REPLACEMENT') {
this.insertBefore(pBlock, list)
if (this.isOnlyChild(listItem)) {
this.removeBlock(list)
} else {
this.removeBlock(listItem)
}
} else if (type === 'INDENT') {
if (list.children.length === 1) {
this.removeBlock(list)
} else {
const newList = this.createBlock(list.type)
let target = this.getNextSibling(listItem)
while (target) {
this.appendChild(newList, target)
const temp = target
target = this.getNextSibling(target)
this.removeBlock(temp, list)
}
if (newList.children.length) this.appendChild(listItem, newList)
this.removeBlock(listItem, list)
if (!list.children.length) {
this.removeBlock(list)
}
}
this.insertAfter(listItem, listParent)
let target = this.getNextSibling(list)
while (target) {
this.appendChild(listItem, target)
this.removeBlock(target, listParent)
target = this.getNextSibling(target)
}
}
return this.partialRender()
}
ContentState.prototype.indentListItem = function () {
const { start } = this.cursor
const startBlock = this.getBlock(start.key)
const parent = this.getParent(startBlock)
const listItem = this.getParent(parent)
const list = this.getParent(listItem)
const prevListItem = this.getPreSibling(listItem)
this.removeBlock(listItem)
// Search for a list in previous block
let newList = this.getLastChild(prevListItem)
if (!newList || !/ol|ul/.test(newList.type)) {
newList = this.createBlock(list.type)
this.appendChild(prevListItem, newList)
}
// Update item type only if we insert into an existing list
if (newList.children.length !== 0) {
listItem.isLooseListItem = newList.children[0].isLooseListItem
}
this.appendChild(newList, listItem)
return this.partialRender()
}
ContentState.prototype.insertTab = function () {
const tabSize = this.tabSize
const tabCharacter = String.fromCharCode(32).repeat(tabSize)
const { start, end } = this.cursor
const startBlock = this.getBlock(start.key)
const endBlock = this.getBlock(end.key)
if (start.key === end.key && start.offset === end.offset) {
startBlock.text = startBlock.text.substring(0, start.offset) +
tabCharacter + endBlock.text.substring(end.offset)
const key = start.key
const offset = start.offset + tabCharacter.length
this.cursor = {
start: { key, offset },
end: { key, offset }
}
return this.partialRender()
}
}
ContentState.prototype.checkCursorAtEndFormat = function (text, offset) {
const { labels } = this.stateRender
const tokens = tokenizer(text, {
hasBeginRules: false,
labels,
options: this.muya.options
})
let result = null
const walkTokens = tkns => {
for (const token of tkns) {
const { marker, type, range, children, srcAndTitle, hrefAndTitle, backlash, closeTag, isFullLink, label } = token
const { start, end } = range
if (BOTH_SIDES_FORMATS.includes(type) && offset > start && offset < end) {
switch (type) {
case 'strong':
case 'em':
case 'inline_code':
case 'emoji':
case 'del':
case 'inline_math': {
if (marker && offset === end - marker.length) {
result = {
offset: marker.length
}
return
}
break
}
case 'image':
case 'link': {
const linkTitleLen = (srcAndTitle || hrefAndTitle).length
const secondLashLen = backlash && backlash.second ? backlash.second.length : 0
if (offset === end - 3 - (linkTitleLen + secondLashLen)) {
result = {
offset: 2
}
return
} else if (offset === end - 1) {
result = {
offset: 1
}
return
}
break
}
case 'reference_image':
case 'reference_link': {
const labelLen = label ? label.length : 0
const secondLashLen = backlash && backlash.second ? backlash.second.length : 0
if (isFullLink) {
if (offset === end - 3 - labelLen - secondLashLen) {
result = {
offset: 2
}
return
} else if (offset === end - 1) {
result = {
offset: 1
}
return
}
} else if (offset === end - 1) {
result = {
offset: 1
}
return
}
break
}
case 'html_tag': {
if (closeTag && offset === end - closeTag.length) {
result = {
offset: closeTag.length
}
return
}
break
}
default:
break
}
}
if (children && children.length) {
walkTokens(children)
}
}
}
walkTokens(tokens)
return result
}
ContentState.prototype.tabHandler = function (event) {
// disable tab focus
event.preventDefault()
const { start, end } = selection.getCursorRange()
if (!start || !end) {
return
}
const startBlock = this.getBlock(start.key)
const endBlock = this.getBlock(end.key)
if (event.shiftKey && startBlock.functionType !== 'cellContent') {
const unindentType = this.isUnindentableListItem(startBlock)
if (unindentType) {
this.unindentListItem(startBlock, unindentType)
}
return
}
// Handle `tab` to jump to the end of format when the cursor is at the end of format content.
if (
start.key === end.key &&
start.offset === end.offset &&
HAS_TEXT_BLOCK_REG.test(startBlock.type) &&
startBlock.functionType !== 'codeContent' && // code content has no inline syntax
startBlock.functionType !== 'languageInput' // language input textarea has no inline syntax
) {
const { text, key } = startBlock
const { offset } = start
const atEnd = this.checkCursorAtEndFormat(text, offset)
if (atEnd) {
this.cursor = {
start: { key, offset: offset + atEnd.offset },
end: { key, offset: offset + atEnd.offset }
}
return this.partialRender()
}
}
// Auto-complete of inline html tag and html block and `html` code block.
if (
start.key === end.key &&
start.offset === end.offset &&
startBlock.type === 'span' &&
(!startBlock.functionType || startBlock.functionType === 'codeContent' && /markup|html|xml|svg|mathml/.test(startBlock.lang))
) {
const { text } = startBlock
const lastWordBeforeCursor = text.substring(0, start.offset).split(/\s+/).pop()
const { tag, isVoid, id, className } = parseSelector(lastWordBeforeCursor)
if (tag) {
const preText = text.substring(0, start.offset - lastWordBeforeCursor.length)
const postText = text.substring(end.offset)
if (!tag) return
let html = `<${tag}`
const key = startBlock.key
let startOffset = 0
let endOffset = 0
switch (tag) {
case 'img':
html += ' alt="" src=""'
startOffset = endOffset = html.length - 1
break
case 'input':
html += ' type="text"'
startOffset = html.length - 5
endOffset = html.length - 1
break
case 'a':
html += ' href=""'
startOffset = endOffset = html.length - 1
break
case 'link':
html += ' rel="stylesheet" href=""'
startOffset = endOffset = html.length - 1
break
}
if (id) {
html += ` id="${id}"`
}
if (className) {
html += ` class="${className}"`
}
html += '>'
if (startOffset === 0 && endOffset === 0) {
startOffset = endOffset = html.length
}
if (!isVoid) {
html += `</${tag}>`
}
startBlock.text = preText + html + postText
this.cursor = {
start: { key, offset: startOffset + preText.length },
end: { key, offset: endOffset + preText.length }
}
return this.partialRender()
}
}
// Handle `tab` key in table cell.
let nextCell
if (start.key === end.key && startBlock.functionType === 'cellContent') {
nextCell = event.shiftKey
? this.findPreviousCell(startBlock)
: this.findNextCell(startBlock)
} else if (endBlock.functionType === 'cellContent') {
nextCell = endBlock
}
if (nextCell) {
const { key } = nextCell
const offset = 0
this.cursor = {
start: { key, offset },
end: { key, offset }
}
const figure = this.closest(nextCell, 'figure')
return this.singleRender(figure)
}
if (this.isIndentableListItem()) {
return this.indentListItem()
}
return this.insertTab()
}
}
export default tabCtrl