BigW Consortium Gitlab

copy_as_gfm.js 13.4 KB
Newer Older
Douwe Maan committed
1
/* eslint-disable class-methods-use-this, object-shorthand, no-unused-vars, no-use-before-define, no-new, max-len, no-restricted-syntax, guard-for-in, no-continue */
2
import _ from 'underscore';
3
import { insertText, getSelectedFragment, nodeMatchesSelector } from './lib/utils/common_utils';
4
import { placeholderImage } from './lazy_loader';
5

6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
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: {
22
    'input[type=checkbox].task-list-item-checkbox'(el) {
23 24 25 26
      return `[${el.checked ? 'x' : ' '}]`;
    },
  },
  ReferenceFilter: {
27
    '.tooltip'(el) {
28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
      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: {
43
    'ul.section-nav'(el) {
44 45 46 47
      return '[[_TOC_]]';
    },
  },
  EmojiFilter: {
48
    'img.emoji'(el) {
49 50
      return el.getAttribute('alt');
    },
51
    'gl-emoji'(el) {
52 53 54 55 56 57 58 59
      return `:${el.getAttribute('data-name')}:`;
    },
  },
  ImageLinkFilter: {
    'a.no-attachment-icon'(el, text) {
      return text;
    },
  },
60 61 62 63 64
  ImageLazyLoadFilter: {
    'img'(el, text) {
      return `![${el.getAttribute('alt')}](${el.getAttribute('src')})`;
    },
  },
65
  VideoLinkFilter: {
66
    '.video-container'(el) {
67 68 69 70 71
      const videoEl = el.querySelector('video');
      if (!videoEl) return false;

      return CopyAsGFM.nodeToGFM(videoEl);
    },
72
    'video'(el) {
73 74 75 76 77 78 79 80 81 82
      return `![${el.dataset.title}](${el.getAttribute('src')})`;
    },
  },
  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}\`$`;
    },
83
    'span.katex-display span.katex-mathml'(el) {
84 85 86 87 88
      const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]');
      if (!mathAnnotation) return false;

      return `\`\`\`math\n${CopyAsGFM.nodeToGFM(mathAnnotation)}\n\`\`\``;
    },
89
    'span.katex-mathml'(el) {
90 91 92 93 94
      const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]');
      if (!mathAnnotation) return false;

      return `$\`${CopyAsGFM.nodeToGFM(mathAnnotation)}\`$`;
    },
95
    'span.katex-html'(el) {
96 97 98 99 100 101 102 103
      // 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: {
104
    'a[name]:not([href]):empty'(el) {
105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126
      return el.outerHTML;
    },
    'dl'(el, text) {
      let lines = text.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>`;
    },
    'sub, dt, dd, kbd, q, samp, var, ruby, rt, rp, abbr, summary, details'(el, text) {
      const tag = el.nodeName.toLowerCase();
      return `<${tag}>${text}</${tag}>`;
    },
  },
  SyntaxHighlightFilter: {
    'pre.code.highlight'(el, t) {
127
      const text = t.trimRight();
128 129

      let lang = el.getAttribute('lang');
130
      if (!lang || lang === 'plaintext') {
131 132 133 134 135 136
        lang = '';
      }

      // Prefixes lines with 4 spaces if the code contains triple backticks
      if (lang === '' && text.match(/^```/gm)) {
        return text.split('\n').map((l) => {
Douwe Maan committed
137 138
          const line = l.trim();
          if (line.length === 0) return '';
139

140 141 142
          return `    ${line}`;
        }).join('\n');
      }
143

