diff --git a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js index fe02096d903..947d019c725 100644 --- a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js @@ -1,320 +1,8 @@ -/* eslint-disable object-shorthand, no-unused-vars, no-use-before-define, no-restricted-syntax, guard-for-in, no-continue */ - import $ from 'jquery'; -import _ from 'underscore'; -import { insertText, getSelectedFragment, nodeMatchesSelector } from '~/lib/utils/common_utils'; -import { placeholderImage } from '~/lazy_loader'; - -const gfmRules = { - // The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert - // GitLab Flavored Markdown (GFM) to HTML. - // These handlers consequently convert that same HTML to GFM to be copied to the clipboard. - // Every filter in lib/banzai/pipeline/gfm_pipeline.rb that generates HTML - // from GFM should have a handler here, in reverse order. - // The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb. - InlineDiffFilter: { - 'span.idiff.addition'(el, text) { - return `{+${text}+}`; - }, - 'span.idiff.deletion'(el, text) { - return `{-${text}-}`; - }, - }, - TaskListFilter: { - 'input[type=checkbox].task-list-item-checkbox'(el) { - return `[${el.checked ? 'x' : ' '}]`; - }, - }, - ReferenceFilter: { - '.tooltip'(el) { - return ''; - }, - 'a.gfm:not([data-link=true])'(el, text) { - return el.dataset.original || text; - }, - }, - AutolinkFilter: { - a(el, text) { - // Fallback on the regular MarkdownFilter's `a` handler. - if (text !== el.getAttribute('href')) return false; - - return text; - }, - }, - TableOfContentsFilter: { - 'ul.section-nav'(el) { - return '[[_TOC_]]'; - }, - }, - EmojiFilter: { - 'img.emoji'(el) { - return el.getAttribute('alt'); - }, - 'gl-emoji'(el) { - return `:${el.getAttribute('data-name')}:`; - }, - }, - ImageLinkFilter: { - 'a.no-attachment-icon'(el, text) { - return text; - }, - }, - ImageLazyLoadFilter: { - img(el, text) { - return `![${el.getAttribute('alt')}](${el.getAttribute('src')})`; - }, - }, - VideoLinkFilter: { - '.video-container'(el) { - const videoEl = el.querySelector('video'); - if (!videoEl) return false; - - return CopyAsGFM.nodeToGFM(videoEl); - }, - video(el) { - return `![${el.dataset.title}](${el.getAttribute('src')})`; - }, - }, - MermaidFilter: { - 'svg.mermaid'(el, text) { - const sourceEl = el.querySelector('text.source'); - if (!sourceEl) return false; - - return `\`\`\`mermaid\n${CopyAsGFM.nodeToGFM(sourceEl)}\n\`\`\``; - }, - 'svg.mermaid style, svg.mermaid g'(el, text) { - // We don't want to include the content of these elements in the copied text. - return ''; - }, - }, - MathFilter: { - 'pre.code.math[data-math-style=display]'(el, text) { - return `\`\`\`math\n${text.trim()}\n\`\`\``; - }, - 'code.code.math[data-math-style=inline]'(el, text) { - return `$\`${text}\`$`; - }, - 'span.katex-display span.katex-mathml'(el) { - const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]'); - if (!mathAnnotation) return false; - - return `\`\`\`math\n${CopyAsGFM.nodeToGFM(mathAnnotation)}\n\`\`\``; - }, - 'span.katex-mathml'(el) { - const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]'); - if (!mathAnnotation) return false; - - return `$\`${CopyAsGFM.nodeToGFM(mathAnnotation)}\`$`; - }, - 'span.katex-html'(el) { - // We don't want to include the content of this element in the copied text. - return ''; - }, - 'annotation[encoding="application/x-tex"]'(el, text) { - return text.trim(); - }, - }, - SanitizationFilter: { - 'a[name]:not([href]):empty'(el) { - return el.outerHTML; - }, - dl(el, text) { - let lines = text - .replace(/\n\n/g, '\n') - .trim() - .split('\n'); - // Add two spaces to the front of subsequent list items lines, - // or leave the line entirely blank. - lines = lines.map(l => { - const line = l.trim(); - if (line.length === 0) return ''; - - return ` ${line}`; - }); - - return `
\n${lines.join('\n')}\n
\n`; - }, - 'dt, dd, summary, details'(el, text) { - const tag = el.nodeName.toLowerCase(); - return `<${tag}>${text}\n`; - }, - 'sup, sub, kbd, q, samp, var, ruby, rt, rp, abbr'(el, text) { - const tag = el.nodeName.toLowerCase(); - return `<${tag}>${text}`; - }, - }, - SyntaxHighlightFilter: { - 'pre.code.highlight'(el, t) { - const text = t.trimRight(); - - let lang = el.getAttribute('lang'); - if (!lang || lang === 'plaintext') { - lang = ''; - } - - // Prefixes lines with 4 spaces if the code contains triple backticks - if (lang === '' && text.match(/^```/gm)) { - return text - .split('\n') - .map(l => { - const line = l.trim(); - if (line.length === 0) return ''; - - return ` ${line}`; - }) - .join('\n'); - } - - return `\`\`\`${lang}\n${text}\n\`\`\``; - }, - 'pre > code'(el, text) { - // Don't wrap code blocks in `` - return text; - }, - }, - MarkdownFilter: { - br(el) { - // Two spaces at the end of a line are turned into a BR - return ' '; - }, - code(el, text) { - let backtickCount = 1; - const backtickMatch = text.match(/`+/); - if (backtickMatch) { - backtickCount = backtickMatch[0].length + 1; - } - - const backticks = Array(backtickCount + 1).join('`'); - const spaceOrNoSpace = backtickCount > 1 ? ' ' : ''; - - return backticks + spaceOrNoSpace + text.trim() + spaceOrNoSpace + backticks; - }, - blockquote(el, text) { - return text - .trim() - .split('\n') - .map(s => `> ${s}`.trim()) - .join('\n'); - }, - img(el) { - const imageSrc = el.src; - const imageUrl = imageSrc && imageSrc !== placeholderImage ? imageSrc : el.dataset.src || ''; - return `![${el.getAttribute('alt')}](${imageUrl})`; - }, - 'a.anchor'(el, text) { - // Don't render a Markdown link for the anchor link inside a heading - return text; - }, - a(el, text) { - return `[${text}](${el.getAttribute('href')})`; - }, - li(el, text) { - const lines = text.trim().split('\n'); - const firstLine = `- ${lines.shift()}`; - // Add four spaces to the front of subsequent list items lines, - // or leave the line entirely blank. - const nextLines = lines.map(s => { - if (s.trim().length === 0) return ''; - - return ` ${s}`; - }); - - return `${firstLine}\n${nextLines.join('\n')}`; - }, - ul(el, text) { - return text; - }, - ol(el, text) { - // LIs get a `- ` prefix by default, which we replace by `1. ` for ordered lists. - return text.replace(/^- /gm, '1. '); - }, - h1(el, text) { - return `# ${text.trim()}\n`; - }, - h2(el, text) { - return `## ${text.trim()}\n`; - }, - h3(el, text) { - return `### ${text.trim()}\n`; - }, - h4(el, text) { - return `#### ${text.trim()}\n`; - }, - h5(el, text) { - return `##### ${text.trim()}\n`; - }, - h6(el, text) { - return `###### ${text.trim()}\n`; - }, - strong(el, text) { - return `**${text}**`; - }, - em(el, text) { - return `_${text}_`; - }, - del(el, text) { - return `~~${text}~~`; - }, - hr(el) { - // extra leading \n is to ensure that there is a blank line between - // a list followed by an hr, otherwise this breaks old redcarpet rendering - return '\n-----\n'; - }, - p(el, text) { - return `${text.trim()}\n`; - }, - table(el) { - const theadEl = el.querySelector('thead'); - const tbodyEl = el.querySelector('tbody'); - if (!theadEl || !tbodyEl) return false; - - const theadText = CopyAsGFM.nodeToGFM(theadEl); - const tbodyText = CopyAsGFM.nodeToGFM(tbodyEl); - - return [theadText, tbodyText].join('\n'); - }, - thead(el, text) { - const cells = _.map(el.querySelectorAll('th'), cell => { - let chars = CopyAsGFM.nodeToGFM(cell).length + 2; - - let before = ''; - let after = ''; - const alignment = cell.align || cell.style.textAlign; - - switch (alignment) { - case 'center': - before = ':'; - after = ':'; - chars -= 2; - break; - case 'right': - after = ':'; - chars -= 1; - break; - default: - break; - } - - chars = Math.max(chars, 3); - - const middle = Array(chars + 1).join('-'); - - return before + middle + after; - }); - - const separatorRow = `|${cells.join('|')}|`; - - return [text, separatorRow].join('\n'); - }, - tr(el) { - const cellEls = el.querySelectorAll('td, th'); - if (cellEls.length === 0) return false; - - const cells = _.map(cellEls, cell => CopyAsGFM.nodeToGFM(cell)); - return `| ${cells.join(' | ')} |`; - }, - }, -}; +import { DOMParser } from 'prosemirror-model'; +import { getSelectedFragment } from '~/lib/utils/common_utils'; +import schema from './schema'; +import markdownSerializer from './serializer'; export class CopyAsGFM { constructor() { @@ -347,8 +35,13 @@ export class CopyAsGFM { e.preventDefault(); e.stopPropagation(); + const div = document.createElement('div'); + div.appendChild(el.cloneNode(true)); + const html = div.innerHTML; + clipboardData.setData('text/plain', el.textContent); clipboardData.setData('text/x-gfm', this.nodeToGFM(el)); + clipboardData.setData('text/html', html); } static pasteGFM(e) { @@ -361,7 +54,7 @@ export class CopyAsGFM { e.preventDefault(); - window.gl.utils.insertText(e.target, (textBefore, textAfter) => { + window.gl.utils.insertText(e.target, textBefore => { // If the text before the cursor contains an odd number of backticks, // we are either inside an inline code span that starts with 1 backtick // or a code block that starts with 3 backticks. @@ -443,75 +136,12 @@ export class CopyAsGFM { return codeElement; } - static nodeToGFM(node, respectWhitespaceParam = false) { - if (node.nodeType === Node.COMMENT_NODE) { - return ''; - } + static nodeToGFM(node) { + const wrapEl = document.createElement('div'); + wrapEl.appendChild(node.cloneNode(true)); + const doc = DOMParser.fromSchema(schema).parse(wrapEl); - if (node.nodeType === Node.TEXT_NODE) { - return node.textContent; - } - - const respectWhitespace = - respectWhitespaceParam || (node.nodeName === 'PRE' || node.nodeName === 'CODE'); - - const text = this.innerGFM(node, respectWhitespace); - - if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { - return text; - } - - for (const filter in gfmRules) { - const rules = gfmRules[filter]; - - for (const selector in rules) { - const func = rules[selector]; - - if (!nodeMatchesSelector(node, selector)) continue; - - let result; - if (func.length === 2) { - // if `func` takes 2 arguments, it depends on text. - // if there is no text, we don't need to generate GFM for this node. - if (text.length === 0) continue; - - result = func(node, text); - } else { - result = func(node); - } - - if (result === false) continue; - - return result; - } - } - - return text; - } - - static innerGFM(parentNode, respectWhitespace = false) { - const nodes = parentNode.childNodes; - - const clonedParentNode = parentNode.cloneNode(true); - const clonedNodes = Array.prototype.slice.call(clonedParentNode.childNodes, 0); - - for (let i = 0; i < nodes.length; i += 1) { - const node = nodes[i]; - const clonedNode = clonedNodes[i]; - - const text = this.nodeToGFM(node, respectWhitespace); - - // `clonedNode.replaceWith(text)` is not yet widely supported - clonedNode.parentNode.replaceChild(document.createTextNode(text), clonedNode); - } - - let nodeText = clonedParentNode.innerText || clonedParentNode.textContent; - - if (!respectWhitespace) { - nodeText = nodeText.trim(); - } - - return nodeText; + return markdownSerializer.serialize(doc); } } diff --git a/app/assets/javascripts/behaviors/markdown/editor_extensions.js b/app/assets/javascripts/behaviors/markdown/editor_extensions.js new file mode 100644 index 00000000000..47e5fc65c48 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/editor_extensions.js @@ -0,0 +1,106 @@ +import Doc from './nodes/doc'; +import Paragraph from './nodes/paragraph'; +import Text from './nodes/text'; + +import Blockquote from './nodes/blockquote'; +import CodeBlock from './nodes/code_block'; +import HardBreak from './nodes/hard_break'; +import Heading from './nodes/heading'; +import HorizontalRule from './nodes/horizontal_rule'; +import Image from './nodes/image'; + +import Table from './nodes/table'; +import TableHead from './nodes/table_head'; +import TableBody from './nodes/table_body'; +import TableHeaderRow from './nodes/table_header_row'; +import TableRow from './nodes/table_row'; +import TableCell from './nodes/table_cell'; + +import Emoji from './nodes/emoji'; +import Reference from './nodes/reference'; + +import TableOfContents from './nodes/table_of_contents'; +import Video from './nodes/video'; + +import BulletList from './nodes/bullet_list'; +import OrderedList from './nodes/ordered_list'; +import ListItem from './nodes/list_item'; + +import DescriptionList from './nodes/description_list'; +import DescriptionTerm from './nodes/description_term'; +import DescriptionDetails from './nodes/description_details'; + +import TaskList from './nodes/task_list'; +import OrderedTaskList from './nodes/ordered_task_list'; +import TaskListItem from './nodes/task_list_item'; + +import Summary from './nodes/summary'; +import Details from './nodes/details'; + +import Bold from './marks/bold'; +import Italic from './marks/italic'; +import Strike from './marks/strike'; +import InlineDiff from './marks/inline_diff'; + +import Link from './marks/link'; +import Code from './marks/code'; +import MathMark from './marks/math'; +import InlineHTML from './marks/inline_html'; + +// The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb transform +// GitLab Flavored Markdown (GFM) to HTML. +// The nodes and marks referenced here transform that same HTML to GFM to be copied to the clipboard. +// Every filter in lib/banzai/pipeline/gfm_pipeline.rb that generates HTML +// from GFM should have a node or mark here. +// The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb. + +export default [ + new Doc(), + new Paragraph(), + new Text(), + + new Blockquote(), + new CodeBlock(), + new HardBreak(), + new Heading({ maxLevel: 6 }), + new HorizontalRule(), + new Image(), + + new Table(), + new TableHead(), + new TableBody(), + new TableHeaderRow(), + new TableRow(), + new TableCell(), + + new Emoji(), + new Reference(), + + new TableOfContents(), + new Video(), + + new BulletList(), + new OrderedList(), + new ListItem(), + + new DescriptionList(), + new DescriptionTerm(), + new DescriptionDetails(), + + new TaskList(), + new OrderedTaskList(), + new TaskListItem(), + + new Summary(), + new Details(), + + new Bold(), + new Italic(), + new Strike(), + new InlineDiff(), + + new Link(), + new Code(), + new MathMark(), + new InlineHTML(), +]; diff --git a/app/assets/javascripts/behaviors/markdown/marks/bold.js b/app/assets/javascripts/behaviors/markdown/marks/bold.js new file mode 100644 index 00000000000..b537954c1cb --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/marks/bold.js @@ -0,0 +1,11 @@ +/* eslint-disable class-methods-use-this */ + +import { Bold as BaseBold } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Bold extends BaseBold { + get toMarkdown() { + return defaultMarkdownSerializer.marks.strong; + } +} diff --git a/app/assets/javascripts/behaviors/markdown/marks/code.js b/app/assets/javascripts/behaviors/markdown/marks/code.js new file mode 100644 index 00000000000..a760ee80dd0 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/marks/code.js @@ -0,0 +1,11 @@ +/* eslint-disable class-methods-use-this */ + +import { Code as BaseCode } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Code extends BaseCode { + get toMarkdown() { + return defaultMarkdownSerializer.marks.code; + } +} diff --git a/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js b/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js new file mode 100644 index 00000000000..ce425e80cd3 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js @@ -0,0 +1,41 @@ +/* eslint-disable class-methods-use-this */ + +import { Mark } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::InlineDiffFilter +export default class InlineDiff extends Mark { + get name() { + return 'inline_diff'; + } + + get schema() { + return { + attrs: { + addition: { + default: true, + }, + }, + parseDOM: [ + { tag: 'span.idiff.addition', attrs: { addition: true } }, + { tag: 'span.idiff.deletion', attrs: { addition: false } }, + ], + toDOM: node => [ + 'span', + { class: `idiff left right ${node.attrs.addition ? 'addition' : 'deletion'}` }, + 0, + ], + }; + } + + get toMarkdown() { + return { + mixable: true, + open(state, mark) { + return mark.attrs.addition ? '{+' : '{-'; + }, + close(state, mark) { + return mark.attrs.addition ? '+}' : '-}'; + }, + }; + } +} diff --git a/app/assets/javascripts/behaviors/markdown/marks/inline_html.js b/app/assets/javascripts/behaviors/markdown/marks/inline_html.js new file mode 100644 index 00000000000..ebed8698e21 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/marks/inline_html.js @@ -0,0 +1,46 @@ +/* eslint-disable class-methods-use-this */ + +import { Mark } from 'tiptap'; +import _ from 'underscore'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class InlineHTML extends Mark { + get name() { + return 'inline_html'; + } + + get schema() { + return { + excludes: '', + attrs: { + tag: {}, + title: { default: null }, + }, + parseDOM: [ + { + tag: 'sup, sub, kbd, q, samp, var', + getAttrs: el => ({ tag: el.nodeName.toLowerCase() }), + }, + { + tag: 'abbr', + getAttrs: el => ({ tag: 'abbr', title: el.getAttribute('title') }), + }, + ], + toDOM: node => [node.attrs.tag, { title: node.attrs.title }, 0], + }; + } + + get toMarkdown() { + return { + mixable: true, + open(state, mark) { + return `<${mark.attrs.tag}${ + mark.attrs.title ? ` title="${state.esc(_.escape(mark.attrs.title))}"` : '' + }>`; + }, + close(state, mark) { + return ``; + }, + }; + } +} diff --git a/app/assets/javascripts/behaviors/markdown/marks/italic.js b/app/assets/javascripts/behaviors/markdown/marks/italic.js new file mode 100644 index 00000000000..44b35c97739 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/marks/italic.js @@ -0,0 +1,11 @@ +/* eslint-disable class-methods-use-this */ + +import { Italic as BaseItalic } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Italic extends BaseItalic { + get toMarkdown() { + return defaultMarkdownSerializer.marks.em; + } +} diff --git a/app/assets/javascripts/behaviors/markdown/marks/link.js b/app/assets/javascripts/behaviors/markdown/marks/link.js new file mode 100644 index 00000000000..5c23d6a5ceb --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/marks/link.js @@ -0,0 +1,21 @@ +/* eslint-disable class-methods-use-this */ + +import { Link as BaseLink } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Link extends BaseLink { + get toMarkdown() { + return { + mixable: true, + open(state, mark, parent, index) { + const open = defaultMarkdownSerializer.marks.link.open(state, mark, parent, index); + return open === '<' ? '' : open; + }, + close(state, mark, parent, index) { + const close = defaultMarkdownSerializer.marks.link.close(state, mark, parent, index); + return close === '>' ? '' : close; + }, + }; + } +} diff --git a/app/assets/javascripts/behaviors/markdown/marks/math.js b/app/assets/javascripts/behaviors/markdown/marks/math.js new file mode 100644 index 00000000000..e582fb18f15 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/marks/math.js @@ -0,0 +1,41 @@ +/* eslint-disable class-methods-use-this */ + +import { Mark } from 'tiptap'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MathFilter +export default class MathMark extends Mark { + get name() { + return 'math'; + } + + get schema() { + return { + parseDOM: [ + // Matches HTML generated by Banzai::Filter::MathFilter + { + tag: 'code.code.math[data-math-style=inline]', + priority: 51, + }, + // Matches HTML after being transformed by app/assets/javascripts/behaviors/markdown/render_math.js + { + tag: 'span.katex', + contentElement: 'annotation[encoding="application/x-tex"]', + }, + ], + toDOM: () => ['code', { class: 'code math', 'data-math-style': 'inline' }, 0], + }; + } + + get toMarkdown() { + return { + escape: false, + open(state, mark, parent, index) { + return `$${defaultMarkdownSerializer.marks.code.open(state, mark, parent, index)}`; + }, + close(state, mark, parent, index) { + return `${defaultMarkdownSerializer.marks.code.close(state, mark, parent, index)}$`; + }, + }; + } +} diff --git a/app/assets/javascripts/behaviors/markdown/marks/strike.js b/app/assets/javascripts/behaviors/markdown/marks/strike.js new file mode 100644 index 00000000000..c2951a40a4b --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/marks/strike.js @@ -0,0 +1,15 @@ +/* eslint-disable class-methods-use-this */ + +import { Strike as BaseStrike } from 'tiptap-extensions'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Strike extends BaseStrike { + get toMarkdown() { + return { + open: '~~', + close: '~~', + mixable: true, + expelEnclosingWhitespace: true, + }; + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js b/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js new file mode 100644 index 00000000000..b0bc8f79643 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js @@ -0,0 +1,13 @@ +/* eslint-disable class-methods-use-this */ + +import { Blockquote as BaseBlockquote } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Blockquote extends BaseBlockquote { + toMarkdown(state, node) { + if (!node.childCount) return; + + defaultMarkdownSerializer.nodes.blockquote(state, node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js b/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js new file mode 100644 index 00000000000..3b0792e1af8 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js @@ -0,0 +1,11 @@ +/* eslint-disable class-methods-use-this */ + +import { BulletList as BaseBulletList } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class BulletList extends BaseBulletList { + toMarkdown(state, node) { + defaultMarkdownSerializer.nodes.bullet_list(state, node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/code_block.js b/app/assets/javascripts/behaviors/markdown/nodes/code_block.js new file mode 100644 index 00000000000..b9b894b3348 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/code_block.js @@ -0,0 +1,79 @@ +/* eslint-disable class-methods-use-this */ + +import { CodeBlock as BaseCodeBlock } from 'tiptap-extensions'; + +const PLAINTEXT_LANG = 'plaintext'; + +// Transforms generated HTML back to GFM for: +// - Banzai::Filter::SyntaxHighlightFilter +// - Banzai::Filter::MathFilter +// - Banzai::Filter::MermaidFilter +export default class CodeBlock extends BaseCodeBlock { + get schema() { + return { + content: 'text*', + marks: '', + group: 'block', + code: true, + defining: true, + attrs: { + lang: { default: PLAINTEXT_LANG }, + }, + parseDOM: [ + // Matches HTML generated by Banzai::Filter::SyntaxHighlightFilter, Banzai::Filter::MathFilter or Banzai::Filter::MermaidFilter + { + tag: 'pre.code.highlight', + preserveWhitespace: 'full', + getAttrs: el => { + const lang = el.getAttribute('lang'); + if (!lang || lang === '') return {}; + + return { lang }; + }, + }, + // Matches HTML generated by Banzai::Filter::MathFilter, + // after being transformed by app/assets/javascripts/behaviors/markdown/render_math.js + { + tag: 'span.katex-display', + preserveWhitespace: 'full', + contentElement: 'annotation[encoding="application/x-tex"]', + attrs: { lang: 'math' }, + }, + // Matches HTML generated by Banzai::Filter::MathFilter, + // after being transformed by app/assets/javascripts/behaviors/markdown/render_mermaid.js + { + tag: 'svg.mermaid', + preserveWhitespace: 'full', + contentElement: 'text.source', + attrs: { lang: 'mermaid' }, + }, + ], + toDOM: node => ['pre', { class: 'code highlight', lang: node.attrs.lang }, ['code', 0]], + }; + } + + toMarkdown(state, node) { + if (!node.childCount) return; + + const { + textContent: text, + attrs: { lang }, + } = node; + + // Prefixes lines with 4 spaces if the code contains a line that starts with triple backticks + if (lang === PLAINTEXT_LANG && text.match(/^```/gm)) { + state.wrapBlock(' ', null, node, () => state.text(text, false)); + return; + } + + state.write('```'); + if (lang !== PLAINTEXT_LANG) state.write(lang); + + state.ensureNewLine(); + state.text(text, false); + state.ensureNewLine(); + + state.write('```'); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/description_details.js b/app/assets/javascripts/behaviors/markdown/nodes/description_details.js new file mode 100644 index 00000000000..a4451d8ce8d --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/description_details.js @@ -0,0 +1,28 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class DescriptionDetails extends Node { + get name() { + return 'description_details'; + } + + get schema() { + return { + content: 'text*', + marks: '', + defining: true, + parseDOM: [{ tag: 'dd' }], + toDOM: () => ['dd', 0], + }; + } + + toMarkdown(state, node) { + state.flushClose(1); + state.write('
'); + state.text(node.textContent, false); + state.write('
'); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/description_list.js b/app/assets/javascripts/behaviors/markdown/nodes/description_list.js new file mode 100644 index 00000000000..6aa1aca29d7 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/description_list.js @@ -0,0 +1,28 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class DescriptionList extends Node { + get name() { + return 'description_list'; + } + + get schema() { + return { + content: '(description_term+ description_details+)+', + group: 'block', + parseDOM: [{ tag: 'dl' }], + toDOM: () => ['dl', 0], + }; + } + + toMarkdown(state, node) { + state.write('
\n'); + state.wrapBlock(' ', null, node, () => state.renderContent(node)); + state.flushClose(1); + state.ensureNewLine(); + state.write('
'); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/description_term.js b/app/assets/javascripts/behaviors/markdown/nodes/description_term.js new file mode 100644 index 00000000000..89057ec6444 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/description_term.js @@ -0,0 +1,28 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class DescriptionTerm extends Node { + get name() { + return 'description_term'; + } + + get schema() { + return { + content: 'text*', + marks: '', + defining: true, + parseDOM: [{ tag: 'dt' }], + toDOM: () => ['dt', 0], + }; + } + + toMarkdown(state, node) { + state.flushClose(state.closed && state.closed.type === node.type ? 1 : 2); + state.write('
'); + state.text(node.textContent, false); + state.write('
'); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/details.js b/app/assets/javascripts/behaviors/markdown/nodes/details.js new file mode 100644 index 00000000000..1c40dbb8168 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/details.js @@ -0,0 +1,28 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Details extends Node { + get name() { + return 'details'; + } + + get schema() { + return { + content: 'summary block*', + group: 'block', + parseDOM: [{ tag: 'details' }], + toDOM: () => ['details', { open: true, onclick: 'return false', tabindex: '-1' }, 0], + }; + } + + toMarkdown(state, node) { + state.write('
\n'); + state.renderContent(node); + state.flushClose(1); + state.ensureNewLine(); + state.write('
'); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/doc.js b/app/assets/javascripts/behaviors/markdown/nodes/doc.js new file mode 100644 index 00000000000..88b16fd85da --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/doc.js @@ -0,0 +1,15 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +export default class Doc extends Node { + get name() { + return 'doc'; + } + + get schema() { + return { + content: 'block+', + }; + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/emoji.js b/app/assets/javascripts/behaviors/markdown/nodes/emoji.js new file mode 100644 index 00000000000..a7cc3e828f5 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/emoji.js @@ -0,0 +1,41 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::EmojiFilter +export default class Emoji extends Node { + get name() { + return 'emoji'; + } + + get schema() { + return { + inline: true, + group: 'inline', + attrs: { + name: {}, + title: {}, + moji: {}, + }, + parseDOM: [ + { + tag: 'gl-emoji', + getAttrs: el => ({ + name: el.dataset.name, + title: el.getAttribute('title'), + moji: el.textContent, + }), + }, + ], + toDOM: node => [ + 'gl-emoji', + { 'data-name': node.attrs.name, title: node.attrs.title }, + node.attrs.moji, + ], + }; + } + + toMarkdown(state, node) { + state.write(`:${node.attrs.name}:`); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/hard_break.js b/app/assets/javascripts/behaviors/markdown/nodes/hard_break.js new file mode 100644 index 00000000000..59e5d8ab3e2 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/hard_break.js @@ -0,0 +1,10 @@ +/* eslint-disable class-methods-use-this */ + +import { HardBreak as BaseHardBreak } from 'tiptap-extensions'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class HardBreak extends BaseHardBreak { + toMarkdown(state) { + if (!state.atBlank()) state.write(' \n'); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/heading.js b/app/assets/javascripts/behaviors/markdown/nodes/heading.js new file mode 100644 index 00000000000..fec8608cf5d --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/heading.js @@ -0,0 +1,13 @@ +/* eslint-disable class-methods-use-this */ + +import { Heading as BaseHeading } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Heading extends BaseHeading { + toMarkdown(state, node) { + if (!node.childCount) return; + + defaultMarkdownSerializer.nodes.heading(state, node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js b/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js new file mode 100644 index 00000000000..695c7160bde --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js @@ -0,0 +1,11 @@ +/* eslint-disable class-methods-use-this */ + +import { HorizontalRule as BaseHorizontalRule } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class HorizontalRule extends BaseHorizontalRule { + toMarkdown(state, node) { + defaultMarkdownSerializer.nodes.horizontal_rule(state, node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/image.js b/app/assets/javascripts/behaviors/markdown/nodes/image.js new file mode 100644 index 00000000000..c225a5ed876 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/image.js @@ -0,0 +1,52 @@ +/* eslint-disable class-methods-use-this */ + +import { Image as BaseImage } from 'tiptap-extensions'; +import { placeholderImage } from '~/lazy_loader'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +export default class Image extends BaseImage { + get schema() { + return { + attrs: { + src: {}, + alt: { + default: null, + }, + title: { + default: null, + }, + }, + group: 'inline', + inline: true, + draggable: true, + parseDOM: [ + // Matches HTML generated by Banzai::Filter::ImageLinkFilter + { + tag: 'a.no-attachment-icon', + priority: 51, + skip: true, + }, + // Matches HTML generated by Banzai::Filter::ImageLazyLoadFilter + { + tag: 'img[src]', + getAttrs: el => { + const imageSrc = el.src; + const imageUrl = + imageSrc && imageSrc !== placeholderImage ? imageSrc : el.dataset.src || ''; + + return { + src: imageUrl, + title: el.getAttribute('title'), + alt: el.getAttribute('alt'), + }; + }, + }, + ], + toDOM: node => ['img', node.attrs], + }; + } + + toMarkdown(state, node) { + defaultMarkdownSerializer.nodes.image(state, node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/list_item.js b/app/assets/javascripts/behaviors/markdown/nodes/list_item.js new file mode 100644 index 00000000000..4237637ed9a --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/list_item.js @@ -0,0 +1,11 @@ +/* eslint-disable class-methods-use-this */ + +import { ListItem as BaseListItem } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class ListItem extends BaseListItem { + toMarkdown(state, node) { + defaultMarkdownSerializer.nodes.list_item(state, node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js b/app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js new file mode 100644 index 00000000000..4c1542d14ea --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js @@ -0,0 +1,10 @@ +/* eslint-disable class-methods-use-this */ + +import { OrderedList as BaseOrderedList } from 'tiptap-extensions'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class OrderedList extends BaseOrderedList { + toMarkdown(state, node) { + state.renderList(node, ' ', () => '1. '); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js b/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js new file mode 100644 index 00000000000..25c4976a1bc --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js @@ -0,0 +1,28 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter +export default class OrderedTaskList extends Node { + get name() { + return 'ordered_task_list'; + } + + get schema() { + return { + group: 'block', + content: '(task_list_item|list_item)+', + parseDOM: [ + { + priority: 51, + tag: 'ol.task-list', + }, + ], + toDOM: () => ['ol', { class: 'task-list' }, 0], + }; + } + + toMarkdown(state, node) { + state.renderList(node, ' ', () => '1. '); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js b/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js new file mode 100644 index 00000000000..dec3207b1bb --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js @@ -0,0 +1,24 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Paragraph extends Node { + get name() { + return 'paragraph'; + } + + get schema() { + return { + content: 'inline*', + group: 'block', + parseDOM: [{ tag: 'p' }], + toDOM: () => ['p', 0], + }; + } + + toMarkdown(state, node) { + defaultMarkdownSerializer.nodes.paragraph(state, node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/reference.js b/app/assets/javascripts/behaviors/markdown/nodes/reference.js new file mode 100644 index 00000000000..5d6bbeca833 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/reference.js @@ -0,0 +1,52 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::ReferenceFilter and subclasses +export default class Reference extends Node { + get name() { + return 'reference'; + } + + get schema() { + return { + inline: true, + group: 'inline', + atom: true, + attrs: { + className: {}, + referenceType: {}, + originalText: { default: null }, + href: {}, + text: {}, + }, + parseDOM: [ + { + tag: 'a.gfm:not([data-link=true])', + priority: 51, + getAttrs: el => ({ + className: el.className, + referenceType: el.dataset.referenceType, + originalText: el.dataset.original, + href: el.getAttribute('href'), + text: el.textContent, + }), + }, + ], + toDOM: node => [ + 'a', + { + class: node.attrs.className, + href: node.attrs.href, + 'data-reference-type': node.attrs.referenceType, + 'data-original': node.attrs.originalText, + }, + node.attrs.text, + ], + }; + } + + toMarkdown(state, node) { + state.write(node.attrs.originalText || node.attrs.text); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/summary.js b/app/assets/javascripts/behaviors/markdown/nodes/summary.js new file mode 100644 index 00000000000..2e36e316d71 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/summary.js @@ -0,0 +1,27 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Summary extends Node { + get name() { + return 'summary'; + } + + get schema() { + return { + content: 'text*', + marks: '', + defining: true, + parseDOM: [{ tag: 'summary' }], + toDOM: () => ['summary', 0], + }; + } + + toMarkdown(state, node) { + state.write(''); + state.text(node.textContent, false); + state.write(''); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table.js b/app/assets/javascripts/behaviors/markdown/nodes/table.js new file mode 100644 index 00000000000..a7fcb9227cd --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/table.js @@ -0,0 +1,25 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Table extends Node { + get name() { + return 'table'; + } + + get schema() { + return { + content: 'table_head table_body', + group: 'block', + isolating: true, + parseDOM: [{ tag: 'table' }], + toDOM: () => ['table', 0], + }; + } + + toMarkdown(state, node) { + state.renderContent(node); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_body.js b/app/assets/javascripts/behaviors/markdown/nodes/table_body.js new file mode 100644 index 00000000000..403556dc0c8 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/table_body.js @@ -0,0 +1,24 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class TableBody extends Node { + get name() { + return 'table_body'; + } + + get schema() { + return { + content: 'table_row+', + parseDOM: [{ tag: 'tbody' }], + toDOM: () => ['tbody', 0], + }; + } + + toMarkdown(state, node) { + state.flushClose(1); + state.renderContent(node); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_cell.js b/app/assets/javascripts/behaviors/markdown/nodes/table_cell.js new file mode 100644 index 00000000000..c63bfe10e39 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/table_cell.js @@ -0,0 +1,35 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class TableCell extends Node { + get name() { + return 'table_cell'; + } + + get schema() { + return { + attrs: { + header: { default: false }, + align: { default: null }, + }, + content: 'inline*', + isolating: true, + parseDOM: [ + { + tag: 'td, th', + getAttrs: el => ({ + header: el.tagName === 'TH', + align: el.getAttribute('align') || el.style.textAlign, + }), + }, + ], + toDOM: node => [node.attrs.header ? 'th' : 'td', { align: node.attrs.align }, 0], + }; + } + + toMarkdown(state, node) { + state.renderInline(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_head.js b/app/assets/javascripts/behaviors/markdown/nodes/table_head.js new file mode 100644 index 00000000000..4cb94bf088c --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/table_head.js @@ -0,0 +1,24 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class TableHead extends Node { + get name() { + return 'table_head'; + } + + get schema() { + return { + content: 'table_header_row', + parseDOM: [{ tag: 'thead' }], + toDOM: () => ['thead', 0], + }; + } + + toMarkdown(state, node) { + state.flushClose(1); + state.renderContent(node); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js b/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js new file mode 100644 index 00000000000..e7eee636402 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js @@ -0,0 +1,43 @@ +/* eslint-disable class-methods-use-this */ + +import TableRow from './table_row'; + +const CENTER_ALIGN = 'center'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class TableHeaderRow extends TableRow { + get name() { + return 'table_header_row'; + } + + get schema() { + return { + content: 'table_cell+', + parseDOM: [ + { + tag: 'thead tr', + priority: 51, + }, + ], + toDOM: () => ['tr', 0], + }; + } + + toMarkdown(state, node) { + const cellWidths = super.toMarkdown(state, node); + + state.flushClose(1); + + state.write('|'); + node.forEach((cell, _, i) => { + if (i) state.write('|'); + + state.write(cell.attrs.align === CENTER_ALIGN ? ':' : '-'); + state.write(state.repeat('-', cellWidths[i])); + state.write(cell.attrs.align === CENTER_ALIGN || cell.attrs.align === 'right' ? ':' : '-'); + }); + state.write('|'); + + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js b/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js new file mode 100644 index 00000000000..20c7fa8a9ab --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js @@ -0,0 +1,33 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::TableOfContentsFilter +export default class TableOfContents extends Node { + get name() { + return 'table_of_contents'; + } + + get schema() { + return { + group: 'block', + atom: true, + parseDOM: [ + { + tag: 'ul.section-nav', + priority: 51, + }, + { + tag: 'p.table-of-contents', + priority: 51, + }, + ], + toDOM: () => ['p', { class: 'table-of-contents' }, 'Table of Contents'], + }; + } + + toMarkdown(state, node) { + state.write('[[_TOC_]]'); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_row.js b/app/assets/javascripts/behaviors/markdown/nodes/table_row.js new file mode 100644 index 00000000000..5852502773a --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/table_row.js @@ -0,0 +1,38 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class TableRow extends Node { + get name() { + return 'table_row'; + } + + get schema() { + return { + content: 'table_cell+', + parseDOM: [{ tag: 'tr' }], + toDOM: () => ['tr', 0], + }; + } + + toMarkdown(state, node) { + const cellWidths = []; + + state.flushClose(1); + + state.write('| '); + node.forEach((cell, _, i) => { + if (i) state.write(' | '); + + const { length } = state.out; + state.render(cell, node, i); + cellWidths.push(state.out.length - length); + }); + state.write(' |'); + + state.closeBlock(node); + + return cellWidths; + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/task_list.js b/app/assets/javascripts/behaviors/markdown/nodes/task_list.js new file mode 100644 index 00000000000..ab33bc21502 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/task_list.js @@ -0,0 +1,28 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter +export default class TaskList extends Node { + get name() { + return 'task_list'; + } + + get schema() { + return { + group: 'block', + content: '(task_list_item|list_item)+', + parseDOM: [ + { + priority: 51, + tag: 'ul.task-list', + }, + ], + toDOM: () => ['ul', { class: 'task-list' }, 0], + }; + } + + toMarkdown(state, node) { + state.renderList(node, ' ', () => '* '); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js new file mode 100644 index 00000000000..d0ee7333d5e --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js @@ -0,0 +1,49 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter +export default class TaskListItem extends Node { + get name() { + return 'task_list_item'; + } + + get schema() { + return { + attrs: { + done: { + default: false, + }, + }, + defining: true, + draggable: false, + content: 'paragraph block*', + parseDOM: [ + { + priority: 51, + tag: 'li.task-list-item', + getAttrs: el => { + const checkbox = el.querySelector('input[type=checkbox].task-list-item-checkbox'); + return { done: checkbox && checkbox.checked }; + }, + }, + ], + toDOM(node) { + return [ + 'li', + { class: 'task-list-item' }, + [ + 'input', + { type: 'checkbox', class: 'task-list-item-checkbox', checked: node.attrs.done }, + ], + ['div', { class: 'todo-content' }, 0], + ]; + }, + }; + } + + toMarkdown(state, node) { + state.write(`[${node.attrs.done ? 'x' : ' '}] `); + state.renderContent(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/text.js b/app/assets/javascripts/behaviors/markdown/nodes/text.js new file mode 100644 index 00000000000..84838c14999 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/text.js @@ -0,0 +1,20 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +export default class Text extends Node { + get name() { + return 'text'; + } + + get schema() { + return { + group: 'inline', + }; + } + + toMarkdown(state, node) { + defaultMarkdownSerializer.nodes.text(state, node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/video.js b/app/assets/javascripts/behaviors/markdown/nodes/video.js new file mode 100644 index 00000000000..516f983397d --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/video.js @@ -0,0 +1,54 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::VideoLinkFilter +export default class Video extends Node { + get name() { + return 'video'; + } + + get schema() { + return { + attrs: { + src: {}, + alt: { + default: null, + }, + }, + group: 'block', + draggable: true, + parseDOM: [ + { + tag: '.video-container', + skip: true, + }, + { + tag: '.video-container p', + priority: 51, + ignore: true, + }, + { + tag: 'video[src]', + getAttrs: el => ({ src: el.getAttribute('src'), alt: el.dataset.title }), + }, + ], + toDOM: node => [ + 'video', + { + src: node.attrs.src, + width: '400', + controls: true, + 'data-setup': '{}', + 'data-title': node.attrs.alt, + }, + ], + }; + } + + toMarkdown(state, node) { + defaultMarkdownSerializer.nodes.image(state, node); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/schema.js b/app/assets/javascripts/behaviors/markdown/schema.js new file mode 100644 index 00000000000..163182ab778 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/schema.js @@ -0,0 +1,24 @@ +import { Schema } from 'prosemirror-model'; +import editorExtensions from './editor_extensions'; + +const nodes = editorExtensions + .filter(extension => extension.type === 'node') + .reduce( + (ns, { name, schema }) => ({ + ...ns, + [name]: schema, + }), + {}, + ); + +const marks = editorExtensions + .filter(extension => extension.type === 'mark') + .reduce( + (ms, { name, schema }) => ({ + ...ms, + [name]: schema, + }), + {}, + ); + +export default new Schema({ nodes, marks }); diff --git a/app/assets/javascripts/behaviors/markdown/serializer.js b/app/assets/javascripts/behaviors/markdown/serializer.js new file mode 100644 index 00000000000..70dbd8bd206 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/serializer.js @@ -0,0 +1,24 @@ +import { MarkdownSerializer } from 'prosemirror-markdown'; +import editorExtensions from './editor_extensions'; + +const nodes = editorExtensions + .filter(extension => extension.type === 'node') + .reduce( + (ns, { name, toMarkdown }) => ({ + ...ns, + [name]: toMarkdown, + }), + {}, + ); + +const marks = editorExtensions + .filter(extension => extension.type === 'mark') + .reduce( + (ms, { name, toMarkdown }) => ({ + ...ms, + [name]: toMarkdown, + }), + {}, + ); + +export default new MarkdownSerializer(nodes, marks); diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js index 2918e1486a7..0eb067d4963 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js @@ -1,6 +1,5 @@ import $ from 'jquery'; import Mousetrap from 'mousetrap'; -import _ from 'underscore'; import Sidebar from '../../right_sidebar'; import Shortcuts from './shortcuts'; import { CopyAsGFM } from '../markdown/copy_as_gfm'; @@ -63,18 +62,18 @@ export default class ShortcutsIssuable extends Shortcuts { } const el = CopyAsGFM.transformGFMSelection(documentFragment.cloneNode(true)); - const selected = CopyAsGFM.nodeToGFM(el); + const blockquoteEl = document.createElement('blockquote'); + blockquoteEl.appendChild(el); + const text = CopyAsGFM.nodeToGFM(blockquoteEl); - if (selected.trim() === '') { + if (text.trim() === '') { return false; } - const quote = _.map(selected.split('\n'), val => `${`> ${val}`.trim()}\n`); - // If replyField already has some content, add a newline before our quote const separator = ($replyField.val().trim() !== '' && '\n\n') || ''; $replyField - .val((a, current) => `${current}${separator}${quote.join('')}\n`) + .val((a, current) => `${current}${separator}${text}\n\n`) .trigger('input') .trigger('change'); diff --git a/lib/banzai/filter/emoji_filter.rb b/lib/banzai/filter/emoji_filter.rb index c87948a30bf..fa1690f73ad 100644 --- a/lib/banzai/filter/emoji_filter.rb +++ b/lib/banzai/filter/emoji_filter.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/emoji.js module Banzai module Filter # HTML filter that replaces :emoji: and unicode with images. diff --git a/lib/banzai/filter/image_lazy_load_filter.rb b/lib/banzai/filter/image_lazy_load_filter.rb index afaee70f351..d8b9eb29cf5 100644 --- a/lib/banzai/filter/image_lazy_load_filter.rb +++ b/lib/banzai/filter/image_lazy_load_filter.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/image.js module Banzai module Filter # HTML filter that moves the value of image `src` attributes to `data-src` diff --git a/lib/banzai/filter/image_link_filter.rb b/lib/banzai/filter/image_link_filter.rb index 884a94fb761..01237303c27 100644 --- a/lib/banzai/filter/image_link_filter.rb +++ b/lib/banzai/filter/image_link_filter.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/image.js module Banzai module Filter # HTML filter that wraps links around inline images. diff --git a/lib/banzai/filter/inline_diff_filter.rb b/lib/banzai/filter/inline_diff_filter.rb index e9ddc6e0e3d..5a1c0bee32d 100644 --- a/lib/banzai/filter/inline_diff_filter.rb +++ b/lib/banzai/filter/inline_diff_filter.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/marks/inline_diff.js module Banzai module Filter class InlineDiffFilter < HTML::Pipeline::Filter diff --git a/lib/banzai/filter/math_filter.rb b/lib/banzai/filter/math_filter.rb index 9d1bc3cf60c..8dd5a8979c8 100644 --- a/lib/banzai/filter/math_filter.rb +++ b/lib/banzai/filter/math_filter.rb @@ -2,6 +2,9 @@ require 'uri' +# Generated HTML is transformed back to GFM by: +# - app/assets/javascripts/behaviors/markdown/marks/math.js +# - app/assets/javascripts/behaviors/markdown/nodes/code_block.js module Banzai module Filter # HTML filter that adds class="code math" and removes the dollar sign in $`2+2`$. diff --git a/lib/banzai/filter/mermaid_filter.rb b/lib/banzai/filter/mermaid_filter.rb index 7c8b165a330..f0adb83af8a 100644 --- a/lib/banzai/filter/mermaid_filter.rb +++ b/lib/banzai/filter/mermaid_filter.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/code_block.js module Banzai module Filter class MermaidFilter < HTML::Pipeline::Filter diff --git a/lib/banzai/filter/reference_filter.rb b/lib/banzai/filter/reference_filter.rb index e5164e7f72a..42f9b3a689c 100644 --- a/lib/banzai/filter/reference_filter.rb +++ b/lib/banzai/filter/reference_filter.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/reference.js module Banzai module Filter # Base class for GitLab Flavored Markdown reference filters. diff --git a/lib/banzai/filter/syntax_highlight_filter.rb b/lib/banzai/filter/syntax_highlight_filter.rb index 18e5e9185de..bcf77861f10 100644 --- a/lib/banzai/filter/syntax_highlight_filter.rb +++ b/lib/banzai/filter/syntax_highlight_filter.rb @@ -3,6 +3,7 @@ require 'rouge/plugins/common_mark' require 'rouge/plugins/redcarpet' +# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/code_block.js module Banzai module Filter # HTML Filter to highlight fenced code blocks diff --git a/lib/banzai/filter/table_of_contents_filter.rb b/lib/banzai/filter/table_of_contents_filter.rb index c6d1e028eaa..f2ae17b44fa 100644 --- a/lib/banzai/filter/table_of_contents_filter.rb +++ b/lib/banzai/filter/table_of_contents_filter.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js module Banzai module Filter # HTML filter that adds an anchor child element to all Headers in a diff --git a/lib/banzai/filter/task_list_filter.rb b/lib/banzai/filter/task_list_filter.rb index ef35a49edcb..c6b402575cb 100644 --- a/lib/banzai/filter/task_list_filter.rb +++ b/lib/banzai/filter/task_list_filter.rb @@ -2,6 +2,10 @@ require 'task_list/filter' +# Generated HTML is transformed back to GFM by: +# - app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js +# - app/assets/javascripts/behaviors/markdown/nodes/task_list.js +# - app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js module Banzai module Filter class TaskListFilter < TaskList::Filter diff --git a/lib/banzai/filter/video_link_filter.rb b/lib/banzai/filter/video_link_filter.rb index 0fb59c914c3..0fff104cf91 100644 --- a/lib/banzai/filter/video_link_filter.rb +++ b/lib/banzai/filter/video_link_filter.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/video.js module Banzai module Filter # Find every image that isn't already wrapped in an `a` tag, and that has diff --git a/lib/banzai/pipeline/gfm_pipeline.rb b/lib/banzai/pipeline/gfm_pipeline.rb index d860dad0b6c..30cafd11834 100644 --- a/lib/banzai/pipeline/gfm_pipeline.rb +++ b/lib/banzai/pipeline/gfm_pipeline.rb @@ -3,11 +3,11 @@ module Banzai module Pipeline class GfmPipeline < BasePipeline - # These filters convert GitLab Flavored Markdown (GFM) to HTML. - # The handlers defined in app/assets/javascripts/behaviors/markdown/copy_as_gfm.js - # consequently convert that same HTML to GFM to be copied to the clipboard. - # Every filter that generates HTML from GFM should have a handler in - # app/assets/javascripts/behaviors/markdown/copy_as_gfm.js, in reverse order. + # These filters transform GitLab Flavored Markdown (GFM) to HTML. + # The nodes and marks referenced in app/assets/javascripts/behaviors/markdown/editor_extensions.js + # consequently transform that same HTML to GFM to be copied to the clipboard. + # Every filter that generates HTML from GFM should have a node or mark in + # app/assets/javascripts/behaviors/markdown/editor_extensions.js. # The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb. def self.filters @filters ||= FilterArray[ diff --git a/package.json b/package.json index ef5c3511121..82c15f90745 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,8 @@ "pikaday": "^1.6.1", "popper.js": "^1.14.3", "prismjs": "^1.6.0", + "prosemirror-markdown": "^1.3.0", + "prosemirror-model": "^1.6.4", "raphael": "^2.2.7", "raven-js": "^3.22.1", "raw-loader": "^1.0.0", @@ -101,6 +103,9 @@ "three-orbit-controls": "^82.1.0", "three-stl-loader": "^1.0.4", "timeago.js": "^3.0.2", + "tiptap": "^1.8.0", + "tiptap-commands": "^1.4.0", + "tiptap-extensions": "^1.8.0", "underscore": "^1.9.0", "url-loader": "^1.1.2", "visibilityjs": "^1.2.4", diff --git a/spec/features/markdown/copy_as_gfm_spec.rb b/spec/features/markdown/copy_as_gfm_spec.rb index 05228e27963..92dee494b7e 100644 --- a/spec/features/markdown/copy_as_gfm_spec.rb +++ b/spec/features/markdown/copy_as_gfm_spec.rb @@ -19,9 +19,9 @@ describe 'Copy as GFM', :js do visit project_issue_path(@project, @feat.issue) end - # The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert GitLab Flavored Markdown (GFM) to HTML. - # The handlers defined in app/assets/javascripts/behaviors/markdown/copy_as_gfm.js consequently convert that same HTML to GFM. - # To make sure these filters and handlers are properly aligned, this spec tests the GFM-to-HTML-to-GFM cycle + # The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb transform GitLab Flavored Markdown (GFM) to HTML. + # The nodes and marks referenced in app/assets/javascripts/behaviors/markdown/editor_extensions.js consequently transform that same HTML to GFM. + # To make sure these filters and nodes/marks are properly aligned, this spec tests the GFM-to-HTML-to-GFM cycle # by verifying (`html_to_gfm(gfm_to_html(gfm)) == gfm`) for a number of examples of GFM for every filter, using the `verify` helper. # These are all in a single `it` for performance reasons. @@ -35,12 +35,15 @@ describe 'Copy as GFM', :js do verify( 'a real world example from the gitlab-ce README', - <<-GFM.strip_heredoc + <<~GFM # GitLab [![Build status](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/build.svg)](https://gitlab.com/gitlab-org/gitlab-ce/commits/master) + [![CE coverage report](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/coverage.svg?job=coverage)](https://gitlab-org.gitlab.io/gitlab-ce/coverage-ruby) + [![Code Climate](https://codeclimate.com/github/gitlabhq/gitlabhq.svg)](https://codeclimate.com/github/gitlabhq/gitlabhq) + [![Core Infrastructure Initiative Best Practices](https://bestpractices.coreinfrastructure.org/projects/42/badge)](https://bestpractices.coreinfrastructure.org/projects/42) ## Canonical source @@ -51,27 +54,31 @@ describe 'Copy as GFM', :js do To see how GitLab looks please see the [features page on our website](https://about.gitlab.com/features/). - - Manage Git repositories with fine grained access controls that keep your code secure + * Manage Git repositories with fine grained access controls that keep your code secure - - Perform code reviews and enhance collaboration with merge requests + * Perform code reviews and enhance collaboration with merge requests - - Complete continuous integration (CI) and CD pipelines to builds, test, and deploy your applications + * Complete continuous integration (CI) and CD pipelines to builds, test, and deploy your applications - - Each project can also have an issue tracker, issue board, and a wiki + * Each project can also have an issue tracker, issue board, and a wiki - - Used by more than 100,000 organizations, GitLab is the most popular solution to manage Git repositories on-premises + * Used by more than 100,000 organizations, GitLab is the most popular solution to manage Git repositories on-premises - - Completely free and open source (MIT Expat license) + * Completely free and open source (MIT Expat license) GFM ) aggregate_failures('an accidentally selected empty element') do gfm = '# Heading1' - html = <<-HTML.strip_heredoc + html = <<~HTML

Heading1

+ +
+ +

         HTML
 
         output_gfm = html_to_gfm(html)
@@ -81,7 +88,7 @@ describe 'Copy as GFM', :js do
       aggregate_failures('an accidentally selected other element') do
         gfm = 'Test comment with **Markdown!**'
 
-        html = <<-HTML.strip_heredoc
+        html = <<~HTML
           
  • @@ -107,10 +114,17 @@ describe 'Copy as GFM', :js do verify( 'TaskListFilter', - '- [ ] Unchecked task', - '- [x] Checked task', - '1. [ ] Unchecked numbered task', - '1. [x] Checked numbered task' + <<~GFM, + * [ ] Unchecked task + + * [x] Checked task + GFM + + <<~GFM + 1. [ ] Unchecked ordered task + + 1. [x] Checked ordered task + GFM ) verify( @@ -139,7 +153,16 @@ describe 'Copy as GFM', :js do verify( 'TableOfContentsFilter', - '[[_TOC_]]' + <<~GFM, + [[_TOC_]] + + # Heading 1 + + ## Heading 2 + GFM + + pipeline: :wiki, + project_wiki: @project.wiki ) verify( @@ -166,7 +189,7 @@ describe 'Copy as GFM', :js do '$`c = \pm\sqrt{a^2 + b^2}`$', # math block - <<-GFM.strip_heredoc + <<~GFM ```math c = \pm\sqrt{a^2 + b^2} ``` @@ -176,7 +199,7 @@ describe 'Copy as GFM', :js do aggregate_failures('MathFilter: math as transformed from HTML to KaTeX') do gfm = '$`c = \pm\sqrt{a^2 + b^2}`$' - html = <<-HTML.strip_heredoc + html = <<~HTML @@ -287,7 +310,7 @@ describe 'Copy as GFM', :js do verify( 'MermaidFilter: mermaid as converted from GFM to HTML', - <<-GFM.strip_heredoc + <<~GFM ```mermaid graph TD; A-->B; @@ -296,14 +319,14 @@ describe 'Copy as GFM', :js do ) aggregate_failures('MermaidFilter: mermaid as transformed from HTML to SVG') do - gfm = <<-GFM.strip_heredoc + gfm = <<~GFM ```mermaid graph TD; A-->B; ``` GFM - html = <<-HTML.strip_heredoc + html = <<~HTML HTML @@ -383,11 +405,18 @@ describe 'Copy as GFM', :js do verify( 'SanitizationFilter', - <<-GFM.strip_heredoc + <<~GFM sub

    dt
    +
    dt
    +
    dd
    +
    dd
    + +
    dt
    +
    dt
    +
    dd
    dd
    @@ -399,30 +428,26 @@ describe 'Copy as GFM', :js do var - ruby + HTML - rt +
    + summary> - rp - - abbr - - summary - -
    details
    + details +
    GFM ) verify( 'SanitizationFilter', - <<-GFM.strip_heredoc, + <<~GFM, ``` Plain text ``` GFM - <<-GFM.strip_heredoc, + <<~GFM, ```ruby def foo bar @@ -430,11 +455,9 @@ describe 'Copy as GFM', :js do ``` GFM - <<-GFM.strip_heredoc + <<~GFM Foo - This is an example of GFM - ```js Code goes here ``` @@ -452,9 +475,8 @@ describe 'Copy as GFM', :js do '> Quote', # multiline quote - <<-GFM.strip_heredoc, - > Multiline - > Quote + <<~GFM, + > Multiline Quote > > With multiple paragraphs GFM @@ -465,48 +487,58 @@ describe 'Copy as GFM', :js do '[Link](https://example.com)', - '- List item', + <<~GFM, + * List item + + * List item 2 + GFM # multiline list item - <<-GFM.strip_heredoc, - - Multiline - List item + <<~GFM, + * Multiline + + List item GFM # nested lists - <<-GFM.strip_heredoc, - - Nested + <<~GFM, + * Nested - - Lists + * Lists GFM # list with blockquote - <<-GFM.strip_heredoc, - - List + <<~GFM, + * List - > Blockquote + > Blockquote GFM - '1. Numbered list item', + <<~GFM, + 1. Ordered list item - # multiline numbered list item - <<-GFM.strip_heredoc, + 1. Ordered list item 2 + GFM + + # multiline ordered list item + <<~GFM, 1. Multiline - Numbered list item + + Ordered list item GFM - # nested numbered list - <<-GFM.strip_heredoc, + # nested ordered list + <<~GFM, 1. Nested - 1. Numbered lists + 1. Ordered lists GFM # list item followed by an HR - <<-GFM.strip_heredoc, - - list item + <<~GFM, + * list item - ----- + --- GFM '# Heading', @@ -518,14 +550,14 @@ describe 'Copy as GFM', :js do '**Bold**', - '_Italics_', + '*Italics*', '~~Strikethrough~~', - '-----', + '---', # table - <<-GFM.strip_heredoc, + <<~GFM, | Centered | Right | Left | |:--------:|------:|------| | Foo | Bar | **Baz** | @@ -533,9 +565,9 @@ describe 'Copy as GFM', :js do GFM # table with empty heading - <<-GFM.strip_heredoc, + <<~GFM, | | x | y | - |---|---|---| + |--|---|---| | a | 1 | 0 | | b | 0 | 1 | GFM @@ -545,9 +577,11 @@ describe 'Copy as GFM', :js do alias_method :gfm_to_html, :markdown def verify(label, *gfms) + markdown_options = gfms.extract_options! + aggregate_failures(label) do gfms.each do |gfm| - html = gfm_to_html(gfm).gsub(/\A | \z/, '') + html = gfm_to_html(gfm, markdown_options).gsub(/\A | \z/, '') output_gfm = html_to_gfm(html) expect(output_gfm.strip).to eq(gfm.strip) end @@ -594,7 +628,7 @@ describe 'Copy as GFM', :js do verify( '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]', - <<-GFM.strip_heredoc, + <<~GFM, ```ruby raise RuntimeError, "System commands must be given as an array of strings" end @@ -627,7 +661,7 @@ describe 'Copy as GFM', :js do verify( '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]', - <<-GFM.strip_heredoc, + <<~GFM, ```ruby unless cmd.is_a?(Array) raise "System commands must be given as an array of strings" @@ -645,7 +679,7 @@ describe 'Copy as GFM', :js do verify( '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]', - <<-GFM.strip_heredoc, + <<~GFM, ```ruby unless cmd.is_a?(Array) raise RuntimeError, "System commands must be given as an array of strings" @@ -691,7 +725,7 @@ describe 'Copy as GFM', :js do verify( '.line[id="LC9"], .line[id="LC10"]', - <<-GFM.strip_heredoc, + <<~GFM, ```ruby raise RuntimeError, "System commands must be given as an array of strings" end @@ -733,7 +767,7 @@ describe 'Copy as GFM', :js do verify( '.line[id="LC27"], .line[id="LC28"]', - <<-GFM.strip_heredoc, + <<~GFM, ```json "bio": null, "skype": "", @@ -752,7 +786,7 @@ describe 'Copy as GFM', :js do end def html_for_selector(selector) - js = <<-JS.strip_heredoc + js = <<~JS (function(selector) { var els = document.querySelectorAll(selector); var htmls = [].slice.call(els).map(function(el) { return el.outerHTML; }); @@ -763,7 +797,7 @@ describe 'Copy as GFM', :js do end def html_to_gfm(html, transformer = 'transformGFMSelection', target: nil) - js = <<-JS.strip_heredoc + js = <<~JS (function(html) { var transformer = window.CopyAsGFM[#{transformer.inspect}]; diff --git a/spec/javascripts/behaviors/copy_as_gfm_spec.js b/spec/javascripts/behaviors/copy_as_gfm_spec.js index cf8c1b77861..6179a02ce16 100644 --- a/spec/javascripts/behaviors/copy_as_gfm_spec.js +++ b/spec/javascripts/behaviors/copy_as_gfm_spec.js @@ -87,7 +87,7 @@ describe('CopyAsGFM', () => { spyOn(window, 'getSelection').and.returnValue(selection); simulateCopy(); - const expectedGFM = '- List Item1\n- List Item2'; + const expectedGFM = '* List Item1\n\n* List Item2'; expect(clipboardData.setData).toHaveBeenCalledWith('text/x-gfm', expectedGFM); }); @@ -97,7 +97,7 @@ describe('CopyAsGFM', () => { spyOn(window, 'getSelection').and.returnValue(selection); simulateCopy(); - const expectedGFM = '1. List Item1\n1. List Item2'; + const expectedGFM = '1. List Item1\n\n1. List Item2'; expect(clipboardData.setData).toHaveBeenCalledWith('text/x-gfm', expectedGFM); }); diff --git a/spec/javascripts/behaviors/shortcuts/shortcuts_issuable_spec.js b/spec/javascripts/behaviors/shortcuts/shortcuts_issuable_spec.js index b709b937180..fe827bb1e18 100644 --- a/spec/javascripts/behaviors/shortcuts/shortcuts_issuable_spec.js +++ b/spec/javascripts/behaviors/shortcuts/shortcuts_issuable_spec.js @@ -186,7 +186,7 @@ describe('ShortcutsIssuable', function() { it('adds the quoted selection to the input', () => { ShortcutsIssuable.replyWithSelectedText(true); - expect($(FORM_SELECTOR).val()).toBe('> _Selected text._\n\n'); + expect($(FORM_SELECTOR).val()).toBe('> *Selected text.*\n\n'); }); it('triggers `focus`', () => { diff --git a/yarn.lock b/yarn.lock index 8496aac6b2b..dd027355865 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4102,6 +4102,13 @@ fastparse@^1.1.1: resolved "https://registry.yarnpkg.com/fastparse/-/fastparse-1.1.1.tgz#d1e2643b38a94d7583b479060e6c4affc94071f8" integrity sha1-0eJkOzipTXWDtHkGDmxK/8lAcfg= +fault@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/fault/-/fault-1.0.2.tgz#c3d0fec202f172a3a4d414042ad2bb5e2a3ffbaa" + integrity sha512-o2eo/X2syzzERAtN5LcGbiVQ0WwZSlN3qLtadwAz3X8Bu+XWD16dja/KMsjZLiQr+BLGPDnHGkc4yUJf1Xpkpw== + dependencies: + format "^0.2.2" + faye-websocket@^0.10.0: version "0.10.0" resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.10.0.tgz#4e492f8d04dfb6f89003507f6edbf2d501e7c6f4" @@ -4321,6 +4328,11 @@ form-data@~2.3.2: combined-stream "^1.0.6" mime-types "^2.1.12" +format@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/format/-/format-0.2.2.tgz#d6170107e9efdc4ed30c9dc39016df942b5cb58b" + integrity sha1-1hcBB+nv3E7TDJ3DkBbflCtctYs= + formdata-polyfill@^3.0.11: version "3.0.11" resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-3.0.11.tgz#c82b4b4bea3356c0a6752219e54ce1edb2a7fb5b" @@ -4819,7 +4831,7 @@ he@^1.1.0, he@^1.1.1: resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd" integrity sha1-k0EP0hsAlzUVH4howvJx80J+I/0= -highlight.js@^9.13.1: +highlight.js@^9.13.1, highlight.js@~9.13.0: version "9.13.1" resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.13.1.tgz#054586d53a6863311168488a0f58d6c505ce641e" integrity sha512-Sc28JNQNDzaH6PORtRLMvif9RSn1mYuOoX3omVjnb0+HbpPygU2ALBI0R/wsiqCb4/fcp07Gdo8g+fhtFrQl6A== @@ -6470,6 +6482,13 @@ lightercollective@^0.1.0: resolved "https://registry.yarnpkg.com/lightercollective/-/lightercollective-0.1.0.tgz#70df102c530dcb8d0ccabfe6175a8d00d5f61300" integrity sha512-J9tg5uraYoQKaWbmrzDDexbG6hHnMcWS1qLYgJSWE+mpA3U5OCSeMUhb+K55otgZJ34oFdR0ECvdIb3xuO5JOQ== +linkify-it@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-2.1.0.tgz#c4caf38a6cd7ac2212ef3c7d2bde30a91561f9db" + integrity sha512-4REs8/062kV2DSHxNfq5183zrqXMl7WP0WzABH9IeJI+NLm429FgE1PDecltYfnOoFDFlZGh2T8PfZn0r+GTRg== + dependencies: + uc.micro "^1.0.1" + load-json-file@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0" @@ -6631,6 +6650,14 @@ lowercase-keys@1.0.0, lowercase-keys@^1.0.0: resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.0.tgz#4e3366b39e7f5457e35f1324bdf6f88d0bfc7306" integrity sha1-TjNms55/VFfjXxMkvfb4jQv8cwY= +lowlight@^1.11.0: + version "1.11.0" + resolved "https://registry.yarnpkg.com/lowlight/-/lowlight-1.11.0.tgz#1304d83005126d4e8b1dc0f07981e9b689ec2efc" + integrity sha512-xrGGN6XLL7MbTMdPD6NfWPwY43SNkjf/d0mecSx/CW36fUZTjRHEq0/Cdug3TWKtRXLWi7iMl1eP0olYxj/a4A== + dependencies: + fault "^1.0.2" + highlight.js "~9.13.0" + lru-cache@2.2.x: version "2.2.4" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-2.2.4.tgz#6c658619becf14031d0d0b594b16042ce4dc063d" @@ -6689,6 +6716,17 @@ map-visit@^1.0.0: dependencies: object-visit "^1.0.0" +markdown-it@^8.4.2: + version "8.4.2" + resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-8.4.2.tgz#386f98998dc15a37722aa7722084f4020bdd9b54" + integrity sha512-GcRz3AWTqSUphY3vsUqQSFMbgR38a4Lh3GWlHRh/7MRwz8mcu9n2IO7HOh+bXHrR9kOPDl5RNCaEsrneb+xhHQ== + dependencies: + argparse "^1.0.7" + entities "~1.1.1" + linkify-it "^2.0.0" + mdurl "^1.0.1" + uc.micro "^1.0.5" + marked@^0.3.12, marked@~0.3.6: version "0.3.19" resolved "https://registry.yarnpkg.com/marked/-/marked-0.3.19.tgz#5d47f709c4c9fc3c216b6d46127280f40b39d790" @@ -6707,6 +6745,11 @@ md5.js@^1.3.4: hash-base "^3.0.0" inherits "^2.0.1" +mdurl@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" + integrity sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4= + media-typer@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" @@ -7420,6 +7463,11 @@ optionator@^0.8.1, optionator@^0.8.2: type-check "~0.3.2" wordwrap "~1.0.0" +orderedmap@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/orderedmap/-/orderedmap-1.0.0.tgz#d90fc2ba1ed085190907d601dec6e6a53f8d41ba" + integrity sha1-2Q/Cuh7QhRkJB9YB3sbmpT+NQbo= + original@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/original/-/original-1.0.2.tgz#e442a61cffe1c5fd20a65f3261c26663b303f25f" @@ -7956,6 +8004,122 @@ prompts@^0.1.9: kleur "^2.0.1" sisteransi "^0.1.1" +prosemirror-commands@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/prosemirror-commands/-/prosemirror-commands-1.0.7.tgz#e5a2ba821e29ea7065c88277fe2c3d7f6b0b9d37" + integrity sha512-IR8yMSdw7XlKuF68tydAak1J9P/lLD5ohsrL7pzoLsJAJAQU7mVPDXtGbQrrm0mesddFjcc1zNo/cJQN3lRYnA== + dependencies: + prosemirror-model "^1.0.0" + prosemirror-state "^1.0.0" + prosemirror-transform "^1.0.0" + +prosemirror-dropcursor@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/prosemirror-dropcursor/-/prosemirror-dropcursor-1.1.1.tgz#c60ed1ed6c58804a06a75db06a0d993b087b7622" + integrity sha512-GeUyMO/tOEf8MXrP7Xb7UIMrfK86OGh0fnyBrHfhav4VjY9cw65mNoqHy87CklE5711AhCP5Qzfp8RL/hVKusg== + dependencies: + prosemirror-state "^1.0.0" + prosemirror-transform "^1.1.0" + prosemirror-view "^1.1.0" + +prosemirror-gapcursor@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/prosemirror-gapcursor/-/prosemirror-gapcursor-1.0.3.tgz#acc6537fc5a35e9b38966f91a199a382dfc715c4" + integrity sha512-X+hJhr42PcHWiSWL+lI5f/UeOhXCxlBFb8M6O8aG1hssmaRrW7sS2/Fjg5jFV+pTdS1REFkmm1occh01FMdDIQ== + dependencies: + prosemirror-keymap "^1.0.0" + prosemirror-model "^1.0.0" + prosemirror-state "^1.0.0" + prosemirror-view "^1.0.0" + +prosemirror-history@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/prosemirror-history/-/prosemirror-history-1.0.3.tgz#5fb8591adfc272afaaf0b41bec64ee7d9522a118" + integrity sha512-IfFGbhafSx+R3aq7nLJGkXeu2iaUiP8mkU3aRu2uQcIIjU8Fq7RJfuvhIOJ2RNUoSyqF/ANkdTjnZ74F5eHs1Q== + dependencies: + prosemirror-state "^1.2.2" + prosemirror-transform "^1.0.0" + rope-sequence "^1.2.0" + +prosemirror-inputrules@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/prosemirror-inputrules/-/prosemirror-inputrules-1.0.1.tgz#f63305fd966379f218e82ca76a2a9b328b66dc7b" + integrity sha512-UHy22NmwxS5WIMQYkzraDttQAF8mpP82FfbJsmKFfx6jwkR/SZa+ZhbkLY0zKQ5fBdJN7euj36JG/B5iAlrpxA== + dependencies: + prosemirror-state "^1.0.0" + prosemirror-transform "^1.0.0" + +prosemirror-keymap@^1.0.0, prosemirror-keymap@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/prosemirror-keymap/-/prosemirror-keymap-1.0.1.tgz#03ef32b828e3a859dfb570eb84928bf2e5330bc2" + integrity sha512-e79ApE7PXXZMFtPz7WbjycjAFd1NPjgY1MkecVz98tqwlBSggXWXYQnWFk6x7UkmnBYRHHbXHkR/RXmu2wyBJg== + dependencies: + prosemirror-state "^1.0.0" + w3c-keyname "^1.1.8" + +prosemirror-markdown@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/prosemirror-markdown/-/prosemirror-markdown-1.3.0.tgz#a100d14c27da7d8fb70818230d786898eeadb7fa" + integrity sha512-76l3yLB/suy6sA7LpzRJvRRWkHtKwOTpgWVNwmlIAIIZJeMypWSPldT/gFyIG604eyXEPZitnx+j80Y2DpbnUQ== + dependencies: + markdown-it "^8.4.2" + prosemirror-model "^1.0.0" + +prosemirror-model@^1.0.0, prosemirror-model@^1.1.0, prosemirror-model@^1.6.4: + version "1.6.4" + resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.6.4.tgz#2ac37a629448a7dbfd1635450e2fdd63c3450d7d" + integrity sha512-C2ALle8fZsAza+6stUF9Gv28jH9XtpNeczb33bowGlnb2cpNI4FZf1HHUyZjf6ou4cEvOlbt6fAYsT4NCKmlcQ== + dependencies: + orderedmap "^1.0.0" + +prosemirror-schema-list@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/prosemirror-schema-list/-/prosemirror-schema-list-1.0.1.tgz#f216e0cf4809b6074aa27912449ac89897f1ae94" + integrity sha512-AiLIX6qm6PEeDtMCKZLcSLi55WXo1ls7DnRK+4hSkoi0IIzNdxGsRlecCd3MzEu//DVz3nAEh+zEmslyW+uk8g== + dependencies: + prosemirror-model "^1.0.0" + prosemirror-transform "^1.0.0" + +prosemirror-state@^1.0.0, prosemirror-state@^1.2.1, prosemirror-state@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/prosemirror-state/-/prosemirror-state-1.2.2.tgz#8df26d95fd6fd327c0f9984a760e84d863204154" + integrity sha512-j8aC/kf9BJSCQau485I/9pj39XQoce+TqH5xzekT7WWFARTsRYFLJtiXBcCKakv1VSeev+sC3bJP0pLfz7Ft8g== + dependencies: + prosemirror-model "^1.0.0" + prosemirror-transform "^1.0.0" + +prosemirror-tables@^0.7.10, prosemirror-tables@^0.7.9: + version "0.7.10" + resolved "https://registry.yarnpkg.com/prosemirror-tables/-/prosemirror-tables-0.7.10.tgz#4b0f623422b4b8f84cdc9c559f8a87579846b3ba" + integrity sha512-VIu7UGS9keYEHs0Y6AEOTGbNE9QI2rL1OKng4vV6yoTshW/lYcb+s3hGXI12i+WLMjDVm7ujhfdWrpKpvFZOkQ== + dependencies: + prosemirror-keymap "^1.0.0" + prosemirror-model "^1.0.0" + prosemirror-state "^1.0.0" + prosemirror-transform "^1.0.0" + prosemirror-view "^1.0.0" + +prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/prosemirror-transform/-/prosemirror-transform-1.1.3.tgz#28cfdf1f9ee514edc40466be7b7db39eed545fdf" + integrity sha512-1O6Di5lOL1mp4nuCnQNkHY7l2roIW5y8RH4ZG3hMYmkmDEWzTaFFnxxAAHsE5ipGLBSRcTlP7SsDhYBIdSuLpQ== + dependencies: + prosemirror-model "^1.0.0" + +prosemirror-utils@^0.7.5: + version "0.7.5" + resolved "https://registry.yarnpkg.com/prosemirror-utils/-/prosemirror-utils-0.7.5.tgz#11b477647b672ec8f10679ab298a5823dad6457a" + integrity sha512-F+63BUiBkUQb1S07c3rGHXjE4MDaZ5OjsNhmaO7eDdSh1lUNORTJJHrvlFEZKnLM7ChoDDXTIKhWNQwnCssQfA== + +prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.6.8: + version "1.6.8" + resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.6.8.tgz#33fc1a6e2731633e5d6dc1af1967378f15810b74" + integrity sha512-YWX3rfji77xsU5EErt4ZoecVytYW9/4oHBYhV1MUHGMYIcppe+QZEBgRlyPMBUuu0lxdZX4m3sq7fCsDvv/MlQ== + dependencies: + prosemirror-model "^1.1.0" + prosemirror-state "^1.0.0" + prosemirror-transform "^1.1.0" + proto-list@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" @@ -8549,6 +8713,11 @@ ripemd160@^2.0.0, ripemd160@^2.0.1: hash-base "^2.0.0" inherits "^2.0.1" +rope-sequence@^1.2.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/rope-sequence/-/rope-sequence-1.2.2.tgz#49c4e5c2f54a48e990b050926771e2871bcb31ce" + integrity sha1-ScTlwvVKSOmQsFCSZ3HihxvLMc4= + rsvp@^3.3.3: version "3.6.2" resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-3.6.2.tgz#2e96491599a96cde1b515d5674a8f7a91452926a" @@ -9497,6 +9666,57 @@ tiny-emitter@^2.0.0: resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.0.2.tgz#82d27468aca5ade8e5fd1e6d22b57dd43ebdfb7c" integrity sha512-2NM0auVBGft5tee/OxP4PI3d8WItkDM+fPnaRAVo6xTDI2knbz9eC5ArWGqtGlYqiH3RU5yMpdyTTO7MguC4ow== +tiptap-commands@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/tiptap-commands/-/tiptap-commands-1.4.0.tgz#0cfb3ac138ee3099de56114cb119abd841fbcbe7" + integrity sha512-ytO8jFXgufK5DziamTaVojzUTolWvL4m2xNXaLkAVJYy9CWXruMK7avqeLoFYPI4GZlhleMn5i4gzYTbD7e2jA== + dependencies: + prosemirror-commands "^1.0.7" + prosemirror-inputrules "^1.0.1" + prosemirror-schema-list "^1.0.1" + prosemirror-state "^1.2.2" + tiptap-utils "^1.1.1" + +tiptap-extensions@^1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/tiptap-extensions/-/tiptap-extensions-1.8.0.tgz#3067620a024f1a9e5fae4450790b143d7ebe4394" + integrity sha512-1JN9uk5QnA7DTID1j07gIBEqeOnRd6lwZ5rx/zqWXJLyreZu8VDPvP939tfP41GskO4oicGlhmsQ0aEnA5QYDw== + dependencies: + lowlight "^1.11.0" + prosemirror-history "^1.0.3" + prosemirror-state "^1.2.2" + prosemirror-tables "^0.7.10" + prosemirror-utils "^0.7.5" + prosemirror-view "^1.6.8" + tiptap "^1.8.0" + tiptap-commands "^1.4.0" + +tiptap-utils@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/tiptap-utils/-/tiptap-utils-1.1.1.tgz#e7aad3e84eb35f7abed704d15da0420029789d0d" + integrity sha512-yPIWwLFaL5a0GC7fcO7aoPlASnH3wOUQex0IlepNWbDCNycSL8shXhVx0HMN/tCnlp943zw1bwcYzpTW3wA4tw== + dependencies: + prosemirror-model "^1.6.4" + prosemirror-state "^1.2.2" + prosemirror-tables "^0.7.9" + prosemirror-utils "^0.7.5" + +tiptap@^1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/tiptap/-/tiptap-1.8.0.tgz#c671188075ffa5ee4f86470f95818fd9ce6f1040" + integrity sha512-zIcVY8U1Wgj4bg3R4pX5a2BCpZUw/dTCh259VZ9g5MtClnzdLW2XpKCcwqfa9iUBEs6MCPSnB3t8jGRtGciHJg== + dependencies: + prosemirror-commands "^1.0.7" + prosemirror-dropcursor "^1.1.1" + prosemirror-gapcursor "^1.0.3" + prosemirror-inputrules "^1.0.1" + prosemirror-keymap "^1.0.1" + prosemirror-model "^1.6.4" + prosemirror-state "^1.2.1" + prosemirror-view "^1.6.8" + tiptap-commands "^1.4.0" + tiptap-utils "^1.1.1" + tmp@0.0.33, tmp@0.0.x, tmp@^0.0.33: version "0.0.33" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" @@ -9648,6 +9868,11 @@ typescript@^2: resolved "https://registry.yarnpkg.com/typescript/-/typescript-2.9.2.tgz#1cbf61d05d6b96269244eb6a3bce4bd914e0f00c" integrity sha512-Gr4p6nFNaoufRIY4NMdpQRNmgxVIGMs4Fcu/ujdYk3nAZqk7supzBE9idmvfZIlH/Cuj//dvi+019qEue9lV0w== +uc.micro@^1.0.1, uc.micro@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.5.tgz#0c65f15f815aa08b560a61ce8b4db7ffc3f45376" + integrity sha512-JoLI4g5zv5qNyT09f4YAvEZIIV1oOjqnewYg5D38dkQljIzpPT296dbIGvKro3digYI1bkb7W6EP1y4uDlmzLg== + uglify-js@^3.1.4: version "3.4.9" resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.4.9.tgz#af02f180c1207d76432e473ed24a28f4a782bae3" @@ -10052,6 +10277,11 @@ w3c-hr-time@^1.0.1: dependencies: browser-process-hrtime "^0.1.2" +w3c-keyname@^1.1.8: + version "1.1.8" + resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-1.1.8.tgz#4e2219663760fd6535b7a1550f1552d71fc9372c" + integrity sha512-2HAdug8GTiu3b4NYhssdtY8PXRue3ICnh1IlxvZYl+hiINRq0GfNWei3XOPDg8L0PsxbmYjWVLuLj6BMRR/9vA== + walker@~1.0.5: version "1.0.7" resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.7.tgz#2f7f9b8fd10d677262b18a884e28d19618e028fb"