diff options
author | Douwe Maan <douwe@selenight.nl> | 2019-01-23 12:10:57 +0100 |
---|---|---|
committer | Douwe Maan <douwe@selenight.nl> | 2019-01-24 12:06:52 +0100 |
commit | a7e77e10fec4c37782edf09aafa12ae0c2d870df (patch) | |
tree | df14697ed9b4c6bbfd91c26a401ad5fc1c120e88 /app/assets/javascripts/behaviors/markdown/nodes | |
parent | da251c6436d6e17b18911d37c1b18314fb29329d (diff) | |
download | gitlab-ce-a7e77e10fec4c37782edf09aafa12ae0c2d870df.tar.gz |
Add tiptap/prosemirror nodes and marks for all Markdown and GFM features
The schema is built on top of the default schema and Node and Mark
classes provided by tiptap-extensions. prosemirror-model is used to
parse HTML/DOM into a prosemirror document, and prosemirror-markdown is
used to serialize this document to (GitLab Flavored) Markdown.
Diffstat (limited to 'app/assets/javascripts/behaviors/markdown/nodes')
30 files changed, 882 insertions, 0 deletions
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); + } +} |