144 145 146 147 148 149 150 151
      return `\`\`\`${lang}\n${text}\n\`\`\``;
    },
    'pre > code'(el, text) {
       // Don't wrap code blocks in ``
      return text;
    },
  },
  MarkdownFilter: {
152
    'br'(el) {
153 154
      // Two spaces at the end of a line are turned into a BR
      return '  ';
155
    },
156 157 158 159 160 161
    'code'(el, text) {
      let backtickCount = 1;
      const backtickMatch = text.match(/`+/);
      if (backtickMatch) {
        backtickCount = backtickMatch[0].length + 1;
      }
Douwe Maan committed
162

163 164
      const backticks = Array(backtickCount + 1).join('`');
      const spaceOrNoSpace = backtickCount > 1 ? ' ' : '';
165

166
      return backticks + spaceOrNoSpace + text.trim() + spaceOrNoSpace + backticks;
167 168 169 170
    },
    'blockquote'(el, text) {
      return text.trim().split('\n').map(s => `> ${s}`.trim()).join('\n');
    },
171
    'img'(el) {
172 173 174
      const imageSrc = el.src;
      const imageUrl = imageSrc && imageSrc !== placeholderImage ? imageSrc : (el.dataset.src || '');
      return `![${el.getAttribute('alt')}](${imageUrl})`;
175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232
    },
    '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(/^- /mg, '1. ');
    },
    'h1'(el, text) {
      return `# ${text.trim()}`;
    },
    'h2'(el, text) {
      return `## ${text.trim()}`;
    },
    'h3'(el, text) {
      return `### ${text.trim()}`;
    },
    'h4'(el, text) {
      return `#### ${text.trim()}`;
    },
    'h5'(el, text) {
      return `##### ${text.trim()}`;
    },
    'h6'(el, text) {
      return `###### ${text.trim()}`;
    },
    'strong'(el, text) {
      return `**${text}**`;
    },
    'em'(el, text) {
      return `_${text}_`;
    },
    'del'(el, text) {
      return `~~${text}~~`;
    },
    'sup'(el, text) {
      return `^${text}`;
    },
233
    'hr'(el) {
234 235
      return '-----';
    },
236
    'table'(el) {
237 238 239
      const theadEl = el.querySelector('thead');
      const tbodyEl = el.querySelector('tbody');
      if (!theadEl || !tbodyEl) return false;
240

241 242
      const theadText = CopyAsGFM.nodeToGFM(theadEl);
      const tbodyText = CopyAsGFM.nodeToGFM(tbodyEl);
243

244
      return [theadText, tbodyText].join('\n');
245 246 247
    },
    'thead'(el, text) {
      const cells = _.map(el.querySelectorAll('th'), (cell) => {
248
        let chars = CopyAsGFM.nodeToGFM(cell).length + 2;
249 250 251 252 253 254 255 256 257 258 259 260 261 262 263

        let before = '';
        let after = '';
        switch (cell.style.textAlign) {
          case 'center':
            before = ':';
            after = ':';
            chars -= 2;
            break;
          case 'right':
            after = ':';
            chars -= 1;
            break;
          default:
            break;
264 265
        }

266
        chars = Math.max(chars, 3);
267

268
        const middle = Array(chars + 1).join('-');
269

270 271
        return before + middle + after;
      });
272

273 274 275
      const separatorRow = `|${cells.join('|')}|`;

      return [text, separatorRow].join('\n');
276
    },
277 278 279 280 281
    'tr'(el) {
      const cellEls = el.querySelectorAll('td, th');
      if (cellEls.length === 0) return false;

      const cells = _.map(cellEls, cell => CopyAsGFM.nodeToGFM(cell));
282 283 284 285
      return `| ${cells.join(' | ')} |`;
    },
  },
};
286

287 288
class CopyAsGFM {
  constructor() {
Douwe Maan committed
289 290 291
    $(document).on('copy', '.md, .wiki', (e) => { CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformGFMSelection); });
    $(document).on('copy', 'pre.code.highlight, .diff-content .line_content', (e) => { CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformCodeSelection); });
    $(document).on('paste', '.js-gfm-input', CopyAsGFM.pasteGFM);
292
  }
Douwe Maan committed
293

Douwe Maan committed
294
  static copyAsGFM(e, transformer) {
295 296
    const clipboardData = e.originalEvent.clipboardData;
    if (!clipboardData) return;
297

298
    const documentFragment = getSelectedFragment();
299
    if (!documentFragment) return;
300

301
    const el = transformer(documentFragment.cloneNode(true), e.currentTarget);
302
    if (!el) return;
303

304
    e.preventDefault();
305
    e.stopPropagation();
306

307
    clipboardData.setData('text/plain', el.textContent);
Douwe Maan committed
308
    clipboardData.setData('text/x-gfm', this.nodeToGFM(el));
309
  }
310

