diff --git a/doc/Block addition properties and its value.md b/doc/Block addition properties and its value.md new file mode 100644 index 00000000..9501314d --- /dev/null +++ b/doc/Block addition properties and its value.md @@ -0,0 +1,161 @@ +### Block addition properties and its value + +##### 1. span + +- functionType + + - languageInput + + - codeLine + + - atxLine (can not contain soft line break and hard line break use in atx heading) + + - thematicBreakLine (can not contain soft line break and hard line break use in thematic break) + + - paragraphContent (defaultValue use in paragraph and setext heading) + +- lang - only when it's code line + + - All prismjs support language or empty string + +##### 2. div + +used for preview `block math`, `mermaid`, `flowchart`, `vega-lite`, `sequence` and `html block`. + +- functionType + + - multiplemath + + - mermaid + + - flowchart + + - vega-lite + + - sequence + + - html + +##### 3. figure + +The container block of `table`, `html`, `block math`, `mermaid`,`flowchart`,`vega-lite`,`sequence`. + +- functionType + + - table + + - html + + - multiplemath + + - mermaid + + - flowchart + + - vega-lite + + - sequence + +##### 4. pre + +Used for `html`,`block math`,`mermaid`,`flowchart`,`vega-lite`,`sequence` `code block`. + +- functionType + + - html + + - multiplemath + + - mermaid + + - flowchart + + - vega-lite + + - sequence + + - fencecode + + - indentcode + + - frontmatter + +- lang + + - all prismjs support language or empty string + +##### 5. code + +- lang + + - all prismjs support language or empty string + +##### ul + +- listType + + - bullet + + - task + +##### ol + +- listType + + - order + +- start + + - 0-999999999 + +##### li + +- listItemType + + - order + + - bullet + + - task + +- isLooseListItem + + - true + + - false + +- bulletMarkerOrDelimiter + + - bulletMarker:`-`, `+`, `*` + + - Delimiter: `)`, `.` + +##### h1~6 + +- headingStyle + + - atx + + - setext + +- marker - only setext heading has marker + +##### input + +- checked + + - true + + - false + +##### table + +- row + +- column + +##### th/td + +- align + +- column diff --git a/package.json b/package.json index e43bdc3b..6d70f6c5 100644 --- a/package.json +++ b/package.json @@ -189,7 +189,7 @@ "keyboard-layout": "^2.0.15", "mermaid": "^8.0.0", "popper.js": "^1.15.0", - "prismjs": "^1.16.0", + "prismjs2": "^1.15.1", "snabbdom": "^0.7.3", "snabbdom-to-html": "^5.1.1", "snapsvg": "^0.5.1", diff --git a/src/main/menu/actions/paragraph.js b/src/main/menu/actions/paragraph.js index 10d59dc9..605a2318 100644 --- a/src/main/menu/actions/paragraph.js +++ b/src/main/menu/actions/paragraph.js @@ -35,11 +35,11 @@ const setParagraphMenuItemStatus = bool => { .forEach(item => (item.enabled = bool)) } -const disableNoMultiple = disableLabels => { +const setMultipleStatus = (list, status) => { const paragraphMenuItem = getMenuItemById('paragraphMenuEntry') paragraphMenuItem.submenu.items - .filter(item => item.id && disableLabels.includes(item.id)) - .forEach(item => (item.enabled = false)) + .filter(item => item.id && list.includes(item.id)) + .forEach(item => (item.enabled = status)) } const setCheckedMenuItem = affiliation => { @@ -105,15 +105,17 @@ ipcMain.on('AGANI::selection-change', (e, { start, end, affiliation }) => { (end.type === 'span' && end.block.functionType === 'codeLine') ) { setParagraphMenuItemStatus(false) + if (start.block.functionType === 'codeLine' || end.block.functionType === 'codeLine') { + setMultipleStatus(['codeFencesMenuItem'], true) formatMenuItem.submenu.items.forEach(item => (item.enabled = false)) } } else if (start.key !== end.key) { formatMenuItem.submenu.items .filter(item => item.id && DISABLE_LABELS.includes(item.id)) .forEach(item => (item.enabled = false)) - disableNoMultiple(DISABLE_LABELS) + setMultipleStatus(DISABLE_LABELS, false) } else if (!affiliation.slice(0, 3).some(p => /ul|ol/.test(p.type))) { - disableNoMultiple(['looseListItemMenuItem']) + setMultipleStatus(['looseListItemMenuItem'], false) } }) diff --git a/src/muya/lib/assets/styles/index.css b/src/muya/lib/assets/styles/index.css index da269ae8..d435cbad 100644 --- a/src/muya/lib/assets/styles/index.css +++ b/src/muya/lib/assets/styles/index.css @@ -22,7 +22,7 @@ pre { outline: none; } -div.ag-show-quick-insert-hint p.ag-paragraph.ag-active > span.ag-line:first-of-type:empty::after { +div.ag-show-quick-insert-hint p.ag-paragraph.ag-active > span.ag-paragraph-content:first-of-type:empty::after { content: 'Type @ to insert'; color: var(--editorColor10); } @@ -31,12 +31,17 @@ div.ag-show-quick-insert-hint p.ag-paragraph.ag-active > span.ag-line:first-of-t position: relative; } -.ag-paragraph:empty::after, -.ag-line:empty::after { - content: '\200B' +.ag-atx-line:empty::after, +.ag-thematic-break-line:empty::after, +.ag-code-line:empty::after, +.ag-paragraph-content:empty::after { + content: '\200B'; } -.ag-line { +.ag-atx-line, +.ag-thematic-break-line, +.ag-paragraph-content, +.ag-code-line { display: block; white-space: pre-wrap; word-break: break-word; @@ -62,11 +67,15 @@ div.ag-show-quick-insert-hint p.ag-paragraph.ag-active > span.ag-line:first-of-t margin: 0 5px; } -.ag-hard-line-break::after { +.ag-hard-line-break-space::after { content: '↩'; opacity: .5; } +.ag-line-end { + display: block; +} + *:not(.ag-hide)::selection, .ag-selection { background: var(--selectionColor); color: var(--editorColor); @@ -77,8 +86,7 @@ div.ag-show-quick-insert-hint p.ag-paragraph.ag-active > span.ag-line:first-of-t color: transparent; } -figure.ag-container-block pre, -div.ag-function-html pre.ag-html-block { +figure.ag-container-block pre { width: 0; height: 0; overflow: hidden; @@ -90,7 +98,6 @@ div.ag-function-html pre.ag-html-block { overflow: visible; } -div.ag-function-html.ag-active pre.ag-html-block, figure.ag-active.ag-container-block pre { position: static; width: 100%; @@ -100,11 +107,11 @@ figure.ag-active.ag-container-block pre { display: block; } -div.ag-function-html .ag-html-preview { +figure[data-role="HTML"] .ag-html-preview { display: block; } -div.ag-function-html.ag-active .ag-html-preview { +figure[data-role="HTML"].ag-active .ag-html-preview { display: none; } @@ -474,7 +481,6 @@ pre.ag-multiple-math span.ag-code-line:first-of-type:empty::after { figure, pre.ag-html-block, -div.ag-function-html, pre.ag-fence-code, pre.ag-indent-code, li.ag-list-item > p.ag-paragraph { diff --git a/src/muya/lib/config/index.js b/src/muya/lib/config/index.js index 317af0e0..402823c9 100644 --- a/src/muya/lib/config/index.js +++ b/src/muya/lib/config/index.js @@ -6,7 +6,7 @@ import voidHtmlTags from 'html-tags/void' // Electron 2.0.2 not support yet! So give a default value 4 export const DEVICE_MEMORY = navigator.deviceMemory || 4 // Get the divice memory number(Chrome >= 63) export const UNDO_DEPTH = DEVICE_MEMORY >= 4 ? 100 : 50 -export const HAS_TEXT_BLOCK_REG = /^(h\d|span|th|td|hr)/i +export const HAS_TEXT_BLOCK_REG = /^(span|th|td)/i export const VOID_HTML_TAGS = voidHtmlTags export const HTML_TAGS = htmlTags // TYPE1 ~ TYPE7 according to https://github.github.com/gfm/#html-blocks @@ -85,6 +85,8 @@ export const CLASS_OR_ID = genUpper2LowerKeyHash([ 'AG_FRONT_ICON', 'AG_GRAY', 'AG_HARD_LINE_BREAK', + 'AG_HARD_LINE_BREAK_SPACE', + 'AG_LINE_END', 'AG_HEADER_TIGHT_SPACE', 'AG_HIDE', 'AG_HIGHLIGHT', @@ -99,7 +101,6 @@ export const CLASS_OR_ID = genUpper2LowerKeyHash([ 'AG_INLINE_RULE', 'AG_LANGUAGE', 'AG_LANGUAGE_INPUT', - 'AG_LINE', 'AG_LINK', 'AG_LINK_IN_BRACKET', 'AG_LIST_ITEM', @@ -111,6 +112,7 @@ export const CLASS_OR_ID = genUpper2LowerKeyHash([ 'AG_RUBY_TEXT', 'AG_RUBY_RENDER', 'AG_SELECTED', + 'AG_SOFT_LINE_BREAK', 'AG_MATH_ERROR', 'AG_MATH_MARKER', 'AG_MATH_RENDER', @@ -159,7 +161,18 @@ export const DEFAULT_TURNDOWN_CONFIG = { codeBlockStyle: 'fenced', // fenced or indented fence: '```', // ``` or ~~~ emDelimiter: '*', // _ or * - strongDelimiter: '**' // ** or __ + strongDelimiter: '**', // ** or __ + blankReplacement (content, node, options) { + if (node && node.classList.contains('ag-soft-line-break')) { + return LINE_BREAK + } else if (node && node.classList.contains('ag-hard-line-break')) { + return ' ' + LINE_BREAK + } else if (node && node.classList.contains('ag-hard-line-break-sapce')) { + return '' + } else { + return node.isBlock ? '\n\n' : '' + } + } } export const FORMAT_MARKER_MAP = { diff --git a/src/muya/lib/contentState/arrowCtrl.js b/src/muya/lib/contentState/arrowCtrl.js index eecc002f..725cf05a 100644 --- a/src/muya/lib/contentState/arrowCtrl.js +++ b/src/muya/lib/contentState/arrowCtrl.js @@ -4,7 +4,7 @@ import selection from '../selection' // If the next block is header, put cursor after the `#{1,6} *` const adjustOffset = (offset, block, event) => { - if (/^h\d$/.test(block.type) && event.key === EVENT_KEYS.ArrowDown) { + if (/^span$/.test(block.type) && block.functionType === 'atxLine' && event.key === EVENT_KEYS.ArrowDown) { const match = /^\s{0,3}(?:#{1,6})(?:\s{1,}|$)/.exec(block.text) if (match) { return match[0].length diff --git a/src/muya/lib/contentState/backspaceCtrl.js b/src/muya/lib/contentState/backspaceCtrl.js index 9ee94ed9..ca45bad7 100644 --- a/src/muya/lib/contentState/backspaceCtrl.js +++ b/src/muya/lib/contentState/backspaceCtrl.js @@ -106,11 +106,32 @@ const backspaceCtrl = ContentState => { if (!start || !end) { return } + const startBlock = this.getBlock(start.key) const endBlock = this.getBlock(end.key) const maybeLastRow = this.getParent(endBlock) const startOutmostBlock = this.findOutMostBlock(startBlock) const endOutmostBlock = this.findOutMostBlock(endBlock) + // Just for fix delete the last `#` or all the atx heading cause error @fixme + if ( + start.key === end.key && + startBlock.type === 'span' && + startBlock.functionType === 'atxLine' + ) { + if ( + start.offset === 0 && end.offset === startBlock.text.length || + start.offset === end.offset && start.offset === 1 && startBlock.text === '#' + ) { + event.preventDefault() + startBlock.text = '' + this.cursor = { + start: { key: start.key, offset: 0 }, + end: { key: end.key, offset: 0 } + } + this.updateToParagraph(this.getParent(startBlock), startBlock) + return this.partialRender() + } + } // fix: #897 const { text } = startBlock const tokens = tokenizer(text) @@ -219,7 +240,7 @@ const backspaceCtrl = ContentState => { ) { const preBlock = this.getParent(parent) const pBlock = this.createBlock('p') - const lineBlock = this.createBlock('span', block.text) + const lineBlock = this.createBlock('span', { text: block.text }) const key = lineBlock.key const offset = 0 this.appendChild(pBlock, lineBlock) @@ -235,10 +256,8 @@ const backspaceCtrl = ContentState => { case 'mermaid': case 'sequence': case 'vega-lite': - referenceBlock = this.getParent(preBlock) - break case 'html': - referenceBlock = this.getParent(this.getParent(preBlock)) + referenceBlock = this.getParent(preBlock) break } this.insertBefore(pBlock, referenceBlock) diff --git a/src/muya/lib/contentState/clickCtrl.js b/src/muya/lib/contentState/clickCtrl.js index d11ad07d..8365d8c0 100644 --- a/src/muya/lib/contentState/clickCtrl.js +++ b/src/muya/lib/contentState/clickCtrl.js @@ -136,6 +136,7 @@ const clickCtrl = ContentState => { return } this.cursor = cursor + return this.partialRender() }) } else { diff --git a/src/muya/lib/contentState/codeBlockCtrl.js b/src/muya/lib/contentState/codeBlockCtrl.js index 99d08cb2..a70e98f1 100644 --- a/src/muya/lib/contentState/codeBlockCtrl.js +++ b/src/muya/lib/contentState/codeBlockCtrl.js @@ -72,27 +72,6 @@ const codeBlockCtrl = ContentState => { this.partialRender() } - ContentState.prototype.indentCodeBlockUpdate = function (block) { - const oldPBlock = this.getParent(block) - const codeBlock = this.createBlock('code') - const inputBlock = this.createBlock('span', '') - const preBlock = this.createBlock('pre') - - oldPBlock.children.forEach(child => { - child.lang = '' - child.functionType = 'codeLine' - child.text = child.text.replace(/^ {4}/, '') - this.appendChild(codeBlock, child) - }) - codeBlock.lang = preBlock.lang = '' - inputBlock.functionType = 'languageInput' - preBlock.functionType = 'indentcode' - this.appendChild(preBlock, inputBlock) - this.appendChild(preBlock, codeBlock) - this.insertBefore(preBlock, oldPBlock) - this.removeBlock(oldPBlock) - } - /** * [codeBlockUpdate if block updated to `pre` return true, else return false] */ @@ -108,9 +87,9 @@ const codeBlockCtrl = ContentState => { const match = CODE_UPDATE_REP.exec(text) if (match || lang) { const codeBlock = this.createBlock('code') - const firstLine = this.createBlock('span', code) + const firstLine = this.createBlock('span', { text: code }) const language = lang || (match ? match[1] : '') - const inputBlock = this.createBlock('span', language) + const inputBlock = this.createBlock('span', { text: language }) loadLanguage(language) inputBlock.functionType = 'languageInput' block.type = 'pre' diff --git a/src/muya/lib/contentState/containerCtrl.js b/src/muya/lib/contentState/containerCtrl.js index e48f3faa..447b7a92 100644 --- a/src/muya/lib/contentState/containerCtrl.js +++ b/src/muya/lib/contentState/containerCtrl.js @@ -4,44 +4,57 @@ const FUNCTION_TYPE_LANG = { 'flowchart': 'yaml', 'mermaid': 'yaml', 'sequence': 'yaml', - 'vega-lite': 'yaml' + 'vega-lite': 'yaml', + 'html': 'markup' } const containerCtrl = ContentState => { ContentState.prototype.createContainerBlock = function (functionType, value = '') { - const figureBlock = this.createBlock('figure') - figureBlock.functionType = functionType + const figureBlock = this.createBlock('figure', { + functionType + }) + const { preBlock, preview } = this.createPreAndPreview(functionType, value) this.appendChild(figureBlock, preBlock) this.appendChild(figureBlock, preview) - this.codeBlocks.set(preBlock.key, value) return figureBlock } ContentState.prototype.createPreAndPreview = function (functionType, value = '') { - const preBlock = this.createBlock('pre') - const codeBlock = this.createBlock('code') - preBlock.functionType = functionType - preBlock.lang = codeBlock.lang = FUNCTION_TYPE_LANG[functionType] + const lang = FUNCTION_TYPE_LANG[functionType] + const preBlock = this.createBlock('pre', { + functionType, + lang + }) + const codeBlock = this.createBlock('code', { + lang + }) + this.appendChild(preBlock, codeBlock) if (typeof value === 'string' && value) { value.replace(/^\s+/, '').split(LINE_BREAKS_REG).forEach(line => { - const codeLine = this.createBlock('span', line) - codeLine.functionType = 'codeLine' - codeLine.lang = FUNCTION_TYPE_LANG[functionType] + const codeLine = this.createBlock('span', { + text: line, + functionType: 'codeLine', + lang + }) + this.appendChild(codeBlock, codeLine) }) } else { - const emptyLine = this.createBlock('span') - emptyLine.functionType = 'codeLine' - emptyLine.lang = FUNCTION_TYPE_LANG[functionType] + const emptyLine = this.createBlock('span', { + functionType: 'codeLine', + lang + }) + this.appendChild(codeBlock, emptyLine) } - const preview = this.createBlock('div', '', false) - this.codeBlocks.set(preBlock.key, '') - preview.functionType = functionType + const preview = this.createBlock('div', { + editable: false, + functionType + }) return { preBlock, preview } } diff --git a/src/muya/lib/contentState/copyCutCtrl.js b/src/muya/lib/contentState/copyCutCtrl.js index 98852738..912d345b 100644 --- a/src/muya/lib/contentState/copyCutCtrl.js +++ b/src/muya/lib/contentState/copyCutCtrl.js @@ -101,6 +101,12 @@ const copyCutCtrl = ContentState => { hb.replaceWith(pre) } + // Just work for turndown, turndown will add `leading` and `traling` space in line-break. + const lineBreaks = wrapper.querySelectorAll('span.ag-soft-line-break, span.ag-hard-line-break') + for (const b of lineBreaks) { + b.innerHTML = '' + } + const mathBlock = wrapper.querySelectorAll(`figure.ag-container-block`) for (const mb of mathBlock) { const preElement = mb.querySelector('pre[data-role]') @@ -128,6 +134,7 @@ const copyCutCtrl = ContentState => { let htmlData = wrapper.innerHTML const textData = this.htmlToMarkdown(htmlData) + htmlData = marked(textData) return { html: htmlData, text: textData } } diff --git a/src/muya/lib/contentState/enterCtrl.js b/src/muya/lib/contentState/enterCtrl.js index aab699ed..59b18608 100644 --- a/src/muya/lib/contentState/enterCtrl.js +++ b/src/muya/lib/contentState/enterCtrl.js @@ -11,6 +11,7 @@ const getIndentSpace = text => { } const enterCtrl = ContentState => { + // TODO@jocs this function need opti. ContentState.prototype.chopBlockByCursor = function (block, key, offset) { const newBlock = this.createBlock('p') const { children } = block @@ -28,7 +29,7 @@ const enterCtrl = ContentState => { this.prependChild(newBlock, activeLine) } else if (offset < text.length) { activeLine.text = text.substring(0, offset) - const newLine = this.createBlock('span', text.substring(offset)) + const newLine = this.createBlock('span', { text: text.substring(offset) }) this.prependChild(newBlock, newLine) } return newBlock @@ -197,23 +198,39 @@ const enterCtrl = ContentState => { // handle `shift + enter` insert `soft line break` or `hard line break` // only cursor in `line block` can create `soft line break` and `hard line break` // handle line in code block - if ( - (event.shiftKey && block.type === 'span') || - (block.type === 'span' && block.functionType === 'codeLine') + if (event.shiftKey && block.type === 'span' && block.functionType === 'paragraphContent') { + let { offset } = start + const { text, key } = block + const indent = getIndentSpace(text) + block.text = text.substring(0, offset) + '\n' + indent + text.substring(offset) + + offset += 1 + indent.length + this.cursor = { + start: { key, offset }, + end: { key, offset } + } + return this.partialRender() + } else if ( + block.type === 'span' && block.functionType === 'codeLine' ) { const { text } = block const newLineText = text.substring(start.offset) const autoIndent = checkAutoIndent(text, start.offset) const indent = getIndentSpace(text) block.text = text.substring(0, start.offset) - const newLine = this.createBlock('span', `${indent}${newLineText}`) - newLine.functionType = block.functionType - newLine.lang = block.lang + const newLine = this.createBlock('span', { + text: `${indent}${newLineText}`, + functionType: block.functionType, + lang: block.lang + }) + this.insertAfter(newLine, block) let { key } = newLine let offset = indent.length if (autoIndent) { - const emptyLine = this.createBlock('span', indent + ' '.repeat(this.tabSize)) + const emptyLine = this.createBlock('span', { + text: indent + ' '.repeat(this.tabSize) + }) emptyLine.functionType = block.functionType emptyLine.lang = block.lang this.insertAfter(emptyLine, block) @@ -221,11 +238,6 @@ const enterCtrl = ContentState => { offset = indent.length + this.tabSize } - if (indent.length >= 4 && !block.preSibling) { - this.indentCodeBlockUpdate(block) - offset = offset - 4 - } - this.cursor = { start: { key, offset }, end: { key, offset } @@ -323,8 +335,14 @@ const enterCtrl = ContentState => { post = `${PREFIX} ${post}` } block.text = pre - newBlock = this.createBlock(type, post) - newBlock.headingStyle = block.headingStyle + newBlock = this.createBlock(type, { + headingStyle: block.headingStyle + }) + const headerContent = this.createBlock('span', { + text: post, + functionType: block.headingStyle === 'atx'? 'atxLine' : 'paragraphContent' + }) + this.appendChild(newBlock, headerContent) if (block.marker) { newBlock.marker = block.marker } diff --git a/src/muya/lib/contentState/htmlBlock.js b/src/muya/lib/contentState/htmlBlock.js index 834f0157..4121d0ff 100644 --- a/src/muya/lib/contentState/htmlBlock.js +++ b/src/muya/lib/contentState/htmlBlock.js @@ -2,61 +2,20 @@ import { VOID_HTML_TAGS, HTML_TAGS } from '../config' import { inlineRules } from '../parser/rules' const HTML_BLOCK_REG = /^<([a-zA-Z\d-]+)(?=\s|>)[^<>]*?>$/ -const LINE_BREAKS = /\n/ const htmlBlock = ContentState => { - ContentState.prototype.createCodeInHtml = function (code) { - const codeContainer = this.createBlock('div') - codeContainer.functionType = 'html' - const preview = this.createBlock('div', '', false) - preview.functionType = 'preview' - const preBlock = this.createBlock('pre') - const codeBlock = this.createBlock('code') - code.split(LINE_BREAKS).forEach(line => { - const codeLine = this.createBlock('span', line) - codeLine.functionType = 'codeLine' - codeLine.lang = 'markup' - this.appendChild(codeBlock, codeLine) - }) - this.codeBlocks.set(preBlock.key, code) - preBlock.lang = 'markup' - codeBlock.lang = 'markup' - preBlock.functionType = 'html' - this.codeBlocks.set(preBlock.key, code) - this.appendChild(preBlock, codeBlock) - this.appendChild(codeContainer, preBlock) - this.appendChild(codeContainer, preview) - return codeContainer - } - - ContentState.prototype.handleHtmlBlockClick = function (codeWrapper) { - const id = codeWrapper.id - const codeBlock = this.getBlock(id).children[0] - const key = codeBlock.key - const offset = 0 - this.cursor = { - start: { key, offset }, - end: { key, offset } - } - this.partialRender() - } - ContentState.prototype.createHtmlBlock = function (code) { const block = this.createBlock('figure') block.functionType = 'html' - const htmlBlock = this.createCodeInHtml(code) - this.appendChild(block, htmlBlock) + const { preBlock, preview } = this.createPreAndPreview('html', code) + this.appendChild(block, preBlock) + this.appendChild(block, preview) return block } ContentState.prototype.initHtmlBlock = function (block) { let htmlContent = '' - const text = block.type === 'p' - ? block.children.map((child => { - return child.text - })).join('\n').trim() - : block.text - + const text = block.children[0].text const matches = inlineRules.html_tag.exec(text) if (matches) { const tag = matches[3] @@ -83,9 +42,11 @@ const htmlBlock = ContentState => { block.functionType = 'html' block.text = htmlContent block.children = [] - const codeContainer = this.createCodeInHtml(htmlContent) - this.appendChild(block, codeContainer) - return codeContainer.children[0] // preBlock + const { preBlock, preview } = this.createPreAndPreview('html', htmlContent) + this.appendChild(block, preBlock) + this.appendChild(block, preview) + + return preBlock // preBlock } ContentState.prototype.updateHtmlBlock = function (block) { diff --git a/src/muya/lib/contentState/index.js b/src/muya/lib/contentState/index.js index fef8e59b..eb237fce 100644 --- a/src/muya/lib/contentState/index.js +++ b/src/muya/lib/contentState/index.js @@ -63,7 +63,6 @@ class ContentState { this.exemption = new Set() this.blocks = [ this.createBlockP() ] this.stateRender = new StateRender(muya) - this.codeBlocks = new Map() this.renderRange = [ null, null ] this.currentCursor = null // you'll select the outmost block of current cursor when you click the front icon. @@ -183,24 +182,32 @@ class ContentState { * A block in Mark Text present a paragraph(block syntax in GFM) or a line in paragraph. * a `span` block must in a `p block` or `pre block` and `p block`'s children must be `span` blocks. */ - createBlock (type = 'span', text = '', editable = true) { + createBlock (type = 'span', extras = {}) { const key = getUniqueId() - return { + const blockData = { key, + text: '', type, - text, - editable, + editable: true, parent: null, preSibling: null, nextSibling: null, children: [] } + + // give span block a default functionType `paragraphContent` + if (type === 'span' && !extras.functionType) { + blockData.functionType = 'paragraphContent' + } + + Object.assign(blockData, extras) + return blockData } createBlockP (text = '') { const pBlock = this.createBlock('p') - const lineBlock = this.createBlock('span', text) - this.appendChild(pBlock, lineBlock) + const contentBlock = this.createBlock('span', { text }) + this.appendChild(pBlock, contentBlock) return pBlock } diff --git a/src/muya/lib/contentState/inputCtrl.js b/src/muya/lib/contentState/inputCtrl.js index 8c0a239e..6308b1fa 100644 --- a/src/muya/lib/contentState/inputCtrl.js +++ b/src/muya/lib/contentState/inputCtrl.js @@ -32,7 +32,7 @@ const inputCtrl = ContentState => { // Input @ to quick insert paragraph ContentState.prototype.checkQuickInsert = function (block) { const { type, text, functionType } = block - if (type !== 'span' || functionType) return false + if (type !== 'span' || functionType !== 'paragraphContent') return false return /^@\S*$/.test(text) } @@ -88,6 +88,7 @@ const inputCtrl = ContentState => { const block = this.getBlock(key) const paragraph = document.querySelector(`#${key}`) let text = getTextContent(paragraph, [ CLASS_OR_ID['AG_MATH_RENDER'], CLASS_OR_ID['AG_RUBY_RENDER'] ]) + let needRender = false let needRenderAll = false if (oldStart.key !== oldEnd.key) { @@ -196,7 +197,26 @@ const inputCtrl = ContentState => { if (this.checkNotSameToken(block.text, text)) { needRender = true } - block.text = text + // Just work for `Shift + Enter` to create a soft and hard line break. + if ( + block.text.endsWith('\n') && + start.offset === text.length && + event.inputType === 'insertText' + ) { + block.text += event.data + start.offset++ + end.offset++ + } else if ( + block.text.length === oldStart.offset && + block.text[oldStart.offset - 2] === '\n' && + event.inputType === 'deleteContentBackward' + ) { + block.text = block.text.substring(0, oldStart.offset - 1) + start.offset = block.text.length + end.offset = block.text.length + } else { + block.text = text + } if (beginRules['reference_definition'].test(text)) { needRenderAll = true } @@ -226,7 +246,6 @@ const inputCtrl = ContentState => { // Update preview content of math block if (block && block.type === 'span' && block.functionType === 'codeLine') { needRender = true - this.updateCodeBlocks(block) } this.cursor = { start, end } diff --git a/src/muya/lib/contentState/paragraphCtrl.js b/src/muya/lib/contentState/paragraphCtrl.js index a69d6246..f250d833 100644 --- a/src/muya/lib/contentState/paragraphCtrl.js +++ b/src/muya/lib/contentState/paragraphCtrl.js @@ -68,12 +68,19 @@ const paragraphCtrl = ContentState => { ContentState.prototype.handleFrontMatter = function () { const firstBlock = this.blocks[0] if (firstBlock.type === 'pre' && firstBlock.functionType === 'frontmatter') return - const frontMatter = this.createBlock('pre') - const codeBlock = this.createBlock('code') - const emptyLine = this.createBlock('span') - frontMatter.lang = codeBlock.lang = emptyLine.lang = 'yaml' - emptyLine.functionType = 'codeLine' - frontMatter.functionType = 'frontmatter' + const lang = 'yaml' + const frontMatter = this.createBlock('pre', { + functionType: 'frontmatter', + lang + }) + const codeBlock = this.createBlock('code', { + lang + }) + const emptyLine = this.createBlock('span', { + functionType: 'codeLine', + lang + }) + this.appendChild(codeBlock, emptyLine) this.appendChild(frontMatter, codeBlock) this.insertBefore(frontMatter, firstBlock) @@ -87,14 +94,13 @@ const paragraphCtrl = ContentState => { ContentState.prototype.handleListMenu = function (paraType, insertMode) { const { start, end, affiliation } = this.selectionChange(this.cursor) - const { orderListMarker, bulletListMarker } = this + const { orderListMarker, bulletListMarker, preferLooseListItem } = this const [blockType, listType] = paraType.split('-') const isListed = affiliation.slice(0, 3).filter(b => /ul|ol/.test(b.type)) - const { preferLooseListItem } = this if (isListed.length && !insertMode) { const listBlock = isListed[0] - if (listType === isListed[0].listType) { + if (listType === listBlock.listType) { const listItems = listBlock.children listItems.forEach(listItem => { listItem.children.forEach(itemParagraph => { @@ -214,41 +220,74 @@ const paragraphCtrl = ContentState => { if (affiliation.length && affiliation[0].type === 'pre' && /code/.test(affiliation[0].functionType)) { const preBlock = affiliation[0] const codeLines = preBlock.children[1].children - this.codeBlocks.delete(preBlock.key) preBlock.type = 'p' preBlock.children = [] + const newParagraphBlock = this.createBlockP(codeLines.map(l => l.text).join('\n')) + this.insertBefore(newParagraphBlock, preBlock) + + this.removeBlock(preBlock) + const { start, end } = this.cursor + + const key = newParagraphBlock.children[0].key + let startOffset = 0 + let endOffset = 0 + let startStop = false + let endStop = false for (const line of codeLines) { - delete line.lang - delete line.functionType - this.appendChild(preBlock, line) + if (line.key !== start.key && !startStop) { + startOffset += line.text.length + 1 + } else { + startOffset += start.offset + startStop = true + } + if (line.key !== end.key && !endStop) { + endOffset += line.text.length + 1 + } else { + endOffset += end.offset + endStop = true + } } - delete preBlock.lang - delete preBlock.functionType this.cursor = { - start: this.cursor.start, - end: this.cursor.end + start: { key, offset: startOffset }, + end: { key, offset: endOffset } } } else { if (start.key === end.key) { if (startBlock.type === 'span') { - startBlock = this.getParent(startBlock) - startBlock.type = 'pre' - const codeBlock = this.createBlock('code') - const inputBlock = this.createBlock('span', '') - inputBlock.functionType = 'languageInput' - startBlock.functionType = 'fencecode' - startBlock.lang = codeBlock.lang = '' - const codeLines = startBlock.children - startBlock.children = [] - codeLines.forEach(line => { - line.functionType = 'codeLine' - line.lang = '' - this.appendChild(codeBlock, line) + const anchorBlock = this.getParent(startBlock) + const lang = '' + const preBlock = this.createBlock('pre', { + functionType: 'fencecode', + lang }) - this.appendChild(startBlock, inputBlock) - this.appendChild(startBlock, codeBlock) + + const codeBlock = this.createBlock('code', { + lang: '' + }) + + const inputBlock = this.createBlock('span', { + functionType: 'languageInput' + }) + + const codes = startBlock.text.split('\n') + + for (const code of codes) { + const codeLine = this.createBlock('span', { + text: code, + functionType: 'codeLine', + lang + }) + this.appendChild(codeBlock, codeLine) + } + + this.appendChild(preBlock, inputBlock) + this.appendChild(preBlock, codeBlock) + this.insertBefore(preBlock, anchorBlock) + + this.removeBlock(anchorBlock) + const { key } = inputBlock const offset = 0 @@ -266,21 +305,31 @@ const paragraphCtrl = ContentState => { const { parent, startIndex, endIndex } = this.getCommonParent() const children = parent ? parent.children : this.blocks const referBlock = children[endIndex] - const preBlock = this.createBlock('pre') - const codeBlock = this.createBlock('code') - preBlock.functionType = 'fencecode' - preBlock.lang = codeBlock.lang = '' + const lang = '' + const preBlock = this.createBlock('pre', { + functionType: 'fencecode', + lang + }) + const codeBlock = this.createBlock('code', { + lang + }) + const listIndentation = this.listIndentation const markdown = new ExportMarkdown(children.slice(startIndex, endIndex + 1), listIndentation).generate() markdown.split(LINE_BREAKS_REG).forEach(text => { - const codeLine = this.createBlock('span', text) - codeLine.lang = '' - codeLine.functionType = 'codeLine' + const codeLine = this.createBlock('span', { + text, + lang, + functionType: 'codeLine' + }) + this.appendChild(codeBlock, codeLine) }) - const inputBlock = this.createBlock('span', '') - inputBlock.functionType = 'languageInput' + const inputBlock = this.createBlock('span', { + functionType: 'languageInput' + }) + this.appendChild(preBlock, inputBlock) this.appendChild(preBlock, codeBlock) this.insertAfter(preBlock, referBlock) @@ -392,7 +441,7 @@ const paragraphCtrl = ContentState => { ContentState.prototype.updateParagraph = function (paraType, insertMode = false) { const { start, end } = this.cursor const block = this.getBlock(start.key) - const { type, text, functionType } = block + const { text } = block switch (paraType) { case 'front-matter': { @@ -445,15 +494,19 @@ const paragraphCtrl = ContentState => { case 'degrade heading': case 'paragraph': { if (start.key !== end.key) return - const [, hash, partText] = /(^#*\s*)(.*)/.exec(text) + const headingStyle = DEFAULT_TURNDOWN_CONFIG.headingStyle + const parent = this.getParent(block) + // \u00A0 is   + const [, hash, partText] = /(^ {0,3}#*[ \u00A0]*)([\s\S]*)/.exec(text) let newLevel = 0 // 1, 2, 3, 4, 5, 6 let newType = 'p' let key + if (/\d/.test(paraType)) { newLevel = Number(paraType.split(/\s/)[1]) newType = `h${newLevel}` } else if (paraType === 'upgrade heading' || paraType === 'degrade heading') { - const currentLevel = getCurrentLevel(type) + const currentLevel = getCurrentLevel(parent.type) newLevel = currentLevel if (paraType === 'upgrade heading' && currentLevel !== 1) { if (currentLevel === 0) newLevel = 6 @@ -475,48 +528,35 @@ const paragraphCtrl = ContentState => { ? '#'.repeat(newLevel) + `${String.fromCharCode(160)}${partText}` //   code: 160 : partText - if (block.type === 'span' && newType !== 'p') { - const header = this.createBlock(newType, newText) - header.headingStyle = DEFAULT_TURNDOWN_CONFIG.headingStyle - key = header.key - const parent = this.getParent(block) - if (this.isOnlyChild(block)) { - this.insertBefore(header, parent) - this.removeBlock(parent) - } else if (this.isFirstChild(block)) { - this.insertBefore(header, parent) - this.removeBlock(block) - } else if (this.isLastChild(block)) { - this.insertAfter(header, parent) - this.removeBlock(block) - } else { - const pBlock = this.createBlock('p') - let nextSibling = this.getNextSibling(block) - while (nextSibling) { - this.appendChild(pBlock, nextSibling) - const oldNextSibling = nextSibling - nextSibling = this.getNextSibling(nextSibling) - this.removeBlock(oldNextSibling) - } - this.removeBlock(block) - this.insertAfter(header, parent) - this.insertAfter(pBlock, header) - } - } else if (/^h/.test(block.type) && newType === 'p') { + // No change + if (newType === 'p' && parent.type === newType) { + return + } + // No change + if (newType !== 'p' && parent.type === newType && parent.headingStyle === headingStyle) { + return + } + + if (newType !== 'p') { + const header = this.createBlock(newType, { + headingStyle + }) + const headerContent = this.createBlock('span', { + text: headingStyle === 'atx'? newText.replace(/\n/g, ' ') : newText, + functionType: headingStyle === 'atx'? 'atxLine' : 'paragraphContent' + }) + this.appendChild(header, headerContent) + key = headerContent.key + + this.insertBefore(header, parent) + this.removeBlock(parent) + } else { const pBlock = this.createBlockP(newText) key = pBlock.children[0].key - this.insertAfter(pBlock, block) - this.removeBlock(block) - } else if (type === 'span' && !functionType && newType === 'p') { - // The original is a paragraph, the new type is also paragraph, no need to update. - return - } else { - const newHeader = this.createBlock(newType, newText) - newHeader.headingStyle = DEFAULT_TURNDOWN_CONFIG.headingStyle - key = newHeader.key - this.insertAfter(newHeader, block) - this.removeBlock(block) + this.insertAfter(pBlock, parent) + this.removeBlock(parent) } + this.cursor = { start: { key, offset: startOffset }, end: { key, offset: endOffset } @@ -527,7 +567,11 @@ const paragraphCtrl = ContentState => { const pBlock = this.createBlockP() const archor = block.type === 'span' ? this.getParent(block) : block const hrBlock = this.createBlock('hr') - hrBlock.text = '---' + const thematicContent = this.createBlock('span', { + functionType: 'thematicBreakLine', + text: '---' + }) + this.appendChild(hrBlock, thematicContent) this.insertAfter(hrBlock, archor) this.insertAfter(pBlock, hrBlock) if (!text) { @@ -562,42 +606,23 @@ const paragraphCtrl = ContentState => { const { start, end } = this.cursor // if cursor is not in one line or paragraph, can not insert paragraph if (start.key !== end.key) return - let block = this.getBlock(start.key) + const block = this.getBlock(start.key) + let anchor = null if (outMost) { - block = this.findOutMostBlock(block) - } else if (block.type === 'span' && !block.functionType) { - block = this.getParent(block) - } else if (block.type === 'span' && block.functionType === 'codeLine') { - const preBlock = this.getParent(this.getParent(block)) - switch (preBlock.functionType) { - case 'fencecode': - case 'indentcode': - case 'frontmatter': { - // You can not insert paragraph before frontmatter - if (preBlock.functionType === 'frontmatter' && location === 'before') { - return - } - block = preBlock - break - } - case 'html': { - block = this.getParent(this.getParent(preBlock)) - break - } - case 'multiplemath': { - block = this.getParent(preBlock) - break - } - } - } else if (/th|td/.test(block.type)) { - // get figure block from table cell - block = this.getParent(this.getParent(this.getParent(this.getParent(block)))) + anchor = this.findOutMostBlock(block) + } else { + anchor = this.getAnchor(block) } + // You can not insert paragraph before frontmatter + if (!anchor || anchor && anchor.functionType === 'frontmatter' && location === 'before') { + return + } + const newBlock = this.createBlockP(text) if (location === 'before') { - this.insertBefore(newBlock, block) + this.insertBefore(newBlock, anchor) } else { - this.insertAfter(newBlock, block) + this.insertAfter(newBlock, anchor) } const { key } = newBlock.children[0] const offset = text.length @@ -634,12 +659,7 @@ const paragraphCtrl = ContentState => { // if copied block has pre block: html, multiplemath, vega-light, mermaid, flowchart, sequence... const copiedBlock = this.copyBlock(startOutmostBlock) this.insertAfter(copiedBlock, startOutmostBlock) - if (copiedBlock.type === 'figure' && copiedBlock.functionType) { - const preBlock = this.getPreBlock(copiedBlock) - if (preBlock) { - this.updateCodeBlocks(preBlock.children[0].children[0]) - } - } + const cursorBlock = this.firstInDescendant(copiedBlock) // set cursor at the end of the first descendant of the duplicated block. const { key, text } = cursorBlock diff --git a/src/muya/lib/contentState/pasteCtrl.js b/src/muya/lib/contentState/pasteCtrl.js index 0aa59770..005c1d67 100644 --- a/src/muya/lib/contentState/pasteCtrl.js +++ b/src/muya/lib/contentState/pasteCtrl.js @@ -8,21 +8,33 @@ const pasteCtrl = ContentState => { // check paste type: `MERGE` or `NEWLINE` ContentState.prototype.checkPasteType = function (start, fragment) { const fragmentType = fragment.type - if (start.type === 'span') { - start = this.getParent(start) - } - if (fragmentType === 'p') return 'MERGE' - if (fragmentType === 'blockquote') return 'NEWLINE' - let parent = this.getParent(start) - if (parent && parent.type === 'li') parent = this.getParent(parent) - let startType = start.type - if (start.type === 'p') { - startType = parent ? parent.type : startType - } - if (LIST_REG.test(fragmentType) && LIST_REG.test(startType)) { + const parent = this.getParent(start) + + if (fragmentType === 'p') { return 'MERGE' + } else if (/^h\d/.test(fragmentType)) { + if (start.text) { + return 'MERGE' + } else { + return 'NEWLINE' + } + } else if (LIST_REG.test(fragmentType)) { + const listItem = this.getParent(parent) + const list = listItem && listItem.type === 'li' ? this.getParent(listItem) : null + if (list) { + if ( + list.listType === fragment.listType && + listItem.bulletMarkerOrDelimiter === fragment.children[0].bulletMarkerOrDelimiter + ) { + return 'MERGE' + } else { + return 'NEWLINE' + } + } else { + return 'NEWLINE' + } } else { - return startType === fragmentType ? 'MERGE' : 'NEWLINE' + return 'NEWLINE' } } @@ -137,7 +149,7 @@ const pasteCtrl = ContentState => { startBlock.text = prePartText + line } else { line = i === textList.length - 1 ? line + postPartText : line - const lineBlock = this.createBlock('span', line) + const lineBlock = this.createBlock('span', { text: line }) lineBlock.functionType = startBlock.functionType lineBlock.lang = startBlock.lang this.insertAfter(lineBlock, referenceBlock) @@ -161,7 +173,6 @@ const pasteCtrl = ContentState => { end: { key, offset } } } - this.updateCodeBlocks(startBlock) return this.partialRender() } @@ -179,41 +190,12 @@ const pasteCtrl = ContentState => { // handle copyAsHtml if (copyType === 'copyAsHtml') { - // already handle code block above - if (startBlock.type === 'span' && startBlock.nextSibling) { - const afterParagraph = this.createBlock('p') - let temp = startBlock - const removeCache = [] - while (temp.nextSibling) { - temp = this.getBlock(temp.nextSibling) - this.appendChild(afterParagraph, temp) - removeCache.push(temp) - } - removeCache.forEach(b => this.removeBlock(b)) - this.insertAfter(afterParagraph, parent) - startBlock.nextSibling = null - } switch (type) { case 'normal': { - const htmlBlock = this.createBlock('p') - const lines = text.trim().split(LINE_BREAKS_REG).map(line => this.createBlock('span', line)) - for (const line of lines) { - this.appendChild(htmlBlock, line) - } - if (startBlock.type === 'span') { - this.insertAfter(htmlBlock, parent) - } else { - this.insertAfter(htmlBlock, startBlock) - } - if ( - startBlock.type === 'span' && startBlock.text.length === 0 && this.isOnlyChild(startBlock) - ) { - this.removeBlock(parent) - } + const htmlBlock = this.createBlockP(text.trim()) + this.insertAfter(htmlBlock, parent) + this.removeBlock(parent) // handler heading - if (startBlock.text.length === 0 && startBlock.type !== 'span') { - this.removeBlock(startBlock) - } this.insertHtmlBlock(htmlBlock) break } @@ -222,24 +204,16 @@ const pasteCtrl = ContentState => { let htmlBlock = null if (!startBlock.text || lines.length > 1) { - htmlBlock = this.createBlock('p') - ;(startBlock.text ? lines.slice(1) : lines).map(line => this.createBlock('span', line)) - .forEach(l => { - this.appendChild(htmlBlock, l) - }) + htmlBlock = this.createBlockP((startBlock.text ? lines.slice(1) : lines).join('\n')) } if (htmlBlock) { - if (startBlock.type === 'span') { - this.insertAfter(htmlBlock, parent) - } else { - this.insertAfter(htmlBlock, startBlock) - } + this.insertAfter(htmlBlock, parent) this.insertHtmlBlock(htmlBlock) } if (startBlock.text) { appendHtml(lines[0]) } else { - this.removeBlock(startBlock.type === 'span' ? parent : startBlock) + this.removeBlock(parent) } break } @@ -300,7 +274,6 @@ const pasteCtrl = ContentState => { if (liChildren[0].type === 'p') { // TODO @JOCS startBlock.text += liChildren[0].children[0].text - liChildren[0].children.slice(1).forEach(c => this.appendChild(parent, c)) const tail = liChildren.slice(1) if (tail.length) { tail.forEach(t => { @@ -323,33 +296,21 @@ const pasteCtrl = ContentState => { this.insertAfter(block, target) target = block }) - } else { - if (firstFragment.type === 'p') { - if (/^h\d$/.test(startBlock.type)) { - // handle paste into header - startBlock.text += firstFragment.children[0].text - if (firstFragment.children.length > 1) { - const newParagraph = this.createBlock('p') - firstFragment.children.slice(1).forEach(line => { - this.appendChild(newParagraph, line) - }) - this.insertAfter(newParagraph, startBlock) - } - } else { - startBlock.text += firstFragment.children[0].text - firstFragment.children.slice(1).forEach(line => { - if (startBlock.functionType) line.functionType = startBlock.functionType - if (startBlock.lang) line.lang = startBlock.lang - this.appendChild(parent, line) - }) + } else if (firstFragment.type === 'p' || /^h\d/.test(firstFragment.type)) { + const text = firstFragment.children[0].text + const lines = text.split('\n') + let target = parent + if (parent.headingStyle === 'atx') { + startBlock.text += lines[0] + if (lines.length > 1) { + const pBlock = this.createBlockP(lines.slice(1).join('\n')) + this.insertAfter(parent, pBlock) + target = pBlock } - } else if (/^h\d$/.test(firstFragment.type)) { - startBlock.text += firstFragment.text.split(/\s+/)[1] } else { - startBlock.text += firstFragment.text + startBlock.text += text } - - let target = /^h\d$/.test(startBlock.type) ? startBlock : parent + tailFragments.forEach(block => { this.insertAfter(block, target) target = block @@ -358,14 +319,13 @@ const pasteCtrl = ContentState => { break } case 'NEWLINE': { - let target = startBlock.type === 'span' ? parent : startBlock + let target = parent stateFragments.forEach(block => { this.insertAfter(block, target) target = block }) if (startBlock.text.length === 0) { - this.removeBlock(startBlock) - if (this.isOnlyChild(startBlock) && startBlock.type === 'span') this.removeBlock(parent) + this.removeBlock(parent) } break } @@ -380,10 +340,7 @@ const pasteCtrl = ContentState => { offset = startBlock.text.length - cacheText.length cursorBlock = startBlock } - // TODO @Jocs duplicate with codes in updateCtrl.js - if (cursorBlock && cursorBlock.type === 'span' && cursorBlock.functionType === 'codeLine') { - this.updateCodeBlocks(cursorBlock) - } + this.cursor = { start: { key, offset diff --git a/src/muya/lib/contentState/tableBlockCtrl.js b/src/muya/lib/contentState/tableBlockCtrl.js index 9fb2f255..76860b71 100644 --- a/src/muya/lib/contentState/tableBlockCtrl.js +++ b/src/muya/lib/contentState/tableBlockCtrl.js @@ -18,7 +18,9 @@ const tableBlockCtrl = ContentState => { const rowBlock = this.createBlock('tr') i === 0 ? this.appendChild(tHead, rowBlock) : this.appendChild(tBody, rowBlock) for (j = 0; j < columns; j++) { - const cell = this.createBlock(i === 0 ? 'th' : 'td', headerTexts && i === 0 ? headerTexts[j] : '') + const cell = this.createBlock(i === 0 ? 'th' : 'td', { + text: headerTexts && i === 0 ? headerTexts[j] : '' + }) this.appendChild(rowBlock, cell) cell.align = '' cell.column = j @@ -41,24 +43,18 @@ const tableBlockCtrl = ContentState => { ContentState.prototype.getAnchor = function (block) { const { type, functionType } = block - switch (true) { - case /^span$/.test(type): { - if (!functionType) { - return this.closest(block, 'p') - } else if (functionType === 'codeLine') { + switch (type) { + case 'span': + if (functionType === 'codeLine') { return this.closest(block, 'figure') || this.closest(block, 'pre') + } else { + return this.getParent(block) } - return null - } - case /^(th|td)$/.test(type): { + + case 'th': + case 'td': return this.closest(block, 'figure') - } - case /^h\d$/.test(type): { - return block - } - case /hr/.test(type): { - return block - } + default: return null } @@ -74,7 +70,7 @@ const tableBlockCtrl = ContentState => { if (!anchor) return this.insertAfter(figureBlock, anchor) - if (anchor.type === 'p' && !endBlock.text) { + if (/p|h\d/.test(anchor.type) && !endBlock.text) { this.removeBlock(anchor) } this.appendChild(figureBlock, table) diff --git a/src/muya/lib/contentState/updateCtrl.js b/src/muya/lib/contentState/updateCtrl.js index f3b69e9a..a5b21a45 100644 --- a/src/muya/lib/contentState/updateCtrl.js +++ b/src/muya/lib/contentState/updateCtrl.js @@ -3,13 +3,14 @@ import { conflict } from '../utils' import { CLASS_OR_ID } from '../config' const INLINE_UPDATE_FRAGMENTS = [ - '^([*+-]\\s)', // Bullet list - '^(\\[[x\\s]{1}\\]\\s)', // Task list - '^(\\d{1,9}(?:\\.|\\))\\s)', // Order list - '^\\s{0,3}(#{1,6})(?=\\s{1,}|$)', // ATX headings - '^\\s{0,3}(\\={3,}|\\-{3,})(?=\\s{1,}|$)', // Setext headings - '^(>).+', // Block quote - '^\\s{0,3}((?:\\*\\s*\\*\\s*\\*|-\\s*-\\s*-|_\\s*_\\s*_)[\\s\\*\\-\\_]*)$' // Thematic break + '(?:^|\n) {0,3}([*+-] {1,4})', // Bullet list + '(?:^|\n)(\\[[x ]{1}\\] {1,4})', // Task list + '(?:^|\n) {0,3}(\\d{1,9}(?:\\.|\\)) {1,4})', // Order list + '(?:^|\n) {0,3}(#{1,6})(?=\\s{1,}|$)', // ATX headings + '^(?:[\\s\\S]+?)\\n {0,3}(\\={3,}|\\-{3,})(?= {1,}|$)', // Setext headings **match from beginning** + '(?:^|\n) {0,3}(>).+', // Block quote + '^( {4,})', // Indent code **match from beginning** + '(?:^|\n) {0,3}((?:\\* *\\* *\\*|- *- *-|_ *_ *_)[ \\*\\-\\_]*)$' // Thematic break ] const INLINE_UPDATE_REG = new RegExp(INLINE_UPDATE_FRAGMENTS.join('|'), 'i') @@ -21,11 +22,6 @@ const updateCtrl = ContentState => { 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.checkSameMarkerOrDelimiter = function (list, markerOrDelimiter) { @@ -40,9 +36,10 @@ const updateCtrl = ContentState => { const endBlock = this.getBlock(cEnd ? cEnd.key : focus.key) const startOffset = cStart ? cStart.offset : anchor.offset const endOffset = cEnd ? cEnd.offset : focus.offset + const NO_NEED_TOKEN_REG = /text|hard_line_break|soft_line_break/ for (const token of tokenizer(startBlock.text, undefined, undefined, labels)) { - if (token.type === 'text') continue + if (NO_NEED_TOKEN_REG.test(token.type)) continue const { start, end } = token.range const textLen = startBlock.text.length if ( @@ -52,7 +49,7 @@ const updateCtrl = ContentState => { } } for (const token of tokenizer(endBlock.text, undefined, undefined, labels)) { - if (token.type === 'text') continue + if (NO_NEED_TOKEN_REG.test(token.type)) continue const { start, end } = token.range const textLen = endBlock.text.length if ( @@ -65,34 +62,35 @@ const updateCtrl = ContentState => { return false } + /** + * block must be span block. + */ ContentState.prototype.checkInlineUpdate = function (block) { // table cell can not have blocks in it if (/th|td|figure/.test(block.type)) return false if (/codeLine|languageInput/.test(block.functionType)) return false - // line in paragraph can also update to other block. So comment bellow code. - // if (block.type === 'span' && block.preSibling) return false - const hasPreLine = !!(block.type === 'span' && block.preSibling) + let line = null const { text } = block if (block.type === 'span') { line = block block = this.getParent(block) } - const parent = this.getParent(block) - const [match, bullet, tasklist, order, atxHeader, setextHeader, blockquote, hr] = text.match(INLINE_UPDATE_REG) || [] + const listItem = this.getParent(block) + const [ + match, bullet, tasklist, order, atxHeader, + setextHeader, blockquote, indentCode, hr + ] = text.match(INLINE_UPDATE_REG) || [] switch (true) { - case ( - (!!hr && new Set(hr.split('').filter(i => /\S/.test(i))).size === 1) || - (!!setextHeader && !hasPreLine) - ): - return this.updateHr(block, hr || setextHeader) + case (!!hr && new Set(hr.split('').filter(i => /\S/.test(i))).size === 1): + return this.updateHr(block, hr, line) case !!bullet: return this.updateList(block, 'bullet', bullet, line) // only `bullet` list item can be update to `task` list item - case !!tasklist && parent && parent.listItemType === 'bullet': + case !!tasklist && listItem && listItem.listItemType === 'bullet': return this.updateTaskListItem(block, 'tasklist', tasklist) case !!order: @@ -101,75 +99,112 @@ const updateCtrl = ContentState => { case !!atxHeader: return this.updateAtxHeader(block, atxHeader, line) - case !!setextHeader && hasPreLine: + case !!setextHeader: return this.updateSetextHeader(block, setextHeader, line) case !!blockquote: return this.updateBlockQuote(block, line) + case !!indentCode: + return this.updateIndentCode(block, line) + case !match: default: - return this.updateToParagraph(block) + return this.updateToParagraph(block, line) } } - // 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 + // Thematic break + ContentState.prototype.updateHr = function (block, marker, line) { + // If the block is already thematic break, no need to update. + if (block.type === 'hr') return null + const text = line.text + const lines = text.split('\n') + const preParagraphLines = [] + let thematicLine = '' + const postParagraphLines = [] + let thematicLineHasPushed = false + + for (const l of lines) { + if (/ {0,3}(?:\\* *\\* *\\*|- *- *-|_ *_ *_)[ \\*\\-\\_]*$/.test(l) && !thematicLineHasPushed) { + thematicLine = l + thematicLineHasPushed = true + } else if (!thematicLineHasPushed) { + preParagraphLines.push(l) + } else { + postParagraphLines.push(l) + } } - return null + + const thematicBlock = this.createBlock('hr') + const thematicLineBlock = this.createBlock('span', { + text: thematicLine, + functionType: 'thematicBreakLine' + }) + this.appendChild(thematicBlock, thematicLineBlock) + this.insertBefore(thematicBlock, block) + if (preParagraphLines.length) { + const preBlock = this.createBlockP(preParagraphLines.join('\n')) + this.insertBefore(preBlock, thematicBlock) + } + if (postParagraphLines.length) { + const postBlock = this.createBlockP(postParagraphLines.join('\n')) + this.insertAfter(postBlock, thematicBlock) + } + + this.removeBlock(block) + const { start, end } = this.cursor + const key = thematicBlock.children[0].key + this.cursor = { + start: { key, offset: start.offset }, + end: { key, offset: end.offset } + } + return thematicBlock } ContentState.prototype.updateList = function (block, type, marker = '', line) { - if (block.type === 'span') { - block = this.getParent(block) - } - const cleanMarker = marker ? marker.trim() : null const { preferLooseListItem } = this - const parent = this.getParent(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') + const newListItemBlock = this.createBlock('li') + const LIST_ITEM_REG = /^ {0,3}(?:[*+-]|\d{1,9}(?:\.|\))) {0,4}/ + const text = line.text + const lines = text.split('\n') - if (/^h\d$/.test(block.type)) { - delete block.marker - delete block.headingStyle - block.type = 'p' - block.children = [] - const line = this.createBlock('span', block.text.substring(marker.length)) - block.text = '' - this.appendChild(block, line) - } else { - line.text = line.text.substring(marker.length) - const paragraphBefore = this.createBlock('p') - const index = block.children.indexOf(line) - if (index !== 0) { - const removeCache = [] - for (const child of block.children) { - if (child === line) break - removeCache.push(child) - } - removeCache.forEach(c => { - this.removeBlock(c) - this.appendChild(paragraphBefore, c) - }) - this.insertBefore(paragraphBefore, block) + const preParagraphLines = [] + const listItemLines = [] + let isPushedListItemLine = false + for (const l of lines) { + if (LIST_ITEM_REG.test(l) && !isPushedListItemLine) { + listItemLines.push(l.replace(LIST_ITEM_REG, '')) + isPushedListItemLine = true + } else if (!isPushedListItemLine) { + preParagraphLines.push(l) + } else { + listItemLines.push(l) } } + const pBlock = this.createBlockP(listItemLines.join('\n')) + this.insertBefore(pBlock, block) + + if (preParagraphLines.length > 0) { + const preParagraphBlock = this.createBlockP(preParagraphLines.join('\n')) + this.insertBefore(preParagraphBlock, pBlock) + } + + this.removeBlock(block) + + // important! + block = pBlock + const preSibling = this.getPreSibling(block) const nextSibling = this.getNextSibling(block) - newBlock.listItemType = type - newBlock.isLooseListItem = preferLooseListItem + newListItemBlock.listItemType = type + newListItemBlock.isLooseListItem = preferLooseListItem let bulletMarkerOrDelimiter if (type === 'order') { @@ -178,7 +213,7 @@ const updateCtrl = ContentState => { const { bulletListMarker } = this bulletMarkerOrDelimiter = marker ? marker.charAt(0) : bulletListMarker } - newBlock.bulletMarkerOrDelimiter = bulletMarkerOrDelimiter + newListItemBlock.bulletMarkerOrDelimiter = bulletMarkerOrDelimiter // Special cases for CommonMark 264 and 265: Changing the bullet or ordered list delimiter starts a new list. // Same list type or new list @@ -188,7 +223,7 @@ const updateCtrl = ContentState => { nextSibling && this.checkSameMarkerOrDelimiter(nextSibling, bulletMarkerOrDelimiter) ) { - this.appendChild(preSibling, newBlock) + this.appendChild(preSibling, newListItemBlock) const partChildren = nextSibling.children.splice(0) partChildren.forEach(b => this.appendChild(preSibling, b)) this.removeBlock(nextSibling) @@ -199,7 +234,7 @@ const updateCtrl = ContentState => { preSibling && this.checkSameMarkerOrDelimiter(preSibling, bulletMarkerOrDelimiter) ) { - this.appendChild(preSibling, newBlock) + this.appendChild(preSibling, newListItemBlock) this.removeBlock(block) const isLooseListItem = preSibling.children.some(c => c.isLooseListItem) preSibling.children.forEach(c => c.isLooseListItem = isLooseListItem) @@ -207,67 +242,59 @@ const updateCtrl = ContentState => { nextSibling && this.checkSameMarkerOrDelimiter(nextSibling, bulletMarkerOrDelimiter) ) { - this.insertBefore(newBlock, nextSibling.children[0]) + this.insertBefore(newListItemBlock, nextSibling.children[0]) this.removeBlock(block) const isLooseListItem = nextSibling.children.some(c => c.isLooseListItem) nextSibling.children.forEach(c => c.isLooseListItem = isLooseListItem) - } else if ( - // todo@jocs remove this if in 0.15.xx - parent && - parent.listType === type && - this.checkSameLooseType(parent, preferLooseListItem) - ) { - this.insertBefore(newBlock, block) - this.removeBlock(block) } else { // Create a new list when changing list type, bullet or list delimiter - const listBlock = this.createBlock(wrapperTag) - listBlock.listType = type + const listBlock = this.createBlock(wrapperTag, { + listType: type + }) + if (wrapperTag === 'ol') { const start = cleanMarker ? cleanMarker.slice(0, -1) : 1 listBlock.start = /^\d+$/.test(start) ? start : 1 } - this.appendChild(listBlock, newBlock) + this.appendChild(listBlock, newListItemBlock) this.insertBefore(listBlock, block) this.removeBlock(block) } // key point - this.appendChild(newBlock, block) - const TASK_LIST_REG = /^\[[x ]\] /i + this.appendChild(newListItemBlock, block) + const TASK_LIST_REG = /^\[[x ]\] {1,4}/i const listItemText = block.children[0].text + const { key } = block.children[0] + const delta = marker.length + preParagraphLines.join('\n').length + 1 + this.cursor = { + start: { + key, + offset: Math.max(0, startOffset - delta) + }, + end: { + key, + offset: Math.max(0, endOffset - delta) + } + } if (TASK_LIST_REG.test(listItemText)) { const [,,tasklist,,,,] = listItemText.match(INLINE_UPDATE_REG) || [] return this.updateTaskListItem(block, 'tasklist', tasklist) } else { - const { key } = block.children[0] - 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 checkbox = this.createBlock('input', { + checked + }) 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' @@ -277,16 +304,21 @@ const updateCtrl = ContentState => { if (this.isOnlyChild(parent)) { grandpa.listType = 'task' } else if (this.isFirstChild(parent) || this.isLastChild(parent)) { - taskListWrapper = this.createBlock('ul') - taskListWrapper.listType = 'task' + taskListWrapper = this.createBlock('ul', { + 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' + taskListWrapper = this.createBlock('ul', { + listType: 'task' + }) + + const bulletListWrapper = this.createBlock('ul', { + listType: 'bullet' + }) let preSibling = this.getPreSibling(parent) while (preSibling) { @@ -319,145 +351,246 @@ const updateCtrl = ContentState => { return taskListWrapper || grandpa } + // ATX heading doesn't support soft line break and hard line break. ContentState.prototype.updateAtxHeader = function (block, header, line) { const newType = `h${header.length}` - const text = line ? line.text : block.text - if (line) { - const index = block.children.indexOf(line) - const header = this.createBlock(newType, text) - header.headingStyle = 'atx' - this.insertBefore(header, block) - const paragraphBefore = this.createBlock('p') - const paragraphAfter = this.createBlock('p') - let i = 0 - const len = block.children.length - for (i; i < len; i++) { - const child = block.children[i] - if (i < index) { - this.appendChild(paragraphBefore, child) - } else if (i > index) { - this.appendChild(paragraphAfter, child) - } - } - if (paragraphBefore.children.length) { - this.insertBefore(paragraphBefore, header) - } - if (paragraphAfter.children.length) { - this.insertAfter(paragraphAfter, header) - } - this.removeBlock(block) - this.cursor.start.key = this.cursor.end.key = header.key - return header - } else { - if (block.type === newType && block.headingStyle === 'atx') { - return null - } - block.headingStyle = 'atx' - block.type = newType - block.text = text - block.children.length = 0 - this.cursor.start.key = this.cursor.end.key = block.key - return block + const headingStyle = 'atx' + if (block.type === newType && block.headingStyle === headingStyle) { + return null } + const text = line.text + const lines = text.split('\n') + const preParagraphLines = [] + let atxLine = '' + const postParagraphLines = [] + let atxLineHasPushed = false + + for (const l of lines) { + if (/^ {0,3}#{1,6}(?=\s{1,}|$)/.test(l) && !atxLineHasPushed) { + atxLine = l + atxLineHasPushed = true + } else if (!atxLineHasPushed) { + preParagraphLines.push(l) + } else { + postParagraphLines.push(l) + } + } + + const atxBlock = this.createBlock(newType, { + headingStyle + }) + const atxLineBlock = this.createBlock('span', { + text: atxLine, + functionType: 'atxLine' + }) + this.appendChild(atxBlock, atxLineBlock) + this.insertBefore(atxBlock, block) + if (preParagraphLines.length) { + const preBlock = this.createBlockP(preParagraphLines.join('\n')) + this.insertBefore(preBlock, atxBlock) + } + if (postParagraphLines.length) { + const postBlock = this.createBlockP(postParagraphLines.join('\n')) + this.insertAfter(postBlock, atxBlock) + } + + this.removeBlock(block) + + const { start, end } = this.cursor + const key = atxBlock.children[0].key + this.cursor = { + start: { key, offset: start.offset }, + end: { key, offset: end.offset } + } + return atxBlock } ContentState.prototype.updateSetextHeader = function (block, marker, line) { const newType = /=/.test(marker) ? 'h1' : 'h2' - const header = this.createBlock(newType) - header.headingStyle = 'setext' - header.marker = marker - const index = block.children.indexOf(line) - let i = 0 - let text = '' - for (i; i < index; i++) { - text += `${block.children[i].text}\n` + const headingStyle = 'setext' + if (block.type === newType && block.headingStyle === headingStyle) { + return null } - header.text = text.trimRight() - this.insertBefore(header, block) - if (line.nextSibling) { - const removedCache = [] - for (const child of block.children) { - removedCache.push(child) - if (child === line) { - break - } + + const text = line.text + const lines = text.split('\n') + let setextLines = [] + const postParagraphLines = [] + let setextLineHasPushed = false + + for (const l of lines) { + if (/^ {0,3}(?:={3,}|-{3,})(?= {1,}|$)/.test(l) && !setextLineHasPushed) { + setextLineHasPushed = true + } else if (!setextLineHasPushed) { + setextLines.push(l) + } else { + postParagraphLines.push(l) } - removedCache.forEach(child => this.removeBlock(child)) - } else { - this.removeBlock(block) } - this.cursor.start.key = this.cursor.end.key = header.key - this.cursor.start.offset = this.cursor.end.offset = header.text.length - return header + + const setextBlock = this.createBlock(newType, { + headingStyle, + marker + }) + const setextLineBlock = this.createBlock('span', { + text: setextLines.join('\n'), + functionType: 'paragraphContent' + }) + this.appendChild(setextBlock, setextLineBlock) + this.insertBefore(setextBlock, block) + + if (postParagraphLines.length) { + const postBlock = this.createBlockP(postParagraphLines.join('\n')) + this.insertAfter(postBlock, setextBlock) + } + + this.removeBlock(block) + + const key = setextBlock.children[0].key + const offset = setextBlock.children[0].text.length + + this.cursor = { + start: { key, offset }, + end: { key, offset } + } + + return setextBlock } ContentState.prototype.updateBlockQuote = function (block, line) { - if (line && !this.isFirstChild(line)) { - const paragraphBefore = this.createBlock('p') - const removeCache = [] - for (const child of block.children) { - if (child === line) break - removeCache.push(child) - } - removeCache.forEach(c => { - this.removeBlock(c) - this.appendChild(paragraphBefore, c) - }) - this.insertBefore(paragraphBefore, block) - } - if (!line && /^h\d/.test(block.type)) { - block.text = block.text.substring(1).trim() - delete block.headingStyle - delete block.marker - block.type = 'p' - block.children = [] - const line = this.createBlock('span', block.text.substring(1)) - block.text = '' - this.appendChild(block, line) - } else { - line.text = line.text.substring(1).trim() - } - const quoteBlock = this.createBlock('blockquote') - this.insertBefore(quoteBlock, block) - this.removeBlock(block) - this.appendChild(quoteBlock, block) + const text = line.text + const lines = text.split('\n') + const preParagraphLines = [] + let quoteLines = [] + let quoteLinesHasPushed = false + for (const l of lines) { + if (/^ {0,3}>/.test(l) && !quoteLinesHasPushed) { + quoteLinesHasPushed = true + quoteLines.push(l.trimStart().substring(1).trimStart()) + } else if (!quoteLinesHasPushed) { + preParagraphLines.push(l) + } else { + quoteLines.push(l) + } + } + let quoteParagraphBlock + if (/^h\d/.test(block.type)) { + quoteParagraphBlock = this.createBlock(block.type, { + headingStyle: block.headingStyle + }) + if (block.headingStyle === 'setext') { + quoteParagraphBlock.marker = block.marker + } + const headerContent = this.createBlock('span', { + text: quoteLines.join('\n'), + functionType: block.headingStyle === 'setext'? 'paragraphContent' : 'atxLine' + }) + this.appendChild(quoteParagraphBlock, headerContent) + } else { + quoteParagraphBlock = this.createBlockP(quoteLines.join('\n')) + } + + const quoteBlock = this.createBlock('blockquote') + this.appendChild(quoteBlock, quoteParagraphBlock) + this.insertBefore(quoteBlock, block) + + if (preParagraphLines.length) { + const preParagraphBlock = this.createBlockP(preParagraphLines.join('\n')) + this.insertBefore(preParagraphBlock, quoteBlock) + } + + this.removeBlock(block) + + const key = quoteParagraphBlock.children[0].key const { start, end } = this.cursor this.cursor = { - start: { - key: start.key, - offset: start.offset - 1 - }, - end: { - key: end.key, - offset: end.offset - 1 - } + start: { key, offset: start.offset - 1 }, + end: { key, offset: end.offset - 1 } } + return quoteBlock } - ContentState.prototype.updateToParagraph = function (block) { + ContentState.prototype.updateIndentCode = function (block, line) { + const codeBlock = this.createBlock('code', { + lang: '' + }) + const inputBlock = this.createBlock('span', { + functionType: 'languageInput' + }) + const preBlock = this.createBlock('pre', { + functionType: 'indentcode', + lang: '' + }) + + const text = line ? line.text : block.text + + const lines = text.split('\n') + const codeLines = [] + const paragraphLines = [] + let canBeCodeLine = true + + for (const l of lines) { + if (/^ {4,}/.test(l) && canBeCodeLine) { + codeLines.push(l.replace(/^ {4}/, '')) + } else { + canBeCodeLine = false + paragraphLines.push(l) + } + } + codeLines.forEach(text => { + const codeLine = this.createBlock('span', { + text, + functionType: 'codeLine', + lang: '' + }) + this.appendChild(codeBlock, codeLine) + }) + + this.appendChild(preBlock, inputBlock) + this.appendChild(preBlock, codeBlock) + this.insertBefore(preBlock, block) + + if (paragraphLines.length > 0 && line) { + const newLine = this.createBlock('span', { + text: paragraphLines.join('\n') + }) + this.insertBefore(newLine, line) + this.removeBlock(line) + } else { + this.removeBlock(block) + } + + const key = codeBlock.children[0].key + const { start, end } = this.cursor + this.cursor = { + start: { key, offset: start.offset - 4 }, + end: { key, offset: end.offset - 4 } + } + return preBlock + } + + ContentState.prototype.updateToParagraph = function (block, line) { if (/^h\d$/.test(block.type) && block.headingStyle === 'setext') { return null } + 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 + const newBlock = this.createBlockP(line.text) + this.insertBefore(newBlock, block) + this.removeBlock(block) + const { start, end } = this.cursor + const key = newBlock.children[0].key + this.cursor = { + start: { key, offset: start.offset }, + end: { key, offset: end.offset } + } return block } return null } - - ContentState.prototype.updateCodeBlocks = function (block) { - const codeBlock = this.getParent(block) - const preBlock = this.getParent(codeBlock) - const code = codeBlock.children.map(line => line.text).join('\n') - this.codeBlocks.set(preBlock.key, code) - } } export default updateCtrl diff --git a/src/muya/lib/eventHandler/clickEvent.js b/src/muya/lib/eventHandler/clickEvent.js index 846d7a6b..7b5e0f2b 100644 --- a/src/muya/lib/eventHandler/clickEvent.js +++ b/src/muya/lib/eventHandler/clickEvent.js @@ -73,15 +73,12 @@ class ClickEvent { if (target.closest('div.ag-container-preview') || target.closest('div.ag-html-preview')) { return event.stopPropagation() } - // handler html preview click + // handler container preview click const editIcon = target.closest(`.ag-container-icon`) if (editIcon) { event.preventDefault() event.stopPropagation() - const nextElement = editIcon.nextElementSibling - if (nextElement && nextElement.classList.contains('ag-function-html')) { - contentState.handleHtmlBlockClick(nextElement) - } else if (editIcon.parentNode.classList.contains('ag-container-block')) { + if (editIcon.parentNode.classList.contains('ag-container-block')) { contentState.handleContainerBlockClick(editIcon.parentNode) } } diff --git a/src/muya/lib/eventHandler/keyboard.js b/src/muya/lib/eventHandler/keyboard.js index a1ab9b6c..a2d50cb1 100644 --- a/src/muya/lib/eventHandler/keyboard.js +++ b/src/muya/lib/eventHandler/keyboard.js @@ -66,6 +66,9 @@ class Keyboard { if (event.target.closest('[contenteditable=false]')) { return } + + // We need check cursor is null, because we may copy the html preview content, + // and no need to dispatch change. const { start, end } = selection.getCursorRange() if (!start || !end) { return diff --git a/src/muya/lib/parser/index.js b/src/muya/lib/parser/index.js index 3792ab59..66e18ba0 100644 --- a/src/muya/lib/parser/index.js +++ b/src/muya/lib/parser/index.js @@ -378,15 +378,37 @@ const tokenizerFac = (src, beginRules, inlineRules, pos = 0, top, labels) => { pos = pos + autoLTo[0].length continue } + // soft line break + const softTo = inlineRules['soft_line_break'].exec(src) + if (softTo) { + const len = softTo[0].length + pushPending() + tokens.push({ + type: 'soft_line_break', + raw: softTo[0], + lineBreak: softTo[1], + isAtEnd: softTo.input.length === softTo[0].length, + parent: tokens, + range: { + start: pos, + end: pos + len + } + }) + src = src.substring(len) + pos += len + continue + } // hard line break const hardTo = inlineRules['hard_line_break'].exec(src) - if (hardTo && top) { + if (hardTo) { const len = hardTo[0].length pushPending() tokens.push({ type: 'hard_line_break', raw: hardTo[0], spaces: hardTo[1], + lineBreak: hardTo[2], + isAtEnd: hardTo.input.length === hardTo[0].length, parent: tokens, range: { start: pos, diff --git a/src/muya/lib/parser/marked/lexer.js b/src/muya/lib/parser/marked/lexer.js index e635cf85..9580c395 100644 --- a/src/muya/lib/parser/marked/lexer.js +++ b/src/muya/lib/parser/marked/lexer.js @@ -185,9 +185,11 @@ Lexer.prototype.token = function (src, top) { // hr cap = this.rules.hr.exec(src) if (cap) { + const marker = cap[0].replace(/\n*$/, '') src = src.substring(cap[0].length) this.tokens.push({ - type: 'hr' + type: 'hr', + marker }) continue } diff --git a/src/muya/lib/parser/render/index.js b/src/muya/lib/parser/render/index.js index a09d726a..67dd9e43 100644 --- a/src/muya/lib/parser/render/index.js +++ b/src/muya/lib/parser/render/index.js @@ -3,7 +3,7 @@ import flowchart from 'flowchart.js' import Diagram from './sequence' import vegaEmbed from 'vega-embed' import { CLASS_OR_ID } from '../../config' -import { conflict, mixins } from '../../utils' +import { conflict, mixins, camelToSnake } from '../../utils' import { patch, toVNode, toHTML, h } from './snabbdom' import { beginRules } from '../rules' import renderInlines from './renderInlines' @@ -13,6 +13,7 @@ class StateRender { constructor (muya) { this.muya = muya this.eventCenter = muya.eventCenter + this.codeCache = new Map() this.loadImageMap = new Map() this.loadMathMap = new Map() this.mermaidCache = new Set() @@ -85,7 +86,7 @@ class StateRender { selector += `.${CLASS_OR_ID['AG_ACTIVE']}` } if (type === 'span') { - selector += `.${CLASS_OR_ID['AG_LINE']}` + selector += `.ag-${camelToSnake(block.functionType)}` } if (!block.parent && selectedBlock && block.key === selectedBlock.key) { selector += `.${CLASS_OR_ID['AG_SELECTED']}` @@ -155,6 +156,7 @@ class StateRender { patch(oldVdom, newVdom) this.renderMermaid() this.renderDiagram() + this.codeCache.clear() } // Only render the blocks which you updated @@ -198,6 +200,7 @@ class StateRender { this.renderMermaid() this.renderDiagram() + this.codeCache.clear() } } diff --git a/src/muya/lib/parser/render/renderBlock/renderBlock.js b/src/muya/lib/parser/render/renderBlock/renderBlock.js index 66dda8e8..3ecd4c4c 100644 --- a/src/muya/lib/parser/render/renderBlock/renderBlock.js +++ b/src/muya/lib/parser/render/renderBlock/renderBlock.js @@ -2,7 +2,7 @@ * [renderBlock render one block, no matter it is a container block or text block] */ export default function renderBlock (block, cursor, activeBlocks, selectedBlock, matches, useCache = false) { - const method = block.children.length > 0 + const method = Array.isArray(block.children) && block.children.length > 0 ? 'renderContainerBlock' : 'renderLeafBlock' diff --git a/src/muya/lib/parser/render/renderBlock/renderContainerBlock.js b/src/muya/lib/parser/render/renderBlock/renderContainerBlock.js index f7335725..916895a2 100644 --- a/src/muya/lib/parser/render/renderBlock/renderContainerBlock.js +++ b/src/muya/lib/parser/render/renderBlock/renderContainerBlock.js @@ -17,28 +17,47 @@ const PRE_BLOCK_HASH = { export default function renderContainerBlock (block, cursor, activeBlocks, selectedBlock, matches, useCache = false) { let selector = this.getSelector(block, cursor, activeBlocks, selectedBlock) + const { + type, + headingStyle, + editable, + functionType, + listType, + listItemType, + bulletMarkerOrDelimiter, + isLooseListItem, + lang + } = block const children = block.children.map(child => this.renderBlock(child, cursor, activeBlocks, selectedBlock, matches, useCache)) const data = { attrs: {}, dataset: {} } - // handle `div` block - if (/div/.test(block.type)) { - if (block.toolBarType) { - selector += `.${'ag-tool-' + block.toolBarType}.${CLASS_OR_ID['AG_TOOL_BAR']}` - } - if (block.functionType) { - selector += `.${'ag-function-' + block.functionType}` - } - if (block.editable !== undefined && !block.editable) { - Object.assign(data.attrs, { contenteditable: 'false' }) - } + + if (editable === false) { + Object.assign(data.attrs, { contenteditable: 'false' }) } - // handle `figure` block - if (block.type === 'figure') { - if (block.functionType) { - Object.assign(data.dataset, { role: block.functionType.toUpperCase() }) - if (block.functionType === 'table') { + + if (/code|pre/.test(type) && typeof lang === 'string' && !!lang) { + selector += `.language-${lang}` + } + + if (/^h/.test(type)) { + if (/^h\d$/.test(type)) { + // TODO: This should be the best place to create and update the TOC. + // Cache `block.key` and title and update only if necessary. + Object.assign(data.dataset, { + head: type + }) + selector += `.${headingStyle}` + } + Object.assign(data.dataset, { + role: type + }) + } else if (type === 'figure') { + if (functionType) { + Object.assign(data.dataset, { role: functionType.toUpperCase() }) + if (functionType === 'table') { children.unshift(renderTableTools(activeBlocks)) } else { children.unshift(renderEditIcon()) @@ -46,61 +65,29 @@ export default function renderContainerBlock (block, cursor, activeBlocks, selec } if ( - /multiplemath|flowchart|mermaid|sequence|vega-lite/.test(block.functionType) + /html|multiplemath|flowchart|mermaid|sequence|vega-lite/.test(functionType) ) { selector += `.${CLASS_OR_ID['AG_CONTAINER_BLOCK']}` } - } - // hanle list block - if (/ul|ol/.test(block.type) && block.listType) { - switch (block.listType) { - case 'order': - selector += `.${CLASS_OR_ID['AG_ORDER_LIST']}` - break - case 'bullet': - selector += `.${CLASS_OR_ID['AG_BULLET_LIST']}` - break - case 'task': - selector += `.${CLASS_OR_ID['AG_TASK_LIST']}` - break - default: - break + } else if (/ul|ol/.test(type) && listType) { + selector += `.ag-${listType}-list` + if (type === 'ol') { + Object.assign(data.attrs, { start: block.start }) } - } - if (block.type === 'li' && block.listItemType) { + } else if (type === 'li' && listItemType) { + Object.assign(data.dataset, { marker: bulletMarkerOrDelimiter }) selector += `.${CLASS_OR_ID['AG_LIST_ITEM']}` - switch (block.listItemType) { - case 'order': - selector += `.${CLASS_OR_ID['AG_ORDER_LIST_ITEM']}` - break - case 'bullet': - selector += `.${CLASS_OR_ID['AG_BULLET_LIST_ITEM']}` - break - case 'task': - selector += `.${CLASS_OR_ID['AG_TASK_LIST_ITEM']}` - break - default: - break - } - Object.assign(data.dataset, { marker: block.bulletMarkerOrDelimiter }) - selector += block.isLooseListItem ? `.${CLASS_OR_ID['AG_LOOSE_LIST_ITEM']}` : `.${CLASS_OR_ID['AG_TIGHT_LIST_ITEM']}` - } - if (block.type === 'ol') { - Object.assign(data.attrs, { start: block.start }) - } - if (block.type === 'code') { - const { lang } = block - if (lang) { - selector += `.language-${lang}` - } - } - if (block.type === 'pre') { - const { lang, functionType } = block - if (lang) { - selector += `.language-${lang}` - } + selector += `.ag-${listItemType}-list-item` + selector += isLooseListItem ? `.${CLASS_OR_ID['AG_LOOSE_LIST_ITEM']}` : `.${CLASS_OR_ID['AG_TIGHT_LIST_ITEM']}` + } else if (type === 'pre') { Object.assign(data.dataset, { role: functionType }) selector += PRE_BLOCK_HASH[block.functionType] + + if (/html|multiplemath|mermaid|flowchart|wega-lite|sequence/.test(functionType)) { + const codeBlock = block.children[0] + const code = codeBlock.children.map(line => line.text).join('\n') + this.codeCache.set(block.key, code) + } } if (!block.parent) { diff --git a/src/muya/lib/parser/render/renderBlock/renderLeafBlock.js b/src/muya/lib/parser/render/renderBlock/renderLeafBlock.js index 9bceb0e3..b63851a2 100644 --- a/src/muya/lib/parser/render/renderBlock/renderLeafBlock.js +++ b/src/muya/lib/parser/render/renderBlock/renderLeafBlock.js @@ -57,7 +57,6 @@ export default function renderLeafBlock (block, cursor, activeBlocks, selectedBl const { text, type, - headingStyle, align, checked, key, @@ -65,13 +64,16 @@ export default function renderLeafBlock (block, cursor, activeBlocks, selectedBl functionType, editable } = block + const data = { props: {}, attrs: {}, dataset: {}, style: {} } + let children = '' + if (text) { let tokens = [] if (highlights.length === 0 && this.tokenCache.has(text)) { @@ -81,7 +83,7 @@ export default function renderLeafBlock (block, cursor, activeBlocks, selectedBl functionType !== 'codeLine' && functionType !== 'languageInput' ) { - const hasBeginRules = /^(h\d|span|hr)/.test(type) + const hasBeginRules = type === 'span' tokens = tokenizer(text, highlights, hasBeginRules, this.labels) const hasReferenceTokens = hasReferenceToken(tokens) if (highlights.length === 0 && useCache && DEVICE_MEMORY >= 4 && !hasReferenceTokens) { @@ -103,9 +105,9 @@ export default function renderLeafBlock (block, cursor, activeBlocks, selectedBl style: `text-align:${align}` }) } else if (type === 'div') { - const code = this.muya.contentState.codeBlocks.get(block.preSibling) + const code = this.codeCache.get(block.preSibling) switch (functionType) { - case 'preview': { + case 'html': { selector += `.${CLASS_OR_ID['AG_HTML_PREVIEW']}` const htmlContent = sanitize(code, PREVIEW_DOMPURIFY_CONFIG) // handle empty html bock @@ -168,7 +170,6 @@ export default function renderLeafBlock (block, cursor, activeBlocks, selectedBl case 'flowchart': case 'sequence': case 'vega-lite': { - const code = this.muya.contentState.codeBlocks.get(block.preSibling) selector += `.${CLASS_OR_ID['AG_CONTAINER_PREVIEW']}` if (code === '') { children = '< Empty Diagram Block >' @@ -183,18 +184,6 @@ export default function renderLeafBlock (block, cursor, activeBlocks, selectedBl break } } - } else if (/^h/.test(type)) { - if (/^h\d$/.test(type)) { - // TODO: This should be the best place to create and update the TOC. - // Cache `block.key` and title and update only if necessary. - Object.assign(data.dataset, { - head: type - }) - selector += `.${headingStyle}` - } - Object.assign(data.dataset, { - role: type - }) } else if (type === 'input') { Object.assign(data.attrs, { type: 'checkbox' @@ -214,8 +203,6 @@ export default function renderLeafBlock (block, cursor, activeBlocks, selectedBl .replace(new RegExp(MARKER_HASK['"'], 'g'), '"') .replace(new RegExp(MARKER_HASK["'"], 'g'), "'") - selector += `.${CLASS_OR_ID['AG_CODE_LINE']}` - if (lang && /\S/.test(code) && loadedCache.has(lang)) { const wrapper = document.createElement('div') wrapper.classList.add(`language-${lang}`) @@ -230,7 +217,6 @@ export default function renderLeafBlock (block, cursor, activeBlocks, selectedBl } } else if (type === 'span' && functionType === 'languageInput') { const html = getHighlightHtml(text, highlights) - selector += `.${CLASS_OR_ID['AG_LANGUAGE_INPUT']}` children = htmlToVNode(html) } if (!block.parent) { diff --git a/src/muya/lib/parser/render/renderInlines/hardLineBreak.js b/src/muya/lib/parser/render/renderInlines/hardLineBreak.js index cbeffa7f..606a3cf5 100644 --- a/src/muya/lib/parser/render/renderInlines/hardLineBreak.js +++ b/src/muya/lib/parser/render/renderInlines/hardLineBreak.js @@ -1,13 +1,17 @@ import { CLASS_OR_ID } from '../../../config' -export default function hardLineBreak (h, cursor, block, token, outerClass) { +export default function softLineBreak (h, cursor, block, token, outerClass) { + const { spaces, lineBreak, isAtEnd } = token const className = CLASS_OR_ID['AG_HARD_LINE_BREAK'] - const content = [token.spaces] - if (block.type === 'span' && block.nextSibling) { + const spaceClass = CLASS_OR_ID['AG_HARD_LINE_BREAK_SPACE'] + if (isAtEnd) { return [ - h(`span.${className}`, content) + h(`span.${className}`, h(`span.${spaceClass}`, spaces)), + h(`span.${CLASS_OR_ID['AG_LINE_END']}`, lineBreak) ] } else { - return content + return [ + h(`span.${className}`, [ h(`span.${spaceClass}`, spaces), lineBreak ]) + ] } } diff --git a/src/muya/lib/parser/render/renderInlines/index.js b/src/muya/lib/parser/render/renderInlines/index.js index 4c5053f8..3ddb0764 100644 --- a/src/muya/lib/parser/render/renderInlines/index.js +++ b/src/muya/lib/parser/render/renderInlines/index.js @@ -7,6 +7,7 @@ import htmlTag from './htmlTag' import hr from './hr' import tailHeader from './tailHeader' import hardLineBreak from './hardLineBreak' +import softLineBreak from './softLineBreak' import codeFense from './codeFense' import inlineMath from './inlineMath' import autoLink from './autoLink' @@ -37,6 +38,7 @@ export default { hr, tailHeader, hardLineBreak, + softLineBreak, codeFense, inlineMath, autoLink, diff --git a/src/muya/lib/parser/render/renderInlines/softLineBreak.js b/src/muya/lib/parser/render/renderInlines/softLineBreak.js new file mode 100644 index 00000000..15bdc0a5 --- /dev/null +++ b/src/muya/lib/parser/render/renderInlines/softLineBreak.js @@ -0,0 +1,13 @@ +import { CLASS_OR_ID } from '../../../config' + +export default function hardLineBreak (h, cursor, block, token, outerClass) { + const { lineBreak, isAtEnd } = token + let selector = `span.${CLASS_OR_ID['AG_SOFT_LINE_BREAK']}` + if (isAtEnd) { + selector += `.${CLASS_OR_ID['AG_LINE_END']}` + } + + return [ + h(selector, lineBreak) + ] +} diff --git a/src/muya/lib/parser/rules.js b/src/muya/lib/parser/rules.js index eb090b7b..9129fa00 100644 --- a/src/muya/lib/parser/rules.js +++ b/src/muya/lib/parser/rules.js @@ -25,7 +25,8 @@ export const inlineRules = { 'tail_header': /^(\s{1,}#{1,})(\s*)$/, 'html_tag': /^(|(<([a-zA-Z]{1}[a-zA-Z\d-]*) *[_\.\-/:a-zA-Z\d='";\? *]* *(?:\/)?>)(?:([\s\S]*?)(<\/\3 *>))?)/, // row html 'html_escape': new RegExp(`^(${escapeCharacters.join('|')})`, 'i'), - 'hard_line_break': /^(\s{2,})$/, + 'soft_line_break': /^(\n)(?!\n)/, + 'hard_line_break': /^( {2,})(\n)(?!\n)/, // patched math marker `$` 'backlash': /^(\\)([\\`*{}\[\]()#+\-.!_>~:\|\<\>$]{1})/, diff --git a/src/muya/lib/prism/index.js b/src/muya/lib/prism/index.js index 6f341610..858c6d54 100644 --- a/src/muya/lib/prism/index.js +++ b/src/muya/lib/prism/index.js @@ -1,11 +1,11 @@ -import Prism from 'prismjs' +import Prism from 'prismjs2' import { filter } from 'fuzzaldrin' import initLoadLanguage, { loadedCache } from './loadLanguage' import languages from './languages' const prism = Prism window.Prism = Prism -import('prismjs/plugins/keep-markup/prism-keep-markup') +import('prismjs2/plugins/keep-markup/prism-keep-markup') const langs = Object.keys(languages).map(name => (languages[name])) const loadLanguage = initLoadLanguage(Prism) diff --git a/src/muya/lib/prism/loadLanguage.js b/src/muya/lib/prism/loadLanguage.js index 7b77095d..50390f49 100644 --- a/src/muya/lib/prism/loadLanguage.js +++ b/src/muya/lib/prism/loadLanguage.js @@ -74,7 +74,7 @@ function initLoadLanguage (Prism) { } delete Prism.languages[language] - await import('prismjs/components/prism-' + language) + await import('prismjs2/components/prism-' + language) loadedCache.add(language) promises.push(Promise.resolve({ status: 'loaded', diff --git a/src/muya/lib/selection/dom.js b/src/muya/lib/selection/dom.js index 13c7d6b5..9e702899 100644 --- a/src/muya/lib/selection/dom.js +++ b/src/muya/lib/selection/dom.js @@ -4,7 +4,12 @@ import { const CHOP_TEXT_REG = /(\*{1,3})([^*]+)(\1)/g export const getTextContent = (node, blackList) => { - if (!blackList) return node.textContent + if (node.nodeType === 3) { + return node.textContent + } else if (!blackList) { + return node.textContent + } + let text = '' if (blackList.some(className => node.classList && node.classList.contains(className))) { return text diff --git a/src/muya/lib/selection/index.js b/src/muya/lib/selection/index.js index 70aa3dfe..258aa8bc 100644 --- a/src/muya/lib/selection/index.js +++ b/src/muya/lib/selection/index.js @@ -436,6 +436,7 @@ class Selection { let { node: anchorNode, offset: anchorOffset } = getNodeAndOffset(anchorParagraph, anchor.offset) let { node: focusNode, offset: focusOffset } = getNodeAndOffset(focusParagraph, focus.offset) + anchorOffset = Math.min(anchorOffset, anchorNode.textContent.length) focusOffset = Math.min(focusOffset, focusNode.textContent.length) // First set the anchor node and anchor offeet, make it collapsed @@ -449,15 +450,14 @@ class Selection { if (node.nodeType === 3) { node = node.parentNode } - return node.closest('p[data-role=hr]') || - node.closest('span.ag-paragraph.ag-line') || + return node.closest('span.ag-paragraph') || node.closest('th.ag-paragraph') || - node.closest('td.ag-paragraph') || - node.closest('.ag-paragraph[data-head]') + node.closest('td.ag-paragraph') } getCursorRange () { let { anchorNode, anchorOffset, focusNode, focusOffset } = this.doc.getSelection() + const isAnchorValid = this.isValidCursorNode(anchorNode) const isFocusValid = this.isValidCursorNode(focusNode) let needFix = false @@ -480,6 +480,16 @@ class Selection { }) } + // fix bug click empty line, the cursor will jump to the end of pre line. + if ( + anchorNode === focusNode && + anchorOffset === focusOffset && + anchorNode.textContent === '\n' && + focusOffset === 0 + ) { + focusOffset = anchorOffset = 1 + } + const anchorParagraph = findNearestParagraph(anchorNode) const focusParagraph = findNearestParagraph(focusNode) @@ -502,6 +512,7 @@ class Selection { const aOffset = getOffsetOfParagraph(anchorNode, anchorParagraph) + anchorOffset const fOffset = getOffsetOfParagraph(focusNode, focusParagraph) + focusOffset + const anchor = { key: anchorParagraph.id, offset: aOffset } const focus = { key: focusParagraph.id, offset: fOffset } const result = new Cursor({ anchor, focus }) diff --git a/src/muya/lib/utils/exportHtml.js b/src/muya/lib/utils/exportHtml.js index 05490373..5b1ba8cf 100644 --- a/src/muya/lib/utils/exportHtml.js +++ b/src/muya/lib/utils/exportHtml.js @@ -1,12 +1,12 @@ import marked from '../parser/marked' -import Prism from 'prismjs' +import Prism from 'prismjs2' import katex from 'katex' import mermaid from 'mermaid' import flowchart from 'flowchart.js' import Diagram from '../parser/render/sequence' import vegaEmbed from 'vega-embed' import githubMarkdownCss from 'github-markdown-css/github-markdown.css' -import highlightCss from 'prismjs/themes/prism.css' +import highlightCss from 'prismjs2/themes/prism.css' import katexCss from 'katex/dist/katex.css' import { EXPORT_DOMPURIFY_CONFIG } from '../config' import { sanitize, unescapeHtml } from '../utils' diff --git a/src/muya/lib/utils/exportMarkdown.js b/src/muya/lib/utils/exportMarkdown.js index a145df52..6ad3c244 100644 --- a/src/muya/lib/utils/exportMarkdown.js +++ b/src/muya/lib/utils/exportMarkdown.js @@ -42,7 +42,8 @@ class ExportMarkdown { } switch (block.type) { - case 'p': { + case 'p': + case 'hr': { this.insertLineBreak(result, indent) result.push(this.translateBlocks2Markdown(block.children, indent)) break @@ -51,11 +52,6 @@ class ExportMarkdown { result.push(this.normalizeParagraphText(block, indent)) break } - case 'hr': { - this.insertLineBreak(result, indent) - result.push(this.normalizeParagraphText(block, indent)) - break - } case 'h1': case 'h2': case 'h3': @@ -171,17 +167,21 @@ class ExportMarkdown { } normalizeParagraphText (block, indent) { - return `${indent}${block.text}\n` + const { text } = block + const lines = text.split('\n') + return lines.map(line => `${indent}${line}`).join('\n') + '\n' } normalizeHeaderText (block, indent) { const { headingStyle, marker } = block + const { text } = block.children[0] if (headingStyle === 'atx') { - const match = block.text.match(/(#{1,6})(.*)/) - const text = `${match[1]} ${match[2].trim()}` - return `${indent}${text}\n` + const match = text.match(/(#{1,6})(.*)/) + const atxHeadingText = `${match[1]} ${match[2].trim()}` + return `${indent}${atxHeadingText}\n` } else if (headingStyle === 'setext') { - return `${indent}${block.text}\n${indent}${marker.trim()}\n` + const lines = text.trim().split('\n') + return lines.map(line => `${indent}${line}`).join('\n') + `\n${indent}${marker.trim()}\n` } } @@ -244,7 +244,7 @@ class ExportMarkdown { normalizeHTML (block, indent) { // figure const result = [] - const codeLines = block.children[0].children[0].children[0].children + const codeLines = block.children[0].children[0].children for (const line of codeLines) { result.push(`${indent}${line.text}\n`) } diff --git a/src/muya/lib/utils/importMarkdown.js b/src/muya/lib/utils/importMarkdown.js index e608abda..776f7a67 100644 --- a/src/muya/lib/utils/importMarkdown.js +++ b/src/muya/lib/utils/importMarkdown.js @@ -13,6 +13,56 @@ import { CURSOR_DNA } from '../config' const LINE_BREAKS_REG = /\n/ +// Just because turndown change `\n`(soft line break) to space, So we add `span.ag-soft-line-break` to workaround. +const turnSoftBreakToSpan = html => { + const parser = new DOMParser() + const doc = parser.parseFromString( + `${html}`, + 'text/html' + ) + const root = doc.querySelector(`#turn-root`) + const travel = childNodes => { + for (const node of childNodes) { + if (node.nodeType === 3) { + let startLen = 0 + let endLen = 0 + const text = node.nodeValue.replace(/^(\n+)/, (_, p) => { + startLen = p.length + return '' + }).replace(/(\n+)$/, (_, p) => { + endLen = p.length + return '' + }) + if (/\n/.test(text)) { + const tokens = text.split('\n') + const params = [] + let i = 0 + const len = tokens.length + for (; i< len; i++) { + let text = tokens[i] + if (i === 0 && startLen !== 0) { + text = '\n'.repeat(startLen) + text + } else if (i === len - 1 && endLen !== 0) { + text = text + '\n'.repeat(endLen) + } + params.push(document.createTextNode(text)) + if (i !== len - 1) { + const softBreak = document.createElement('span') + softBreak.classList.add('ag-soft-line-break') + params.push(softBreak) + } + } + node.replaceWith(...params) + } + } else if (node.nodeType === 1) { + travel(node.childNodes) + } + } + } + travel(root.childNodes) + return root.innerHTML.trim() +} + const importRegister = ContentState => { // turn markdown to blocks ContentState.prototype.markdownToState = function (markdown) { @@ -37,37 +87,57 @@ const importRegister = ContentState => { while ((token = tokens.shift())) { switch (token.type) { case 'frontmatter': { + const lang = 'yaml' value = token.text - block = this.createBlock('pre') - const codeBlock = this.createBlock('code') + block = this.createBlock('pre', { + functionType: token.type, + lang + }) + const codeBlock = this.createBlock('code', { + lang + }) value .replace(/^\s+/, '') .replace(/\s$/, '') .split(LINE_BREAKS_REG).forEach(line => { - const codeLine = this.createBlock('span', line) - codeLine.functionType = 'codeLine' - codeLine.lang = 'yaml' + const codeLine = this.createBlock('span', { + text: line, + lang, + functionType: 'codeLine' + }) + this.appendChild(codeBlock, codeLine) }) - block.functionType = token.type - block.lang = codeBlock.lang = 'yaml' - this.codeBlocks.set(block.key, value) this.appendChild(block, codeBlock) this.appendChild(parentList[0], block) break } case 'hr': { - value = '---' - block = this.createBlock('hr', value) + value = token.marker + block = this.createBlock('hr') + const thematicBreakContent = this.createBlock('span', { + text: value, + functionType: 'thematicBreakLine' + }) + this.appendChild(block, thematicBreakContent) this.appendChild(parentList[0], block) break } case 'heading': { const { headingStyle, depth, text, marker } = token value = headingStyle === 'atx' ? '#'.repeat(+depth) + ` ${text}` : text - block = this.createBlock(`h${depth}`, value) - block.headingStyle = headingStyle + block = this.createBlock(`h${depth}`, { + headingStyle + }) + + const headingContent = this.createBlock('span', { + text: value, + functionType: headingStyle === 'atx'? 'atxLine' : 'paragraphContent' + }) + + this.appendChild(block, headingContent) + if (marker) { block.marker = marker } @@ -95,15 +165,25 @@ const importRegister = ContentState => { block = this.createContainerBlock(lang, value) this.appendChild(parentList[0], block) } else { - block = this.createBlock('pre') - const codeBlock = this.createBlock('code') + block = this.createBlock('pre', { + functionType: codeBlockStyle === 'fenced' ? 'fencecode' : 'indentcode', + lang + }) + const codeBlock = this.createBlock('code', { + lang + }) value.split(LINE_BREAKS_REG).forEach(line => { - const codeLine = this.createBlock('span', line) + const codeLine = this.createBlock('span', { + text: line + }) codeLine.lang = lang codeLine.functionType = 'codeLine' this.appendChild(codeBlock, codeLine) }) - const inputBlock = this.createBlock('span', lang) + const inputBlock = this.createBlock('span', { + text: lang, + functionType: 'languageInput' + }) if (lang && !languageLoaded.has(lang)) { languageLoaded.add(lang) loadLanguage(lang) @@ -121,10 +201,7 @@ const importRegister = ContentState => { console.warn(err) }) } - inputBlock.functionType = 'languageInput' - this.codeBlocks.set(block.key, value) - block.functionType = codeBlockStyle === 'fenced' ? 'fencecode' : 'indentcode' - block.lang = codeBlock.lang = lang + this.appendChild(block, inputBlock) this.appendChild(block, codeBlock) this.appendChild(parentList[0], block) @@ -144,7 +221,9 @@ const importRegister = ContentState => { } for (const headText of header) { const i = header.indexOf(headText) - const th = this.createBlock('th', restoreTableEscapeCharacters(headText)) + const th = this.createBlock('th', { + text: restoreTableEscapeCharacters(headText) + }) Object.assign(th, { align: align[i] || '', column: i }) this.appendChild(theadRow, th) } @@ -152,7 +231,9 @@ const importRegister = ContentState => { const rowBlock = this.createBlock('tr') for (const cell of row) { const i = row.indexOf(cell) - const td = this.createBlock('td', restoreTableEscapeCharacters(cell)) + const td = this.createBlock('td', { + text: restoreTableEscapeCharacters(cell) + }) Object.assign(td, { align: align[i] || '', column: i }) this.appendChild(rowBlock, td) } @@ -181,20 +262,20 @@ const importRegister = ContentState => { value += `\n${token.text}` } block = this.createBlock('p') - const lines = value.split(LINE_BREAKS_REG).map(line => this.createBlock('span', line)) - for (const line of lines) { - this.appendChild(block, line) - } + const contentBlock = this.createBlock('span', { + text: value + }) + this.appendChild(block, contentBlock) this.appendChild(parentList[0], block) break } case 'paragraph': { value = token.text block = this.createBlock('p') - const lines = value.split(LINE_BREAKS_REG).map(line => this.createBlock('span', line)) - for (const line of lines) { - this.appendChild(block, line) - } + const contentBlock = this.createBlock('span', { + text: value + }) + this.appendChild(block, contentBlock) this.appendChild(parentList[0], block) break } @@ -226,13 +307,17 @@ const importRegister = ContentState => { case 'loose_item_start': case 'list_item_start': { const { listItemType, bulletMarkerOrDelimiter, checked, type } = token - block = this.createBlock('li') - block.listItemType = checked !== undefined ? 'task' : listItemType - block.bulletMarkerOrDelimiter = bulletMarkerOrDelimiter - block.isLooseListItem = type === 'loose_item_start' + block = this.createBlock('li', { + listItemType: checked !== undefined ? 'task' : listItemType, + bulletMarkerOrDelimiter, + isLooseListItem: type === 'loose_item_start' + }) + if (checked !== undefined) { - const input = this.createBlock('input') - input.checked = checked + const input = this.createBlock('input', { + checked + }) + this.appendChild(block, input) } this.appendChild(parentList[0], block) @@ -260,10 +345,10 @@ const importRegister = ContentState => { const { turndownConfig } = this const turndownService = new TurndownService(turndownConfig) usePluginAddRules(turndownService) - // remove double `\\` in Math but I dont know why there are two '\' when paste. @jocs // fix #752, but I don't know why the   vanlished. html = html.replace(/ /g, ' ') - const markdown = turndownService.turndown(html) // .replace(/(\\)\\/g, '$1') + html = turnSoftBreakToSpan(html) + const markdown = turndownService.turndown(html) return markdown } @@ -308,7 +393,7 @@ const importRegister = ContentState => { // set cursor const travel = blocks => { for (const block of blocks) { - const { key, text, children, editable, type, functionType } = block + const { key, text, children, editable } = block if (text) { const offset = text.indexOf(CURSOR_DNA) if (offset > -1) { @@ -318,17 +403,6 @@ const importRegister = ContentState => { start: { key, offset }, end: { key, offset } } - // handle cursor in Math block, need to remove `CURSOR_DNA` in preview block - if (type === 'span' && functionType === 'codeLine') { - const preBlock = this.getParent(this.getParent(block)) - const code = this.codeBlocks.get(preBlock.key) - if (!code) return - const offset = code.indexOf(CURSOR_DNA) - if (offset > -1) { - const newCode = code.substring(0, offset) + code.substring(offset + CURSOR_DNA.length) - this.codeBlocks.set(preBlock.key, newCode) - } - } return } } @@ -351,7 +425,6 @@ const importRegister = ContentState => { } ContentState.prototype.importMarkdown = function (markdown) { - this.codeBlocks = new Map() this.blocks = this.markdownToState(markdown) } } diff --git a/src/muya/lib/utils/index.js b/src/muya/lib/utils/index.js index be2879ad..604d9f11 100644 --- a/src/muya/lib/utils/index.js +++ b/src/muya/lib/utils/index.js @@ -23,6 +23,8 @@ export const isEven = number => Math.abs(number) % 2 === 0 export const isLengthEven = (str = '') => str.length % 2 === 0 export const snakeToCamel = name => name.replace(/_([a-z])/g, (p0, p1) => p1.toUpperCase()) + +export const camelToSnake = name => name.replace(/([A-Z])/g, (_, p) => `-${p.toLowerCase()}`) /** * Are two arrays have intersection */ diff --git a/src/muya/lib/utils/turndownService.js b/src/muya/lib/utils/turndownService.js index f0609807..51a0cef4 100644 --- a/src/muya/lib/utils/turndownService.js +++ b/src/muya/lib/utils/turndownService.js @@ -26,11 +26,13 @@ export const usePluginAddRules = turndownService => { } }) - // handle `soft line break` and `hard line break` - // add `LINE_BREAK` to the end of soft line break and hard line break. - turndownService.addRule('lineBreak', { + // handle `line break` in code block + // add `LINE_BREAK` to the end of every code line but not the last line. + turndownService.addRule('codeLineBreak', { filter (node, options) { - return node.nodeName === 'SPAN' && node.classList.contains(CLASS_OR_ID['AG_LINE']) && node.nextElementSibling + return ( + node.nodeName === 'SPAN' && node.classList.contains(CLASS_OR_ID['AG_CODE_LINE']) && node.nextElementSibling + ) }, replacement (content, node, options) { return content + LINE_BREAK diff --git a/src/muya/themes/default.css b/src/muya/themes/default.css index 450b5835..dbb05e6e 100644 --- a/src/muya/themes/default.css +++ b/src/muya/themes/default.css @@ -141,12 +141,6 @@ kbd { text-decoration: none; } - h1 .ag-gray, - h2 .ag-gray, - h3 .ag-gray { - font-size: .6em; - } - .ag-header-tight-space { margin-left: -.3em; } @@ -227,29 +221,48 @@ kbd { } h1 { - font-size: 40px; + font-size: 30px; } h2 { - font-size: 32px; - } - - h3 { - font-size: 28px; - } - - h4 { font-size: 24px; } - h5 { + h3 { + font-size: 22px; + } + + h4 { font-size: 20px; } + h5 { + font-size: 18px; + } + h6 { font-size: 16px; } + h1 .ag-gray { + font-size: 21px; + } + h2 .ag-gray { + font-size: 20px; + } + h3 .ag-gray { + font-size: 19px; + } + h4 .ag-gray { + font-size: 18px; + } + h5 .ag-gray { + font-size: 17px; + } + h6 .ag-gray { + font-size: 16px; + } + p, blockquote, ul, diff --git a/test/unit/data/common/BasicTextFormatting.md b/test/unit/data/common/BasicTextFormatting.md index 4e604031..0c63416b 100644 --- a/test/unit/data/common/BasicTextFormatting.md +++ b/test/unit/data/common/BasicTextFormatting.md @@ -1,4 +1,10 @@ -# Basic Text Formatting +## Basic Text Formatting + +**Strong** text __Also Strong__ + +~~strike~~ and `inline code` + +under line and 43 H2O *this is in italic* and _so is this_ @@ -12,17 +18,19 @@ this is strike through text +So _a_ single _word_ followed _b_y _a_nother + +So __a__ single __word__ followed __b__y __a__nother + +## Some markdown extentions + +This is emoji :man: + +This is inline math $a \ne b$ + ## Paragraph A two trailing spaces and a new line makes a line break. Two new lines make a new paragraph. - -## Failing Tests - -``` -So _a_ single _word_ followed _b_y _a_nother - -So __a__ single __word__ followed __b__y __a__nother -``` diff --git a/test/unit/data/common/CodeBlocks.md b/test/unit/data/common/CodeBlocks.md index 091087d3..6e270159 100644 --- a/test/unit/data/common/CodeBlocks.md +++ b/test/unit/data/common/CodeBlocks.md @@ -1,5 +1,7 @@ # Code Blocks +## Indent Code Block + This line won't *have any markdown* formatting applied. I can even write HTML and it will show up as text. This is great for showing program source code, or HTML or even @@ -9,6 +11,8 @@ Within a paragraph, you can use backquotes to do the same thing. `This won't be *italic* or **bold** at all.` +## Fence Code Block + ```cpp #include diff --git a/test/unit/data/common/Headings.md b/test/unit/data/common/Headings.md index 3fee4dc0..248fa527 100644 --- a/test/unit/data/common/Headings.md +++ b/test/unit/data/common/Headings.md @@ -1,5 +1,7 @@ # Headings +## Setext heading + This is a huge header === @@ -9,6 +11,14 @@ this is a smaller header header --- +This is a huge header +================== + +this is a smaller header +------------------ + +## Atx heading + ## ATX Headings # foo @@ -53,20 +63,6 @@ foo bar -## Failing Tests - -Headings and horizontal rules are shrinked to three characters because this simplify the parsing - maybe we should change this. - -``` -This is a huge header -================== - -this is a smaller header ------------------- -``` - -``` ## Horizontal Rule ----------------- -``` + - - - - -- --- --- ---- diff --git a/yarn.lock b/yarn.lock index 9e58352f..4e3d181b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8682,10 +8682,10 @@ pretty-error@^2.0.2: renderkid "^2.0.1" utila "~0.4" -prismjs@^1.16.0: - version "1.16.0" - resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.16.0.tgz#406eb2c8aacb0f5f0f1167930cb83835d10a4308" - integrity sha512-OA4MKxjFZHSvZcisLGe14THYsug/nF6O1f0pAJc0KN0wTyAcLqmsbE+lTGKSpyh+9pEW57+k6pg2AfYR+coyHA== +prismjs2@^1.15.1: + version "1.15.1" + resolved "https://registry.yarnpkg.com/prismjs2/-/prismjs2-1.15.1.tgz#6dda1b9aa7e8ecddf55b145f2189b605f89e2738" + integrity sha512-tDYrcjuYxi5VceNCniF7YjxFTHJv7unA5KbN9EVZh0hnKmEaxdSSe43Gagobvue5UnbnUSB0y+l5b8Y3C1cXkA== optionalDependencies: clipboard "^2.0.0"