diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html index a98952c25c..0c254ee6c9 100644 --- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html +++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html @@ -27,12 +27,14 @@ limitations under the License. } p, ul, + code, blockquote, gr-linked-text.pre { margin: 0 0 var(--spacing-m) 0; } p, ul, + code, blockquote { max-width: var(--gr-formatted-text-prose-max-width, none); } @@ -42,10 +44,16 @@ limitations under the License. :host(.noTrailingMargin) gr-linked-text.pre:last-child { margin: 0; } + code, blockquote { border-left: 1px solid #aaa; padding: 0 var(--spacing-m); } + code { + display: block; + white-space: pre; + color: var(--deemphasized-text-color); + } li { list-style-type: disc; margin-left: var(--spacing-xl); diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js index 7483590cf5..e62fcc8598 100644 --- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js +++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js @@ -19,6 +19,7 @@ // eslint-disable-next-line no-unused-vars const QUOTE_MARKER_PATTERN = /\n\s?>\s/g; + const CODE_MARKER_PATTERN = /^(`{1,3})([^`]+?)\1$/; /** @extends Polymer.Element */ class GrFormattedText extends Polymer.GestureEventListeners( @@ -54,19 +55,6 @@ } } - /** - * Get the plain text as it appears in the generated DOM. - * - * This differs from the `content` property in that it will not include - * formatting markers such as > characters to make quotes or * and - markers - * to make list items. - * - * @return {string} - */ - getTextContent() { - return this._blocksToText(this._computeBlocks(this.content)); - } - _contentChanged(content) { // In the case where the config may not be set (perhaps due to the // request for it still being in flight), set the content anyway to @@ -99,9 +87,10 @@ * * 'quote' (Block quote.) * * 'pre' (Pre-formatted text.) * * 'list' (Unordered list.) + * * 'code' (code blocks.) * - * For blocks of type 'paragraph' and 'pre' there is a `text` property that - * maps to a string of the block's content. + * For blocks of type 'paragraph', 'pre' and 'code' there is a `text` + * property that maps to a string of the block's content. * * For blocks of type 'list', there is an `items` property that maps to a * list of strings representing the list items. @@ -118,123 +107,134 @@ if (!content) { return []; } const result = []; - const split = content.split('\n\n'); - let p; + const lines = content.replace(/[\s\n\r\t]+$/g, '').split('\n'); - for (let i = 0; i < split.length; i++) { - p = split[i]; - if (!p.length) { continue; } + for (let i = 0; i < lines.length; i++) { + if (!lines[i].length) { + continue; + } - if (this._isQuote(p)) { - result.push(this._makeQuote(p)); - } else if (this._isPreFormat(p)) { - result.push({type: 'pre', text: p}); - } else if (this._isList(p)) { - this._makeList(p, result); + if (this._isCodeMarkLine(lines[i])) { + // handle multi-line code + let nextI = i+1; + while (!this._isCodeMarkLine(lines[nextI]) && nextI < lines.length) { + nextI++; + } + + if (this._isCodeMarkLine(lines[nextI])) { + result.push({ + type: 'code', + text: lines.slice(i+1, nextI).join('\n'), + }); + i = nextI; + continue; + } + + // otherwise treat it as regular line and continue + // check for other cases + } + + if (this._isSingleLineCode(lines[i])) { + // no guard check as _isSingleLineCode tested on the pattern + const codeContent = lines[i].match(CODE_MARKER_PATTERN)[2]; + result.push({type: 'code', text: codeContent}); + } else if (this._isList(lines[i])) { + let nextI = i + 1; + while (this._isList(lines[nextI])) { + nextI++; + } + result.push(this._makeList(lines.slice(i, nextI))); + i = nextI - 1; + } else if (this._isQuote(lines[i])) { + let nextI = i + 1; + while (this._isQuote(lines[nextI])) { + nextI++; + } + const blockLines = lines.slice(i, nextI) + .map(l => l.replace(/^[ ]?>[ ]?/, '')); + result.push({ + type: 'quote', + blocks: this._computeBlocks(blockLines.join('\n')), + }); + i = nextI - 1; + } else if (this._isPreFormat(lines[i])) { + let nextI = i + 1; + // include pre or all regular lines but stop at next new line + while (this._isPreFormat(lines[nextI]) + || (this._isRegularLine(lines[nextI]) && lines[nextI].length)) { + nextI++; + } + result.push({ + type: 'pre', + text: lines.slice(i, nextI).join('\n'), + }); + i = nextI - 1; } else { - result.push({type: 'paragraph', text: p}); + let nextI = i + 1; + while (this._isRegularLine(lines[nextI])) { + nextI++; + } + result.push({ + type: 'paragraph', + text: lines.slice(i, nextI).join('\n'), + }); + i = nextI - 1; } } + return result; } /** - * Take a block of comment text that contains a list and potentially - * a paragraph (but does not contain blank lines), generate appropriate + * Take a block of comment text that contains a list, generate appropriate * block objects and append them to the output list. * - * In simple cases, this will generate a single list block. For example, on - * the following input. + * * Item one. + * * Item two. + * * item three. * - * * Item one. - * * Item two. - * * item three. + * TODO(taoalpha): maybe we should also support nested list * - * However, if the list starts with a paragraph, it will need to also - * generate that paragraph. Consider the following input. - * - * A bit of text describing the context of the list: - * * List item one. - * * List item two. - * * Et cetera. - * - * In this case, `_makeList` generates a paragraph block object - * containing the non-bullet-prefixed text, followed by a list block. - * - * @param {!string} p The block containing the list (as well as a - * potential paragraph). - * @param {!Array} out The list of blocks to append to. + * @param {!Array} lines The block containing the list. */ - _makeList(p, out) { - let block = null; - let inList = false; - let inParagraph = false; - const lines = p.split('\n'); + _makeList(lines) { + const block = {type: 'list', items: []}; let line; for (let i = 0; i < lines.length; i++) { line = lines[i]; - - if (line[0] === '-' || line[0] === '*') { - // The next line looks like a list item. If not building a list - // already, then create one. Remove the list item marker (* or -) from - // the line. - if (!inList) { - if (inParagraph) { - // Add the finished paragraph block to the result. - inParagraph = false; - if (block !== null) { - out.push(block); - } - } - inList = true; - block = {type: 'list', items: []}; - } - line = line.substring(1).trim(); - } else if (!inList) { - // Otherwise, if a list has not yet been started, but the next line - // does not look like a list item, then add the line to a paragraph - // block. If a paragraph block has not yet been started, then create - // one. - if (!inParagraph) { - inParagraph = true; - block = {type: 'paragraph', text: ''}; - } else { - block.text += ' '; - } - block.text += line; - continue; - } + line = line.substring(1).trim(); block.items.push(line); } - if (block !== null) { - out.push(block); - } + return block; } - _makeQuote(p) { - const quotedLines = p - .split('\n') - .map(l => l.replace(/^[ ]?>[ ]?/, '')) - .join('\n'); - return { - type: 'quote', - blocks: this._computeBlocks(quotedLines), - }; + _isRegularLine(line) { + // line can not be recognized by existing patterns + if (line === undefined) return false; + return !this._isQuote(line) && !this._isCodeMarkLine(line) + && !this._isSingleLineCode(line) && !this._isList(line) && + !this._isPreFormat(line); } - _isQuote(p) { - return p.startsWith('> ') || p.startsWith(' > '); + _isQuote(line) { + return line && (line.startsWith('> ') || line.startsWith(' > ')); } - _isPreFormat(p) { - return p.includes('\n ') || p.includes('\n\t') || - p.startsWith(' ') || p.startsWith('\t'); + _isCodeMarkLine(line) { + return line && line.trim() === '```'; } - _isList(p) { - return p.includes('\n- ') || p.includes('\n* ') || - p.startsWith('- ') || p.startsWith('* '); + _isSingleLineCode(line) { + return line && CODE_MARKER_PATTERN.test(line); + } + + _isPreFormat(line) { + return line && /^[ \t]/.test(line); + } + + _isList(line) { + return line && /^[-*] /.test(line); } /** @@ -274,6 +274,12 @@ return bq; } + if (block.type === 'code') { + const code = document.createElement('code'); + code.textContent = block.text; + return code; + } + if (block.type === 'pre') { return this._makeLinkedText(block.text, true); } @@ -289,20 +295,6 @@ } }); } - - _blocksToText(blocks) { - return blocks.map(block => { - if (block.type === 'paragraph' || block.type === 'pre') { - return block.text; - } - if (block.type === 'quote') { - return this._blocksToText(block.blocks); - } - if (block.type === 'list') { - return block.items.join('\n'); - } - }).join('\n\n'); - } } customElements.define(GrFormattedText.is, GrFormattedText); diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html index 1f0d26defd..a6d852412b 100644 --- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html +++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html @@ -80,13 +80,11 @@ limitations under the License. assertBlock(result, 0, 'paragraph', comment); }); - test('parse para break', () => { + test('parse para break without special blocks', () => { const comment = 'Para 1\n\nPara 2\n\nPara 3'; const result = element._computeBlocks(comment); - assert.lengthOf(result, 3); - assertBlock(result, 0, 'paragraph', 'Para 1'); - assertBlock(result, 1, 'paragraph', 'Para 2'); - assertBlock(result, 2, 'paragraph', 'Para 3'); + assert.lengthOf(result, 1); + assertBlock(result, 0, 'paragraph', comment); }); test('parse quote', () => { @@ -107,14 +105,6 @@ limitations under the License. assertBlock(result[0].blocks, 0, 'paragraph', 'Quote text'); }); - test('parse excludes empty', () => { - const comment = 'Para 1\n\n\n\nPara 2'; - const result = element._computeBlocks(comment); - assert.lengthOf(result, 2); - assertBlock(result, 0, 'paragraph', 'Para 1'); - assertBlock(result, 1, 'paragraph', 'Para 2'); - }); - test('parse multiline quote', () => { const comment = '> Quote line 1\n> Quote line 2\n > Quote line 3\n'; const result = element._computeBlocks(comment); @@ -122,7 +112,7 @@ limitations under the License. assert.equal(result[0].type, 'quote'); assert.lengthOf(result[0].blocks, 1); assertBlock(result[0].blocks, 0, 'paragraph', - 'Quote line 1\nQuote line 2\nQuote line 3\n'); + 'Quote line 1\nQuote line 2\nQuote line 3'); }); test('parse pre', () => { @@ -146,13 +136,6 @@ limitations under the License. assertBlock(result, 0, 'pre', comment); }); - test('parse intermediate leading whitespace pre', () => { - const comment = 'No indent.\n\tNonzero indent.\nNo indent again.'; - const result = element._computeBlocks(comment); - assert.lengthOf(result, 1); - assertBlock(result, 0, 'pre', comment); - }); - test('parse star list', () => { const comment = '* Item 1\n* Item 2\n* Item 3'; const result = element._computeBlocks(comment); @@ -197,19 +180,19 @@ limitations under the License. 'Parting words.'; const result = element._computeBlocks(comment); assert.lengthOf(result, 7); - assertBlock(result, 0, 'paragraph', 'Paragraph\nacross\na\nfew\nlines.'); + assertBlock(result, 0, 'paragraph', 'Paragraph\nacross\na\nfew\nlines.\n'); assert.equal(result[1].type, 'quote'); assert.lengthOf(result[1].blocks, 1); assertBlock(result[1].blocks, 0, 'paragraph', 'Quote\nacross\nnot many lines.'); - assertBlock(result, 2, 'paragraph', 'Another paragraph'); + assertBlock(result, 2, 'paragraph', 'Another paragraph\n'); assertListBlock(result, 3, 0, 'Series'); assertListBlock(result, 3, 1, 'of'); assertListBlock(result, 3, 2, 'list'); assertListBlock(result, 3, 3, 'items'); - assertBlock(result, 4, 'paragraph', 'Yet another paragraph'); + assertBlock(result, 4, 'paragraph', 'Yet another paragraph\n'); assertBlock(result, 5, 'pre', '\tPreformatted text.'); assertBlock(result, 6, 'paragraph', 'Parting words.'); }); @@ -218,13 +201,13 @@ limitations under the License. const comment = 'A\n\n* line 1\n* 2nd line'; const result = element._computeBlocks(comment); assert.lengthOf(result, 2); - assertBlock(result, 0, 'paragraph', 'A'); + assertBlock(result, 0, 'paragraph', 'A\n'); assertListBlock(result, 1, 0, 'line 1'); assertListBlock(result, 1, 1, '2nd line'); }); test('bullet list 2', () => { - const comment = 'A\n\n* line 1\n* 2nd line\n\nB'; + const comment = 'A\n* line 1\n* 2nd line\n\nB'; const result = element._computeBlocks(comment); assert.lengthOf(result, 3); assertBlock(result, 0, 'paragraph', 'A'); @@ -260,13 +243,13 @@ limitations under the License. '* Be very unlucky\n'; const result = element._computeBlocks(comment); assert.lengthOf(result, 2); - assertBlock(result, 0, 'paragraph', 'To see this bug, you have to:'); + assertBlock(result, 0, 'paragraph', 'To see this bug,\nyou have to:'); assertListBlock(result, 1, 0, 'Be on IMAP or EAS (not on POP)'); assertListBlock(result, 1, 1, 'Be very unlucky'); }); test('dash list 1', () => { - const comment = 'A\n\n- line 1\n- 2nd line'; + const comment = 'A\n- line 1\n- 2nd line'; const result = element._computeBlocks(comment); assert.lengthOf(result, 2); assertBlock(result, 0, 'paragraph', 'A'); @@ -275,7 +258,7 @@ limitations under the License. }); test('dash list 2', () => { - const comment = 'A\n\n- line 1\n- 2nd line\n\nB'; + const comment = 'A\n- line 1\n- 2nd line\n\nB'; const result = element._computeBlocks(comment); assert.lengthOf(result, 3); assertBlock(result, 0, 'paragraph', 'A'); @@ -293,8 +276,18 @@ limitations under the License. assertBlock(result, 1, 'paragraph', 'B'); }); + test('nested list will NOT be recognized', () => { + // will be rendered as two separate lists + const comment = '- line 1\n - line with indentation\n- line 2'; + const result = element._computeBlocks(comment); + assert.lengthOf(result, 3); + assertListBlock(result, 0, 0, 'line 1'); + assert.equal(result[1].type, 'pre'); + assertListBlock(result, 2, 0, 'line 2'); + }); + test('pre format 1', () => { - const comment = 'A\n\n This is pre\n formatted'; + const comment = 'A\n This is pre\n formatted'; const result = element._computeBlocks(comment); assert.lengthOf(result, 2); assertBlock(result, 0, 'paragraph', 'A'); @@ -302,7 +295,7 @@ limitations under the License. }); test('pre format 2', () => { - const comment = 'A\n\n This is pre\n formatted\n\nbut this is not'; + const comment = 'A\n This is pre\n formatted\n\nbut this is not'; const result = element._computeBlocks(comment); assert.lengthOf(result, 3); assertBlock(result, 0, 'paragraph', 'A'); @@ -311,7 +304,7 @@ limitations under the License. }); test('pre format 3', () => { - const comment = 'A\n\n Q\n \n S\n\nB'; + const comment = 'A\n Q\n \n S\n\nB'; const result = element._computeBlocks(comment); assert.lengthOf(result, 3); assertBlock(result, 0, 'paragraph', 'A'); @@ -338,7 +331,7 @@ limitations under the License. }); test('quote 2', () => { - const comment = 'See this said:\n\n > a quoted\n > string block\n\nOK?'; + const comment = 'See this said:\n > a quoted\n > string block\n\nOK?'; const result = element._computeBlocks(comment); assert.lengthOf(result, 3); assertBlock(result, 0, 'paragraph', 'See this said:'); @@ -357,15 +350,65 @@ limitations under the License. assert.equal(result[0].blocks[0].type, 'quote'); assert.lengthOf(result[0].blocks[0].blocks, 1); assertBlock(result[0].blocks[0].blocks, 0, 'paragraph', 'prior'); - assertBlock(result[0].blocks, 1, 'paragraph', 'next\n'); + assertBlock(result[0].blocks, 1, 'paragraph', 'next'); }); - test('getTextContent', () => { - const comment = 'Paragraph\n\n pre\n\n* List\n* Of\n* Items\n\n> Quote'; - element.content = comment; - const result = element.getTextContent(); - const expected = 'Paragraph\n\n pre\n\nList\nOf\nItems\n\nQuote'; - assert.equal(result, expected); + test('code 1', () => { + const comment = '```\n// test code\n```'; + const result = element._computeBlocks(comment); + assert.lengthOf(result, 1); + assert.equal(result[0].type, 'code'); + assert.equal(result[0].text, '// test code'); + }); + + test('code 2', () => { + const comment = 'test code\n```// test code```'; + const result = element._computeBlocks(comment); + assert.lengthOf(result, 2); + assert.equal(result[0].type, 'paragraph'); + assert.equal(result[0].text, 'test code'); + assert.equal(result[1].type, 'code'); + assert.equal(result[1].text, '// test code'); + }); + + test('code 3', () => { + const comment = 'test code\n```// test code```'; + const result = element._computeBlocks(comment); + assert.lengthOf(result, 2); + assert.equal(result[0].type, 'paragraph'); + assert.equal(result[0].text, 'test code'); + assert.equal(result[1].type, 'code'); + assert.equal(result[1].text, '// test code'); + }); + + test('not a code', () => { + const comment = 'test code\n```// test code'; + const result = element._computeBlocks(comment); + assert.lengthOf(result, 1); + assert.equal(result[0].type, 'paragraph'); + assert.equal(result[0].text, 'test code\n```// test code'); + }); + + test('not a code 2', () => { + const comment = 'test code\n```\n// test code'; + const result = element._computeBlocks(comment); + assert.lengthOf(result, 2); + assert.equal(result[0].type, 'paragraph'); + assert.equal(result[0].text, 'test code'); + assert.equal(result[1].type, 'paragraph'); + assert.equal(result[1].text, '```\n// test code'); + }); + + test('mix all 1', () => { + const comment = ' bullets:\n- bullet 1\n- bullet 2\n\ncode example:\n' + + '```// test code```\n\n> reference is here'; + const result = element._computeBlocks(comment); + assert.lengthOf(result, 5); + assert.equal(result[0].type, 'pre'); + assert.equal(result[1].type, 'list'); + assert.equal(result[2].type, 'paragraph'); + assert.equal(result[3].type, 'code'); + assert.equal(result[4].type, 'quote'); }); test('_computeNodes called without config', () => {