diff --git a/src/editor/config.js b/src/editor/config.js index 85114930..5514c1a9 100644 --- a/src/editor/config.js +++ b/src/editor/config.js @@ -89,7 +89,9 @@ export const CLASS_OR_ID = genUpper2LowerKeyHash([ 'AG_HIGHLIGHT', 'AG_MATH', 'AG_MATH_RENDER', - 'AG_MATH_ERROR' + 'AG_MATH_ERROR', + 'AG_LOOSE_LIST_ITEM', + 'AG_TIGHT_LIST_ITEM' ]) export const codeMirrorConfig = { diff --git a/src/editor/contentState/enterCtrl.js b/src/editor/contentState/enterCtrl.js index 653c2fd7..9ac7dc08 100644 --- a/src/editor/contentState/enterCtrl.js +++ b/src/editor/contentState/enterCtrl.js @@ -187,6 +187,7 @@ const enterCtrl = ContentState => { newBlock = this.createBlockLi(post) newBlock.listItemType = block.listItemType } + newBlock.isLooseListItem = block.isLooseListItem } else { block.text = pre newBlock = this.createBlock(type, post) @@ -218,6 +219,7 @@ const enterCtrl = ContentState => { newBlock = this.createBlockLi() newBlock.listItemType = parent.listItemType } + newBlock.isLooseListItem = parent.isLooseListItem this.insertAfter(newBlock, parent) const index = this.findIndex(parent.children, block) const partChildren = parent.children.splice(index + 1) @@ -245,6 +247,7 @@ const enterCtrl = ContentState => { newBlock = this.createBlockLi() newBlock.listItemType = block.listItemType } + newBlock.isLooseListItem = block.isLooseListItem } else { newBlock = this.createBlock('p') } diff --git a/src/editor/contentState/updateCtrl.js b/src/editor/contentState/updateCtrl.js index 029ed2d9..5e5430a3 100644 --- a/src/editor/contentState/updateCtrl.js +++ b/src/editor/contentState/updateCtrl.js @@ -30,7 +30,7 @@ const updateCtrl = ContentState => { return false } - ContentState.prototype.checkInlineUpdate = function (block) { + ContentState.prototype.checkInlineUpdate = function (block, preferLooseListItem) { if (/th|td|figure/.test(block.type)) return false const { text } = block const parent = this.getParent(block) @@ -43,11 +43,11 @@ const updateCtrl = ContentState => { return true case !!bullet: - this.updateList(block, 'bullet', bullet) + this.updateList(block, 'bullet', preferLooseListItem, bullet) return true case !!tasklist && parent && parent.listItemType === 'bullet': // only `bullet` list item can be update to `task` list item - this.updateTaskListItem(block, 'tasklist', tasklist) + this.updateTaskListItem(block, 'tasklist', preferLooseListItem, tasklist) return true case !!order: @@ -144,7 +144,7 @@ const updateCtrl = ContentState => { this.render() } - ContentState.prototype.updateList = function (block, type, marker = '') { + ContentState.prototype.updateList = function (block, type, preferLooseListItem, marker = '') { const parent = this.getParent(block) const preSibling = this.getPreSibling(block) const nextSibling = this.getNextSibling(block) @@ -155,6 +155,7 @@ const updateCtrl = ContentState => { const endOffset = end.offset const newBlock = this.createBlockLi(newText, block.type) newBlock.listItemType = type + newBlock.isLooseListItem = preferLooseListItem if (preSibling && preSibling.listType === type && nextSibling && nextSibling.listType === type) { this.appendChild(preSibling, newBlock) @@ -224,7 +225,7 @@ const updateCtrl = ContentState => { block.type = 'hr' } - ContentState.prototype.updateState = function (event) { + ContentState.prototype.updateState = function (event, preferLooseListItem) { const { floatBox } = this const { start, end } = selection.getCursorRange() const { start: oldStart, end: oldEnd } = this.cursor @@ -233,7 +234,7 @@ const updateCtrl = ContentState => { } if (event.type === 'click' && start.key !== end.key) { setTimeout(() => { - this.updateState(event) + this.updateState(event, preferLooseListItem) }) } if (event.type === 'input' && oldStart.key !== oldEnd.key) { @@ -326,7 +327,7 @@ const updateCtrl = ContentState => { this.cursor = { start, end } const checkMarkedUpdate = this.checkNeedRender(block) - const checkInlineUpdate = this.checkInlineUpdate(block) + const checkInlineUpdate = this.checkInlineUpdate(block, preferLooseListItem) if (checkMarkedUpdate || checkInlineUpdate || needRender) { this.render() diff --git a/src/editor/index.js b/src/editor/index.js index 96dfa694..3157f98e 100644 --- a/src/editor/index.js +++ b/src/editor/index.js @@ -19,7 +19,7 @@ import './assets/symbolIcon/index.css' class Aganippe { constructor (container, options) { - const { focusMode = false, theme = 'light', markdown = '' } = options + const { focusMode = false, theme = 'light', markdown = '', preferLooseListItem = true } = options this.container = container const eventCenter = this.eventCenter = new EventCenter() const floatBox = this.floatBox = new FloatBox(eventCenter) @@ -31,6 +31,7 @@ class Aganippe { this.markdown = markdown this.fontSize = 16 this.lineHeight = 1.6 + this.preferLooseListItem = preferLooseListItem // private property this._isEditChinese = false this.init() @@ -342,7 +343,7 @@ class Aganippe { // const style = getComputedStyle(target) // if (event.type === 'click' && !style.contenteditable) return if (!this._isEditChinese) { - this.contentState.updateState(event) + this.contentState.updateState(event, this.preferLooseListItem) } if (event.type === 'click' || event.type === 'keyup') { const selectionChanges = this.getSelection() diff --git a/src/editor/parser/StateRender.js b/src/editor/parser/StateRender.js index 699b484d..b806fc16 100644 --- a/src/editor/parser/StateRender.js +++ b/src/editor/parser/StateRender.js @@ -113,6 +113,7 @@ class StateRender { default: break } + blockSelector += 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 }) diff --git a/src/editor/parser/marked.js b/src/editor/parser/marked.js index af8cb205..c0da7a90 100644 --- a/src/editor/parser/marked.js +++ b/src/editor/parser/marked.js @@ -1,3 +1,5 @@ +import { CLASS_OR_ID } from '../config' + /** * marked - a markdown parser * Copyright (c) 2011-2014, Christopher Jeffrey. (MIT Licensed) @@ -155,7 +157,7 @@ Lexer.prototype.lex = function(src) { Lexer.prototype.token = function(src, top, bq) { var src = src.replace(/^ +$/gm, ''), - next, loose, cap, bull, b, item, space, i, l, checked; + loose, cap, bull, b, item, space, i, l, checked; while (src) { // newline @@ -288,14 +290,18 @@ Lexer.prototype.token = function(src, top, bq) { listType: bull.length > 1 ? (/\d/.test(bull) ? 'order' : 'task') : 'bullet' }); + let next = false; + let prevNext = true; + let listItemIndices = []; + // Get each top-level item. cap = cap[0].match(this.rules.item); - next = false; l = cap.length; i = 0; for (; i < l; i++) { - item = cap[i]; + const itemWithBullet = cap[i]; + item = itemWithBullet; // Remove the list item's bullet // so it is seen as the next token. @@ -331,13 +337,33 @@ Lexer.prototype.token = function(src, top, bq) { } } - // Determine whether item is loose or not. - // Use: /(^|\n)(?! )[^\n]+\n\n(?!\s*$)/ - // for discount behavior. - loose = next || /\n\n(?!\s*$)/.test(item); - if (i !== l - 1) { - next = item.charAt(item.length - 1) === '\n'; - if (!loose) loose = next; + var prevItem = ''; + if (i === 0) { + prevItem = item; + } else { + prevItem = cap[i - 1]; + } + + // Determine whether item is loose or not. If previous item is loose + // this item is also loose. + loose = next = next || /^ *([*+-]|\d+\.) +\S+\n\n(?!\s*$)/.test(itemWithBullet); + + // Check if previous line ends with a new line. + if (!loose && (i !== 0 || l > 1) && prevItem.length !== 0 && prevItem.charAt(prevItem.length - 1) === '\n') { + loose = next = true; + } + + // A list is either loose or tight, so update previous list items. + if (next && prevNext !== next) { + for(const index of listItemIndices) { + this.tokens[index].type = 'loose_item_start' + } + listItemIndices = []; + } + prevNext = next; + + if (!loose) { + listItemIndices.push(this.tokens.length); } this.tokens.push({ @@ -828,19 +854,26 @@ Renderer.prototype.list = function(body, ordered, taskList) { return '<' + type + classes + '>\n' + body + '\n'; }; -Renderer.prototype.listitem = function(text, checked, listItemType) { - var classes +Renderer.prototype.listitem = function(text, checked, listItemType, loose) { + var classes; switch (listItemType) { case 'order': - classes = ' class="order-list-item"' - break + classes = ' class="order-list-item'; + break; case 'task': - classes = ' class="task-list-item"' - break + classes = ' class="task-list-item'; + break; case 'bullet': - classes = ' class="bullet-list-item"' - break + classes = ' class="bullet-list-item'; + break; + default: + throw new + Error('Invalid state'); } + + // "tight-list-item" is only used to remove

padding + classes += loose ? ` .${CLASS_OR_ID['AG_LOOSE_LIST_ITEM']}"` : ` .${CLASS_OR_ID['AG_TIGHT_LIST_ITEM']}"`; + if (checked === undefined) { return '

  • ' + text + '
  • \n'; } @@ -1107,7 +1140,7 @@ Parser.prototype.tok = function() { this.tok(); } - return this.renderer.listitem(body, checked, listItemType); + return this.renderer.listitem(body, checked, listItemType, false); } case 'loose_item_start': { @@ -1119,7 +1152,7 @@ Parser.prototype.tok = function() { body += this.tok(); } - return this.renderer.listitem(body, checked, listItemType); + return this.renderer.listitem(body, checked, listItemType, true); } case 'html': { diff --git a/src/editor/utils/exportMarkdown.js b/src/editor/utils/exportMarkdown.js index e25682e4..8d5ee0cf 100644 --- a/src/editor/utils/exportMarkdown.js +++ b/src/editor/utils/exportMarkdown.js @@ -4,6 +4,8 @@ class ExportMarkdown { constructor (blocks) { this.blocks = blocks this.listType = [] // 'ul' or 'ol' + // helper to translate the first tight item in a nested list + this.isLooseParentList = true } generate () { @@ -20,7 +22,7 @@ class ExportMarkdown { switch (block.type) { case 'p': case 'hr': - this.insertLineBreak(result, indent) + this.insertLineBreak(result, indent, true) result.push(this.normalizeParagraphText(block, indent)) break @@ -30,43 +32,58 @@ class ExportMarkdown { case 'h4': case 'h5': case 'h6': - this.insertLineBreak(result, indent) + this.insertLineBreak(result, indent, true) result.push(this.normalizeHeaderText(block, indent)) break case 'figure': - this.insertLineBreak(result, indent) + this.insertLineBreak(result, indent, true) const table = block.children[1] result.push(this.normalizeTable(table, indent)) break - case 'li': - this.insertLineBreak(result, indent) - result.push(this.normalizeListItem(block, indent)) - break + case 'li': { + const insertNewLine = block.isLooseListItem - case 'ul': - this.insertLineBreak(result, indent) + // helper variable to correct the first tight item in a nested list + this.isLooseParentList = insertNewLine + + this.insertLineBreak(result, indent, insertNewLine) + result.push(this.normalizeListItem(block, indent)) + this.isLooseParentList = true + break + } + + case 'ul': { + const insertNewLine = this.isLooseParentList + this.isLooseParentList = true + + this.insertLineBreak(result, indent, insertNewLine) this.listType.push({ type: 'ul' }) result.push(this.normalizeList(block, indent)) this.listType.pop() break + } - case 'ol': - this.insertLineBreak(result, indent) + case 'ol': { + const insertNewLine = this.isLooseParentList + this.isLooseParentList = true + + this.insertLineBreak(result, indent, insertNewLine) const listCount = block.start !== undefined ? block.start : 1 this.listType.push({ type: 'ol', listCount }) result.push(this.normalizeList(block, indent)) this.listType.pop() break + } case 'pre': - this.insertLineBreak(result, indent) + this.insertLineBreak(result, indent, true) result.push(this.normalizeCodeBlock(block, indent)) break case 'blockquote': - this.insertLineBreak(result, indent) + this.insertLineBreak(result, indent, true) result.push(this.normalizeBlockquote(block, indent)) break default: @@ -77,12 +94,13 @@ class ExportMarkdown { return result.join('') } - insertLineBreak (result, indent) { + insertLineBreak (result, indent, insertNewLine) { + const newLine = insertNewLine ? '\n' : '' if (result.length > 0) { if (/\S/.test(indent)) { - result.push(`${indent}\n`) - } else { - result.push('\n') + result.push(`${indent}${newLine}`) + } else if (insertNewLine) { + result.push(newLine) } } } diff --git a/src/editor/utils/importMarkdown.js b/src/editor/utils/importMarkdown.js index f7476329..541c6ccf 100644 --- a/src/editor/utils/importMarkdown.js +++ b/src/editor/utils/importMarkdown.js @@ -153,9 +153,11 @@ const importRegister = ContentState => { break case 'li': - const isTask = child.attrs.some(attr => attr.name === 'class' && attr.value === 'task-list-item') + const isTask = child.attrs.some(attr => attr.name === 'class' && attr.value.includes('task-list-item')) + const isLoose = child.attrs.some(attr => attr.name === 'class' && attr.value.includes(CLASS_OR_ID['AG_LOOSE_LIST_ITEM'])) block = this.createBlock('li') block.listItemType = parent.nodeName === 'ul' ? (isTask ? 'task' : 'bullet') : 'order' + block.isLooseListItem = isLoose this.appendChild(parent, block) travel(block, child.childNodes) break diff --git a/src/renderer/app.vue b/src/renderer/app.vue index f7996c76..b1f6e062 100644 --- a/src/renderer/app.vue +++ b/src/renderer/app.vue @@ -70,7 +70,7 @@ computed: { ...mapState([ 'pathname', 'filename', 'isSaved', 'windowActive', 'wordCount', - 'typewriter', 'focus', 'sourceCode', 'markdown', + 'typewriter', 'focus', 'sourceCode', 'markdown', 'preferLooseListItem', 'cursor', 'theme', 'platform', 'lightColor', 'darkColor', 'fontSize', 'lineHeight' ]) }, diff --git a/src/renderer/components/editor.vue b/src/renderer/components/editor.vue index d846a5d5..7cf1123e 100644 --- a/src/renderer/components/editor.vue +++ b/src/renderer/components/editor.vue @@ -59,6 +59,7 @@