Douwe Maan committed
311
  static pasteGFM(e) {
312 313
    const clipboardData = e.originalEvent.clipboardData;
    if (!clipboardData) return;
314

315
    const text = clipboardData.getData('text/plain');
316 317
    const gfm = clipboardData.getData('text/x-gfm');
    if (!gfm) return;
318

319
    e.preventDefault();
320

321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337
    window.gl.utils.insertText(e.target, (textBefore, textAfter) => {
      // 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.
      // This logic still holds when there are one or more _closed_ code spans
      // or blocks that will have 2 or 6 backticks.
      // This will break down when the actual code block contains an uneven
      // number of backticks, but this is a rare edge case.
      const backtickMatch = textBefore.match(/`/g);
      const insideCodeBlock = backtickMatch && (backtickMatch.length % 2) === 1;

      if (insideCodeBlock) {
        return text;
      }

      return gfm;
    });
338
  }
339

340
  static transformGFMSelection(documentFragment) {
341 342
    const gfmElements = documentFragment.querySelectorAll('.md, .wiki');
    switch (gfmElements.length) {
343 344 345 346
      case 0: {
        return documentFragment;
      }
      case 1: {
347
        return gfmElements[0];
348 349
      }
      default: {
350
        const allGfmElement = document.createElement('div');
351

352 353 354 355
        for (let i = 0; i < gfmElements.length; i += 1) {
          const gfmElement = gfmElements[i];
          allGfmElement.appendChild(gfmElement);
          allGfmElement.appendChild(document.createTextNode('\n\n'));
356
        }
357

358
        return allGfmElement;
359 360
      }
    }
361 362
  }

363 364
  static transformCodeSelection(documentFragment, target) {
    let lineSelector = '.line';
365

366 367 368 369 370 371 372 373 374 375 376 377 378
    if (target) {
      const lineClass = ['left-side', 'right-side'].filter(name => target.classList.contains(name))[0];
      if (lineClass) {
        lineSelector = `.line_content.${lineClass} ${lineSelector}`;
      }
    }

    const lineElements = documentFragment.querySelectorAll(lineSelector);

    let codeElement;
    if (lineElements.length > 1) {
      codeElement = document.createElement('pre');
      codeElement.className = 'code highlight';
379

380
      const lang = lineElements[0].getAttribute('lang');
381
      if (lang) {
382
        codeElement.setAttribute('lang', lang);
383 384
      }
    } else {
385
      codeElement = document.createElement('code');
386 387
    }

388 389 390 391 392
    if (lineElements.length > 0) {
      for (let i = 0; i < lineElements.length; i += 1) {
        const lineElement = lineElements[i];
        codeElement.appendChild(lineElement);
        codeElement.appendChild(document.createTextNode('\n'));
393 394
      }
    } else {
395
      codeElement.appendChild(documentFragment);
396 397
    }

398
    return codeElement;
399 400
  }

401
  static nodeToGFM(node, respectWhitespaceParam = false) {
402 403 404 405
    if (node.nodeType === Node.COMMENT_NODE) {
      return '';
    }

406 407 408
    if (node.nodeType === Node.TEXT_NODE) {
      return node.textContent;
    }
409

410
    const respectWhitespace = respectWhitespaceParam || (node.nodeName === 'PRE' || node.nodeName === 'CODE');
411 412

    const text = this.innerGFM(node, respectWhitespace);
413

414
    if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
415 416 417
      return text;
    }

418 419
    for (const filter in gfmRules) {
      const rules = gfmRules[filter];
420

421 422
      for (const selector in rules) {
        const func = rules[selector];
423

424
        if (!nodeMatchesSelector(node, selector)) continue;
425

426 427 428 429 430 431 432 433 434 435 436
        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);
        }

437
        if (result === false) continue;
438

439
        return result;
440
      }
441 442 443 444
    }

    return text;
  }
445

446
  static innerGFM(parentNode, respectWhitespace = false) {
447 448 449 450 451 452 453 454 455
    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];

456
      const text = this.nodeToGFM(node, respectWhitespace);
457 458 459

      // `clonedNode.replaceWith(text)` is not yet widely supported
      clonedNode.parentNode.replaceChild(document.createTextNode(text), clonedNode);
460
    }
461

462 463 464 465 466 467 468
    let nodeText = clonedParentNode.innerText || clonedParentNode.textContent;

    if (!respectWhitespace) {
      nodeText = nodeText.trim();
    }

    return nodeText;
469
  }
470
}
471

472 473
window.gl = window.gl || {};
window.gl.CopyAsGFM = CopyAsGFM;
474

475
new CopyAsGFM();