SOURCE

console 命令行工具 X clear

                    
>
console
class MarkdownParser {
    compile(nodes) {
        let matchArr
        let html = ''
        for (let i = 0, len = nodes.length; i < len; i++) {
            let text = ''
            let index = 0
            const node = nodes[i]
            if (node.nodeType === Node.ELEMENT_NODE) {
                if (node.innerHTML === '<br>') {
                    // 多个空行只算一个
                    html += `<div></div>`
                    while (nodes[i + 1].nodeType === Node.ELEMENT_NODE && nodes[i + 1].innerHTML === '<br>') {
                        i++
                    }

                    continue
                }

                index = node.dataset.index
                text = node.textContent.trim()
            } else {
                text = node.nodeValue.trim()
            }

            matchArr = text.match(/^#\s/)
                        || text.match(/^##\s/)
                        || text.match(/^###\s/)
                        || text.match(/^####\s/)
                        || text.match(/^#####\s/)
                        || text.match(/^######\s/)
                        || text.match(/^\*{3,}/)
                        || text.match(/^>/)
                        || text.match(/^\*\s/)
                        || text.match(/^\d*\.\s/)
                        || text.match(/^```/)
                        || text.match(/^\|.*\|/)

            if (matchArr) {
                let temp = ''
                const re1 = /^>/
                const re2 = /^\*\s/
                const re3 = /^\d*\.\s/
                const re4 = /^```/
                const re5 = /^\|.*\|/
                switch(matchArr[0]) {
                    case '# ':
                        html += `<h1 data-index="${index}">` + this.format(text.substring(2)) + '</h1>'
                        break
                    case '## ':
                        html += `<h2 data-index="${index}">` + this.format(text.substring(3)) + '</h2>'
                        break
                    case '### ':
                        html += `<h3 data-index="${index}">` + this.format(text.substring(4)) + '</h3>'
                        break
                    case '#### ':
                        html += `<h4 data-index="${index}">` + this.format(text.substring(5)) + '</h4>'
                        break
                    case '##### ':
                        html += `<h5 data-index="${index}">` + this.format(text.substring(6)) + '</h5>'
                        break
                    case '###### ':
                        html += `<h6 data-index="${index}">` + this.format(text.substring(7)) + '</h6>'
                        break
                    case text.match(/^\*{3,}/) && text.match(/^\*{3,}/)[0]:
                        html += text.replace(/^\*{3,}/g, '<hr>')
                        break
                    case '>':
                        while (i < len && nodes[i].textContent.match(re1)) {
                            const str = nodes[i].textContent
                            temp += '<div>' + this.format(str.slice(1)) + '</div>'
                            i++
                        }

                        i--
                        html += `<blockquote data-index="${index}">` + temp + '</blockquote>'
                        break
                    case '* ':
                        while (i < len && nodes[i].textContent?.match(re2)) {
                            const str = nodes[i].textContent
                            temp += `<li data-index="${nodes[i]?.dataset?.index}">` + this.format(str.slice(2)) + '</li>'
                            i++
                        }

                        i--
                        html += `<ul data-index="${index}">` + temp + '</ul>'
                        break
                    case text.match(/^\d*\.\s/) && text.match(/^\d*\.\s/)[0]:
                        while (i < len && nodes[i].textContent?.match(re3)) {
                            const str = nodes[i].textContent
                            temp += `<li data-index="${nodes[i]?.dataset?.index}">` + this.format(str.replace(/^\d*\.\s/, '')) + '</li>'
                            i++
                        } 

                        i--
                        html += `<ol data-index="${index}">` + temp + '</ol>'
                        break
                    case '```':
                        i++
                        while (i < len && !re4.test(nodes[i].textContent)) {
                            temp += `<div data-index="${nodes[i]?.dataset?.index}">` + escapeHTML(nodes[i].textContent) + '</div>'
                            i++
                        }
                        
                        html += `<pre data-index="${index}"><code>` + temp + '</code></pre>'
                        break
                    case text.match(/^\|.*\|/) && text.match(/^\|.*\|/)[0]:
                        let thRe = /^\[th\]/
                        let arr, j, jlen
                        while (i < len && re5.test(nodes[i].textContent)) {
                            arr = nodes[i].textContent.split('|')
                            temp += `<tr data-index="${nodes[i]?.dataset?.index}">`
                            for (j = 1, jlen = arr.length - 1; j < jlen; j++) {
                                if (thRe.test(arr[1])) {
                                    temp += '<th>' + arr[j] + '</th>'
                                } else {
                                    temp += '<td>' + arr[j] + '</td>'
                                }
                            }
                            temp += '</tr>'
                            temp = temp.replace('[th]', '')
                            i++
                        }

                        html += '<table>' + temp + '</table>'
                        break
                }
            } else if (text) {
                html += `<div data-index="${index}">` + this.format(text) + '</div>'
            }

        }

        return html
    }

    format(str) { 
        str = str.replace(/\s/g, '&nbsp') 

        const bold = str.match(/\*{2}[^*].*?\*{2}/g) // 惰性匹配
        if (bold) {
            for (let i = 0, len = bold.length; i < len; i++) {
                str = str.replace(bold[i], '<b>' + bold[i].substring(2, bold[i].length - 2) + '</b>')
            }
        }

        const italic = str.match(/\*[^*].*?\*/g)  
        if (italic) {
            for (let i = 0, len = italic.length; i < len; i++) {
                str = str.replace(italic[i], '<i>' + italic[i].substring(1, italic[i].length - 1) + '</i>')
            }
        }

        const code = str.match(/`[^`]*`/g)
        if (code) {
            for (let i = 0, len = code.length; i < len; i++) {
                str = str.replace(code[i], '<code>' + code[i].substring(1, code[i].length - 1) + '</code>')
            }
        }

        const img = str.match(/!\[.*\]\(.*\)/g)
        const re1 = /\(.*\)/
        const re2 = /\[.*\]/
        if (img) {
            for (let i = 0, len = img.length; i < len; i++) {
                const url = img[i].match(re1)[0]
                const title = img[i].match(re2)[0]
                str = str.replace(img[i], '<img src=' + url.substring(1, url.length - 1) + ' alt=' + title.substring(1, title.length -1) + '>')
            }
        }

        const a = str.match(/\[.*?\]\(.*?\)/g)
        if (a) {
            for (let i = 0, len = a.length; i < len; i++) {
                const url = a[i].match(re1)[0]
                const title = a[i].match(re2)[0]
                str = str.replace(a[i], '<a href=' + url.substring(1, url.length - 1)  + '>' + title.substring(1, title.length -1) + '</a>')
            }
        }

        return escapeHTML2(str)
    }
}

function escapeHTML(html) {
    return html.replace(/</g, '&lt;').replace(/>/g, '&gt;')
}

function escapeHTML2(html) {
    return html.replace(/<(\/)?script>/g, '&lt;$1script&gt;')
}

function $(selector) {
    return document.querySelector(selector)
}

// 修改光标位置
function changeCursorPosition(node) {
    const selection = window.getSelection()
    // 清除所有选区 如果是 Caret 类型,清除选区后变为 Range,如果不是 Range 类型,后面的 addRange() 就不起作用
    selection.removeAllRanges()
    const range = document.createRange()
    // 选中节点的内容
    range.selectNode(node)
    selection.addRange(range)
    // 取消选中并将光标移至选区最后
    selection.collapseToEnd()
}

// 清除复制后的内容样式
function clearTextStyle(e) {
    e.preventDefault()

    let text
    const clp = (e.originalEvent || e).clipboardData
    if (clp === undefined || clp === null) {
        text = window.clipboardData.getData('text') || ''
        if (text !== '') {
            if (window.getSelection) {
                var newNode = document.createElement('span')
                newNode.innerHTML = text
                window.getSelection().getRangeAt(0).insertNode(newNode)
            } else {
                document.selection.createRange().pasteHTML(text)
            }
        }
    } else {
        text = clp.getData('text/plain') || ''
        if (text !== '') {
            document.execCommand('insertText', false, text)
        }
    }
}

function throttle(delay) {
    let waitForCallFunc
    let canCall = true
    return function helper(callback, ...args) {
        if (!canCall) {
            if (callback) waitForCallFunc = callback
            return
        }

        callback(...args)
        canCall = false
        setTimeout(() => {
            canCall = true
            if (waitForCallFunc) {
                helper(waitForCallFunc, ...args)
                waitForCallFunc = null
            }
        }, delay)
    }
}

function debounce(delay) {
    let timer
    return function(callback, ...args) {
        clearTimeout(timer)
        timer = setTimeout(() => callback.call(null, ...args), delay)
    }
}

/**
 * 计算 dom 到容器顶部的距离
 * @param {HTMLElement} dom 需要计算的容器
 * @param {HTMLElement} topContainer 终止条件
 * @returns 
 */
 function getHeightToTop(dom) {
    let height = dom.offsetTop
    let parent = dom.offsetParent

    while (parent) {
        height += parent.offsetTop
        parent = parent.offsetParent
    }

    return height
}

// dom 是否在屏幕内
function isInScreen(dom) {
    const { top, bottom } = dom.getBoundingClientRect()
    return bottom >= 0 && top < window.innerHeight
}

// dom 在当前屏幕展示内容的百分比
function percentOfdomInScreen(dom) {
    const { height, bottom } = dom.getBoundingClientRect()
    if (bottom <= 0) return 0
    if (bottom >= height) return 1
    return bottom / height
}

function canNodeCalculate(node) {
    return (
        node.innerHTML 
        && node.innerHTML !== '<br>' 
        && !node.textContent.startsWith('```') 
        && isInScreen(node) 
        && percentOfdomInScreen(node) >= 0
    )
}

const editor = $('#editor')
const showDom = $('#show-content')
const markdown = new MarkdownParser()
showDom.innerHTML = markdown.compile(editor.children)

function onInput() {
    // 为每个元素加上索引,通过索引找到 markdown 渲染后的元素
    let index = 0
    const data = Array.from(editor.children)
    data.forEach(item => {
        delete item.dataset.index
        // 忽略 br 换行符和空文本字节
        if (item.tagName !== 'BR' && item.innerText.trim() !== '') {
            if (!item.children.length || (item.children.length === 1 && item.children[0].tagName === 'BR')) {
                item.dataset.index = index++
                return
            }

            // 这里主要是针对复制过来的有嵌套节点的内容
            const frag = document.createDocumentFragment()
            Array.from(item.childNodes).forEach(e => {
                if (e.nodeType === Node.TEXT_NODE) {
                    const div = document.createElement('div')
                    div.textContent = e.nodeValue
                    item.replaceChild(div, e)
                    div.dataset.index = index++
                    frag.appendChild(div)
                } else if (item.tagName !== 'BR') {
                    e.dataset?.index && delete e.dataset.index
                    e.dataset.index = index++
                    frag.appendChild(e)
                }
            })
            
            editor.replaceChild(frag, item)
            
            // 需要修改光标位置,不然光标会在复制内容的前面,修改后会在复制内容的后面
            changeCursorPosition(editor.querySelector(`[data-index="${index - 1}"]`))
        }
    })

    showDom.innerHTML = markdown.compile(editor.childNodes)
}

const debounceFn = debounce(100) // 防抖
editor.oninput = () => {
    debounceFn(onInput)
}

editor.onpaste = (e) => {
    clearTextStyle(e)
}

// 是否允许滚动
const canScroll = {
    editor: true,
    showDom: true,
}

const debounceFn2 = debounce(100) // 防抖
const throttleFn = throttle(50) // 节流
editor.onscroll = () => {
    if (!canScroll.editor) return

    canScroll.showDom = false
    throttleFn(onScroll, editor, showDom)
    debounceFn2(resumeScroll)
}

showDom.onscroll = () => {
    if (!canScroll.showDom) return

    canScroll.editor = false
    throttleFn(onScroll, showDom, editor)
    debounceFn(resumeScroll)
}

// 恢复滚动
function resumeScroll() {
    canScroll.editor = true
    canScroll.showDom = true
}

/**
 * 
 * @param {HTMLElement} scrollContainer 正在滚动的容器
 * @param {HTMLElement} ShowContainer 需要同步滚动的容器
 * @returns 
 */
function onScroll(scrollContainer, ShowContainer) {
    const scrollHeight = ShowContainer.scrollHeight
    // 滚动到底部
    if (scrollContainer.offsetHeight + scrollContainer.scrollTop >= scrollContainer.scrollHeight) {
        ShowContainer.scrollTo({ top: scrollHeight - ShowContainer.clientHeight })
        return
    }

    // 滚动到顶部
    if (scrollContainer.scrollTop === 0) {
        ShowContainer.scrollTo({ top: 0 })
        return
    }

    const nodes = Array.from(scrollContainer.children)
    for (const node of nodes) {
        // 从上往下遍历,找到第一个在屏幕内的元素
        if (canNodeCalculate(node)) {
            // 如果当前滚动的元素是 <pre> <table>
            if (node.tagName === 'PRE' || node.tagName === 'TABLE') {
                // 如果 pre 里面的子元素同步滚动了,则直接返回
                if (hasPreElementInScrollContainerScroll(node, ShowContainer)) return
                // 否则直接从下一个元素开始计算
                continue
            }

            const index = node.dataset.index
            const dom = ShowContainer.querySelector(`[data-index="${index}"]`)
            if (!dom) continue

            const percent = percentOfdomInScreen(node)
            const heightToTop = getHeightToTop(dom)
            const domNeedHideHeight = dom.offsetHeight * (1 - percent)
            ShowContainer.scrollTo({ top: heightToTop + domNeedHideHeight })
            break
        }
    }
}

function hasPreElementInScrollContainerScroll(preElement, ShowContainer) {
    for (const node of preElement.children[0].children) {
        // 从上往下遍历,找到第一个在屏幕内的元素
        if (isInScreen(node) && percentOfdomInScreen(node) >= 0) {
            const index = node.dataset.index
            const dom = ShowContainer.querySelector(`[data-index="${index}"]`)
            if (!dom) continue

            const percent = percentOfdomInScreen(node)
            const heightToTop = getHeightToTop(dom)
            const domNeedHideHeight = dom.offsetHeight * (1 - percent)
            ShowContainer.scrollTo({ top: heightToTop + domNeedHideHeight })
            return true
        }
    }

    return false
}
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>markdown 编辑器双屏同步滚动 demo1</title>
    <link rel="stylesheet" href="index.css">
</head>
<body>
    <div id="editor" contenteditable class="text-container">
        <div data-index="0">![](https://avatars.githubusercontent.com/u/22117876?v=4)
        </div><div data-index="1">### h3
        </div><div data-index="2">### h3
        </div><div data-index="3">### h3
        </div><div data-index="4">### h3
        </div><div data-index="5">### h3
        </div><div data-index="6">### h3</div><div data-index="7">![](https://avatars.githubusercontent.com/u/22117876?v=4)
        </div><div data-index="8">### h3
        </div><div data-index="9">### h3
        </div><div data-index="10">### h3
        </div><div data-index="11">### h3
        </div><div data-index="12">### h3
        </div><div data-index="13">![](https://avatars.githubusercontent.com/u/22117876?v=4)
        </div><div data-index="14">### h3
        </div><div data-index="15">### h3
        </div><div data-index="16">### h3
        </div><div data-index="17">### h3
        </div><div data-index="18">### h3
        </div><div data-index="19">![](https://avatars.githubusercontent.com/u/22117876?v=4)
        </div><div data-index="20">### h3
        </div><div data-index="21">### h3
        </div><div data-index="22">### h3
        </div><div data-index="23">![](https://avatars.githubusercontent.com/u/22117876?v=4)
        </div><div data-index="24">### h3
        </div><div data-index="25">### h3
        </div><div data-index="26">### h3</div><div data-index="27">### h3</div><div data-index="28">### h3</div><div data-index="29">### h3</div><div data-index="30">![](https://avatars.githubusercontent.com/u/22117876?v=4)</div><div data-index="31">### h3</div><div data-index="32">### h3</div><div data-index="33">### h3</div><div data-index="34">### h3</div><div data-index="35">![](https://avatars.githubusercontent.com/u/22117876?v=4)</div><div data-index="36">### h3</div><div data-index="37">### h3</div><div data-index="38">### h3</div><div data-index="39">### h3</div><div data-index="40">### h3</div><div data-index="41">### h3</div><div data-index="42">![](https://avatars.githubusercontent.com/u/22117876?v=4)</div><div data-index="43">### h3
        </div><div data-index="44">### h3
        </div><div data-index="45">### h3
        </div><div data-index="46">### h3
        </div><div data-index="47">### h3
        </div><div data-index="48">![](https://avatars.githubusercontent.com/u/22117876?v=4)</div><div data-index="49">### h3
        </div><div data-index="50">### h3
        </div><div data-index="51">### h3
        </div><div data-index="52">### h3
        </div><div data-index="53">### h3
        </div><div data-index="54">![](https://avatars.githubusercontent.com/u/22117876?v=4)</div><div data-index="55">### h3
        </div><div data-index="56">### h3
        </div><div data-index="57">### h3
        </div><div data-index="58">### h3
        </div><div data-index="59">### h3
        </div><div data-index="60">![](https://avatars.githubusercontent.com/u/22117876?v=4)</div><div data-index="61">### h3
        </div><div data-index="62">### h3
        </div><div data-index="63">### h3
        </div><div data-index="64">### h3
        </div><div data-index="65">### h3
        </div><div data-index="66">![](https://avatars.githubusercontent.com/u/22117876?v=4)</div>
    </div>
    <div id="show-content" class="text-container markdown"></div>
</body>s
</html>
html,
body {
    height: 100%;
    font-size: 0;
}

* {
    margin: 0;
    padding: 0;
    font-family: 'Microsoft yahei';
}

body {
    display: flex;
}

table {
    border-collapse: collapse;
}

.text-container {
    box-sizing: border-box;
    width: 50%;
    border: 1px solid #e6e6e6;
    height: 100%;
    font-size: 14px;
    background: #f5f5f5;
    padding: 10px;
    line-height: 20px;
    outline: none;
    overflow: auto;
}
.text-container > div {
    min-height: 20px;
}

/* 定义滚动条高宽及背景
 高宽分别对应横竖滚动条的尺寸 */
 ::-webkit-scrollbar {
    width: 5px;
    height: 5px;
}

/* 定义滚动条轨道
  内阴影+圆角 */
::-webkit-scrollbar-track {
    background-color: transparent;
}

/* 定义滑块
  内阴影+圆角 */
::-webkit-scrollbar-thumb {
    border-radius: 4px;
    background-color: #ddd;
}

/* markdown style */
.markdown {
    font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol;
    font-size: 16px;
    line-height: 1.5;
    word-wrap: break-word;
    background-color: #fff;
    color: #24292f;
}
.markdown img {
    max-width: 100%;
    margin: 10px 0;
}
.markdown>:first-child {
    margin-top: 0!important;
}
.markdown h1,
.markdown h2 {
    border-bottom: 1px solid #eaecef;
    padding-bottom: .3em;
}
.markdown h1,
.markdown h2, 
.markdown h3, 
.markdown h4, 
.markdown h5, 
.markdown h6 {
    font-weight: 600;
    line-height: 1.25;
    margin-bottom: 16px;
    margin-top: 24px;
}
.markdown div {
    min-height: 20px;
}
code, 
pre, 
tt {
    font-family: SFMono-Regular,Consolas,Liberation Mono,Menlo,Courier,monospace;
    font-size: 14px;
}
.markdown pre {
    background-color: #f6f8fa;
    border-radius: 3px;
    font-size: 85%;
    line-height: 1.45;
    overflow: auto;
    padding: 16px;
    color: #24292e;
    font-size: 14px;
    font-family: SFMono-Regular,Consolas,Liberation Mono,Menlo,Courier,monospace;
    margin: 0;
    margin-bottom: 1em;
}
.markdown code {
    background-color: #f6f8fa;
    border: 0;
    display: inline;
    line-height: inherit;
    margin: 0;
    max-width: auto;
    overflow: visible;
    padding: 0;
    word-wrap: normal;
    word-break: break-all;
}
.markdown blockquote {
    background-color: #f6f8fa;
    padding: 10px;
}
.markdown table {
    display: block;
    overflow: auto;
    width: 100%;
    margin-bottom: 15px;
}
.markdown table tr {
    background-color: #fff;
}
.markdown table td,
.markdown table th {
    border: 1px solid #dfe2e5;
    padding: 6px 13px;
}
.markdown p {
    margin-bottom: 1em;
}
.markdown li {
    word-wrap: break-all;
}
.markdown ul li {
    list-style: disc;
}
.markdown ol,
.markdown ul {
    padding-left: 2em;
}
.markdown blockquote, 
.markdown details, 
.markdown dl, 
.markdown ol, 
.markdown p, 
.markdown pre, 
.markdown table, 
.markdown ul {
    margin-bottom: 16px;
    margin-top: 0;
}
.markdown a {
    color: #0366d6;
    text-decoration: none;
}