diff options
author | Phil Hughes <me@iamphill.com> | 2019-01-25 14:38:42 +0300 |
---|---|---|
committer | Phil Hughes <me@iamphill.com> | 2019-01-25 14:38:42 +0300 |
commit | cc29bc61e0a5be0e53805665a7b3d2253f310827 (patch) | |
tree | 01c0fab807cea5e5e38e7c596e967b3251ae8884 /app | |
parent | 53b8e6e3890efdb8db132559d35ff231f2aaf71d (diff) | |
parent | 8a03dbf8b7e6776264cef9824aba1e58dcbaef70 (diff) |
Merge branch 'db-copy-as-gfm-prosemirror' into 'master'
Reimplement Copy-as-GFM using the prosemirror document model
See merge request gitlab-org/gitlab-ce!22797
Diffstat (limited to 'app')
43 files changed, 1253 insertions, 391 deletions
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 `<dl>\n${lines.join('\n')}\n</dl>\n`; - }, - 'dt, dd, summary, details'(el, text) { - const tag = el.nodeName.toLowerCase(); - return `<${tag}>${text}</${tag}>\n`; - }, - 'sup, sub, kbd, q, samp, var, ruby, rt, rp, abbr'(el, text) { - const tag = el.nodeName.toLowerCase(); - return `<${tag}>${text}</${tag}>`; - }, - }, - 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 ''; - } - - 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(); - } + static nodeToGFM(node) { + const wrapEl = document.createElement('div'); + wrapEl.appendChild(node.cloneNode(true)); + const doc = DOMParser.fromSchema(schema).parse(wrapEl); - 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 `</${mark.attrs.tag}>`; + }, + }; + } +} 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('<dd>'); + state.text(node.textContent, false); + state.write('</dd>'); + 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('<dl>\n'); + state.wrapBlock(' ', null, node, () => state.renderContent(node)); + state.flushClose(1); + state.ensureNewLine(); + state.write('</dl>'); + 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('<dt>'); + state.text(node.textContent, false); + state.write('</dt>'); + 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('<details>\n'); + state.renderContent(node); + state.flushClose(1); + state.ensureNewLine(); + state.write('</details>'); + 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('<summary>'); + state.text(node.textContent, false); + state.write('</summary>'); + 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'); |