Commit cc29bc61 authored by Phil Hughes's avatar Phil Hughes

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
parents 53b8e6e3 8a03dbf8
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(),
];
/* 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;
}
}
/* 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;
}
}
/* 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 ? '+}' : '-}';
},
};
}
}
/* 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}>`;
},
};
}
}
/* 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;
}
}
/* 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;
},
};
}
}
/* 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)}$`;
},
};
}
}
/* 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,
};
}
}
/* 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);
}
}
/* 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);
}
}
/* 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);
}
}
/* 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);
}
}
/* 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);
}
}
/* 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);
}
}
/* 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);
}
}
/* 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+',
};
}
}
/* 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}:`);
}
}
/* 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');
}
}
/* 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);
}
}
/* 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);
}
}
/* 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);
}
}
/* 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);
}
}
/* 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. ');
}
}
/* 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. ');
}
}
/* 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);
}
}
/* 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);
}
}
/* 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);
}
}
/* 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);
}
}
/* 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);
}
}
/* 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);
}
}
/* 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);
}
}
/* 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);
}
}
/* 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);
}
}
/* 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;
}
}
/* 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, ' ', () => '* ');
}
}
/* 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);
}
}
/* 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);
}
}
/* 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);
}
}
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 });
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);
import $ from 'jquery'; import $ from 'jquery';
import Mousetrap from 'mousetrap'; import Mousetrap from 'mousetrap';
import _ from 'underscore';
import Sidebar from '../../right_sidebar'; import Sidebar from '../../right_sidebar';
import Shortcuts from './shortcuts'; import Shortcuts from './shortcuts';
import { CopyAsGFM } from '../markdown/copy_as_gfm'; import { CopyAsGFM } from '../markdown/copy_as_gfm';
...@@ -63,18 +62,18 @@ export default class ShortcutsIssuable extends Shortcuts { ...@@ -63,18 +62,18 @@ export default class ShortcutsIssuable extends Shortcuts {
} }
const el = CopyAsGFM.transformGFMSelection(documentFragment.cloneNode(true)); 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; return false;
} }
const quote = _.map(selected.split('\n'), val => `${`> ${val}`.trim()}\n`);
// If replyField already has some content, add a newline before our quote // If replyField already has some content, add a newline before our quote
const separator = ($replyField.val().trim() !== '' && '\n\n') || ''; const separator = ($replyField.val().trim() !== '' && '\n\n') || '';
$replyField $replyField
.val((a, current) => `${current}${separator}${quote.join('')}\n`) .val((a, current) => `${current}${separator}${text}\n\n`)
.trigger('input') .trigger('input')
.trigger('change'); .trigger('change');
......
# frozen_string_literal: true # frozen_string_literal: true
# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/emoji.js
module Banzai module Banzai
module Filter module Filter
# HTML filter that replaces :emoji: and unicode with images. # HTML filter that replaces :emoji: and unicode with images.
......
# frozen_string_literal: true # frozen_string_literal: true
# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/image.js
module Banzai module Banzai
module Filter module Filter
# HTML filter that moves the value of image `src` attributes to `data-src` # HTML filter that moves the value of image `src` attributes to `data-src`
......
# frozen_string_literal: true # frozen_string_literal: true
# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/image.js
module Banzai module Banzai
module Filter module Filter
# HTML filter that wraps links around inline images. # HTML filter that wraps links around inline images.
......
# frozen_string_literal: true # frozen_string_literal: true
# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/marks/inline_diff.js
module Banzai module Banzai
module Filter module Filter
class InlineDiffFilter < HTML::Pipeline::Filter class InlineDiffFilter < HTML::Pipeline::Filter
......
...@@ -2,6 +2,9 @@ ...@@ -2,6 +2,9 @@
require 'uri' 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 Banzai
module Filter module Filter
# HTML filter that adds class="code math" and removes the dollar sign in $`2+2`$. # HTML filter that adds class="code math" and removes the dollar sign in $`2+2`$.
......
# frozen_string_literal: true # frozen_string_literal: true
# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/code_block.js
module Banzai module Banzai
module Filter module Filter
class MermaidFilter < HTML::Pipeline::Filter class MermaidFilter < HTML::Pipeline::Filter
......
# frozen_string_literal: true # frozen_string_literal: true
# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/reference.js
module Banzai module Banzai
module Filter module Filter
# Base class for GitLab Flavored Markdown reference filters. # Base class for GitLab Flavored Markdown reference filters.
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
require 'rouge/plugins/common_mark' require 'rouge/plugins/common_mark'
require 'rouge/plugins/redcarpet' require 'rouge/plugins/redcarpet'
# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/code_block.js
module Banzai module Banzai
module Filter module Filter
# HTML Filter to highlight fenced code blocks # HTML Filter to highlight fenced code blocks
......
# frozen_string_literal: true # 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 Banzai
module Filter module Filter
# HTML filter that adds an anchor child element to all Headers in a # HTML filter that adds an anchor child element to all Headers in a
......
...@@ -2,6 +2,10 @@ ...@@ -2,6 +2,10 @@
require 'task_list/filter' 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 Banzai
module Filter module Filter
class TaskListFilter < TaskList::Filter class TaskListFilter < TaskList::Filter
......
# frozen_string_literal: true # frozen_string_literal: true
# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/video.js
module Banzai module Banzai
module Filter module Filter
# Find every image that isn't already wrapped in an `a` tag, and that has # Find every image that isn't already wrapped in an `a` tag, and that has
......
...@@ -3,11 +3,11 @@ ...@@ -3,11 +3,11 @@
module Banzai module Banzai
module Pipeline module Pipeline
class GfmPipeline < BasePipeline class GfmPipeline < BasePipeline
# These filters convert GitLab Flavored Markdown (GFM) to HTML. # These filters transform GitLab Flavored Markdown (GFM) to HTML.
# The handlers defined in app/assets/javascripts/behaviors/markdown/copy_as_gfm.js # The nodes and marks referenced in app/assets/javascripts/behaviors/markdown/editor_extensions.js
# consequently convert that same HTML to GFM to be copied to the clipboard. # consequently transform that same HTML to GFM to be copied to the clipboard.
# Every filter that generates HTML from GFM should have a handler in # Every filter that generates HTML from GFM should have a node or mark in
# app/assets/javascripts/behaviors/markdown/copy_as_gfm.js, in reverse order. # 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. # The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb.
def self.filters def self.filters
@filters ||= FilterArray[ @filters ||= FilterArray[
......
This diff is collapsed.
...@@ -87,7 +87,7 @@ describe('CopyAsGFM', () => { ...@@ -87,7 +87,7 @@ describe('CopyAsGFM', () => {
spyOn(window, 'getSelection').and.returnValue(selection); spyOn(window, 'getSelection').and.returnValue(selection);
simulateCopy(); simulateCopy();
const expectedGFM = '- List Item1\n- List Item2'; const expectedGFM = '* List Item1\n\n* List Item2';
expect(clipboardData.setData).toHaveBeenCalledWith('text/x-gfm', expectedGFM); expect(clipboardData.setData).toHaveBeenCalledWith('text/x-gfm', expectedGFM);
}); });
...@@ -97,7 +97,7 @@ describe('CopyAsGFM', () => { ...@@ -97,7 +97,7 @@ describe('CopyAsGFM', () => {
spyOn(window, 'getSelection').and.returnValue(selection); spyOn(window, 'getSelection').and.returnValue(selection);
simulateCopy(); 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); expect(clipboardData.setData).toHaveBeenCalledWith('text/x-gfm', expectedGFM);
}); });
......
...@@ -186,7 +186,7 @@ describe('ShortcutsIssuable', function() { ...@@ -186,7 +186,7 @@ describe('ShortcutsIssuable', function() {
it('adds the quoted selection to the input', () => { it('adds the quoted selection to the input', () => {
ShortcutsIssuable.replyWithSelectedText(true); ShortcutsIssuable.replyWithSelectedText(true);
expect($(FORM_SELECTOR).val()).toBe('> _Selected text._\n\n'); expect($(FORM_SELECTOR).val()).toBe('> *Selected text.*\n\n');
}); });
it('triggers `focus`', () => { it('triggers `focus`', () => {
......
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment