Skip to content
Snippets Groups Projects
Unverified Commit 65dd3ebb authored by Douwe Maan's avatar Douwe Maan
Browse files

WIP: Add Rich WYSIWYG editor to Markdown fields

parent 1c51a6be
No related branches found
No related tags found
No related merge requests found
Showing
with 916 additions and 56 deletions
Loading
Loading
@@ -59,11 +59,6 @@ const gfmRules = {
return text;
},
},
ImageLazyLoadFilter: {
'img'(el, text) {
return `![${el.getAttribute('alt')}](${el.getAttribute('src')})`;
},
},
VideoLinkFilter: {
'.video-container'(el) {
const videoEl = el.querySelector('video');
Loading
Loading
@@ -335,6 +330,12 @@ export class CopyAsGFM {
 
clipboardData.setData('text/plain', el.textContent);
clipboardData.setData('text/x-gfm', this.nodeToGFM(el));
const div = document.createElement("div");
div.appendChild(el);
const html = div.innerHTML;
clipboardData.setData('text/html', html);
}
 
static pasteGFM(e) {
Loading
Loading
Loading
Loading
@@ -123,7 +123,7 @@ export function insertMarkdownText({ textArea, text, tag, blockTag, selected, wr
return moveCursor({ textArea, tag: tag.replace(textPlaceholder, selected), wrap, removedLastNewLine, select });
}
 
function updateText({ textArea, tag, blockTag, wrap, select }) {
export function updateMarkdownText({ textArea, tag, blockTag, wrap, select }) {
var $textArea, selected, text;
$textArea = $(textArea);
textArea = $textArea.get(0);
Loading
Loading
@@ -140,7 +140,7 @@ function replaceRange(s, start, end, substitute) {
export function addMarkdownListeners(form) {
return $('.js-md', form).off('click').on('click', function() {
const $this = $(this);
return updateText({
return updateMarkdownText({
textArea: $this.closest('.md-area').find('textarea'),
tag: $this.data('mdTag'),
blockTag: $this.data('mdBlock'),
Loading
Loading
Loading
Loading
@@ -268,7 +268,7 @@ Please check your network connection and try again.`;
if (shouldClear) {
this.note = '';
this.resizeTextarea();
this.$refs.markdownField.previewMarkdown = false;
this.$refs.markdownField.mode = 'markdown'
}
 
this.autosave.reset();
Loading
Loading
<script>
import $ from 'jquery';
import { Editor } from 'tiptap'
import {
HistoryExtension,
PlaceholderExtension,
BoldMark,
ItalicMark,
LinkMark,
BulletListNode,
HardBreakNode,
HeadingNode,
ListItemNode,
OrderedListNode,
} from 'tiptap-extensions'
import { s__ } from '~/locale';
import Flash from '../../../flash';
import GLForm from '../../../gl_form';
import { CopyAsGFM } from '../../../behaviors/markdown/copy_as_gfm';
import markdownHeader from './header.vue';
import markdownToolbar from './toolbar.vue';
import icon from '../icon.vue';
import { updateMarkdownText } from '../../../lib/utils/text_markdown';
import InlineDiffMark from './marks/inline_diff';
import InlineHTMLMark from './marks/inline_html';
import StrikeMark from './marks/strike';
import CodeMark from './marks/code';
import MathMark from './marks/math';
import EmojiNode from './nodes/emoji';
import HorizontalRuleNode from './nodes/horizontal_rule.js';
import ReferenceNode from './nodes/reference';
import BlockquoteNode from './nodes/blockquote';
import CodeBlockNode from './nodes/code_block';
import ImageNode from './nodes/image';
import VideoNode from './nodes/video';
import DetailsNode from './nodes/details';
import SummaryNode from './nodes/summary';
import markdownSerializer from './markdown_serializer';
 
export default {
components: {
Editor,
markdownHeader,
markdownToolbar,
icon,
Loading
Loading
@@ -51,13 +84,62 @@
},
data() {
return {
markdownPreview: '',
rendered: '',
referencedCommands: '',
referencedUsers: '',
markdownPreviewLoading: false,
previewMarkdown: false,
renderedLoading: false,
mode: 'markdown',
editorExtensions: [
new HistoryExtension,
new PlaceholderExtension,
// new TableOfContentsNode,
new EmojiNode,
new VideoNode,
new DetailsNode,
new SummaryNode,
new ReferenceNode,
new HorizontalRuleNode,
// new TableNode,
// new TableHeadNode,
// new TableRowNode,
// new TableCellNode,
// new TodoItemNode,
// new TodoListNode,
new BlockquoteNode,
new BulletListNode,
new CodeBlockNode,
new HeadingNode({ maxLevel: 6 }),
new HardBreakNode,
new ImageNode,
new ListItemNode,
new OrderedListNode,
new BoldMark,
new LinkMark,
new ItalicMark,
new StrikeMark,
new InlineDiffMark,
new InlineHTMLMark,
new MathMark,
new CodeMark,
// new SuggestionsPlugin,
// new MentionNode,
]
};
},
watch: {
rendered(newRendered, oldRendered) {
if (newRendered.length) {
this.$refs.editor.setContent(newRendered);
} else {
this.$refs.editor.clearContent(true);
}
}
},
computed: {
shouldShowReferencedUsers() {
const referencedUsersThreshold = 10;
Loading
Loading
@@ -87,36 +169,63 @@
},
methods: {
showPreviewTab() {
if (this.previewMarkdown) return;
if (this.mode == 'rich') {
this.getTextFromEditor();
}
this.mode = 'preview';
this.renderMarkdown();
},
showRichTab() {
this.mode = 'rich';
this.renderMarkdown();
},
showMarkdownTab() {
// TODO: Better event handling around switching tabs. Old mode/new mode?
if (this.mode == 'rich') {
this.getTextFromEditor();
}
this.rendered = '';
this.mode = 'markdown';
},
getTextFromEditor() {
// const html = this.$refs.editor.getHTML();
// var node = document.createElement('div');
// $(html).each(function() { node.appendChild(this) });
// const markdown = CopyAsGFM.nodeToGFM(node);
const doc = this.$refs.editor.getDocument();
const markdown = markdownSerializer.serialize(doc);
 
this.previewMarkdown = true;
// TODO: Only works with CommentForm
this.$parent.note = markdown || '';
},
 
/*
Can't use `$refs` as the component is technically in the parent component
so we access the VNode & then get the element
*/
const text = this.$slots.textarea[0].elm.value;
renderMarkdown() {
// TODO: Only works with CommentForm
const text = this.$parent.note;
 
if (text) {
this.markdownPreviewLoading = true;
this.renderedLoading = true;
this.$http
.post(this.versionedPreviewPath(), { text })
.post(this.versionedRenderPath(), { text })
.then(resp => resp.json())
.then(data => this.renderMarkdown(data))
.then(data => this.updateRendered(data))
.catch(() => new Flash(s__('Error loading markdown preview')));
} else {
this.renderMarkdown();
this.updateRendered();
}
},
 
showWriteTab() {
this.markdownPreview = '';
this.previewMarkdown = false;
},
renderMarkdown(data = {}) {
this.markdownPreviewLoading = false;
this.markdownPreview = data.body || 'Nothing to preview.';
updateRendered(data = {}) {
this.renderedLoading = false;
this.rendered = data.body || "";
 
if (data.references) {
this.referencedCommands = data.references.commands;
Loading
Loading
@@ -128,12 +237,52 @@
});
},
 
versionedPreviewPath() {
versionedRenderPath() {
const { markdownPreviewPath, markdownVersion } = this;
return `${markdownPreviewPath}${
markdownPreviewPath.indexOf('?') === -1 ? '?' : '&'
}markdown_version=${markdownVersion}`;
},
toolbarButtonClicked(button) {
if (this.mode == 'markdown') {
updateMarkdownText({
textArea: this.$slots.textarea[0].elm,
tag: button.tag,
blockTag: button.block,
wrap: !button.prepend,
select: button.select
});
} else {
const menuActions = this.$refs.editor.menuActions;
switch(button.tag) {
case '**':
menuActions.marks.bold.command();
break;
case '*':
menuActions.marks.italic.command();
break;
case '> ':
menuActions.nodes.blockquote.command();
break;
case '`':
menuActions.marks.code.command();
break;
case '[{text}](url)':
menuActions.marks.link.command();
break;
case '* ':
menuActions.nodes.bullet_list.command();
break;
case '1. ':
menuActions.nodes.ordered_list.command();
break;
case '* [ ] ':
menuActions.nodes.todo_list.command();
break;
}
}
}
},
};
</script>
Loading
Loading
@@ -144,12 +293,14 @@
:class="{ 'prepend-top-default append-bottom-default': addSpacingClasses }"
class="md-area js-vue-markdown-field">
<markdown-header
:preview-markdown="previewMarkdown"
@preview-markdown="showPreviewTab"
@write-markdown="showWriteTab"
:mode="mode"
@preview="showPreviewTab"
@markdown="showMarkdownTab"
@rich="showRichTab"
@toolbarButtonClicked="toolbarButtonClicked"
/>
<div
v-show="!previewMarkdown"
v-show="mode == 'markdown'"
class="md-write-holder"
>
<div class="zen-backdrop">
Loading
Loading
@@ -172,19 +323,38 @@
</div>
</div>
<div
v-show="previewMarkdown"
v-show="mode == 'rich'"
class="md-rich-editor md md-preview-holder"
>
<editor
ref="editor"
:class="['editor', { 'editable': !renderedLoading }]"
:extensions="editorExtensions"
:editable="!renderedLoading"
>
<div slot="content" slot-scope="props"></div>
</editor>
<span v-if="renderedLoading">
Loading...
</span>
</div>
<div
v-show="mode == 'preview'"
class="md md-preview-holder md-preview js-vue-md-preview"
>
<div
ref="markdown-preview"
v-html="markdownPreview"
v-html="rendered"
>
</div>
<span v-if="markdownPreviewLoading">
<span v-if="!renderedLoading && rendered.length == 0">
Nothing to preview
</span>
<span v-if="renderedLoading">
Loading...
</span>
</div>
<template v-if="previewMarkdown && !markdownPreviewLoading">
<template v-if="mode == 'preview' && !renderedLoading">
<div
v-if="referencedCommands"
class="referenced-commands"
Loading
Loading
Loading
Loading
@@ -13,8 +13,8 @@
icon,
},
props: {
previewMarkdown: {
type: Boolean,
mode: {
type: String,
required: true,
},
},
Loading
Loading
@@ -29,12 +29,12 @@
},
},
mounted() {
$(document).on('markdown-preview:show.vue', this.previewMarkdownTab);
$(document).on('markdown-preview:hide.vue', this.writeMarkdownTab);
$(document).on('markdown-preview:show.vue', this.previewTab);
$(document).on('markdown-preview:hide.vue', this.markdownTab);
},
beforeDestroy() {
$(document).off('markdown-preview:show.vue', this.previewMarkdownTab);
$(document).off('markdown-preview:hide.vue', this.writeMarkdownTab);
$(document).off('markdown-preview:show.vue', this.previewTab);
$(document).off('markdown-preview:hide.vue', this.markdownTab);
},
methods: {
isValid(form) {
Loading
Loading
@@ -43,19 +43,30 @@
$(this.$el).closest('form')[0] === form[0];
},
 
previewMarkdownTab(event, form) {
previewTab(event, form) {
if (event.target.blur) event.target.blur();
if (!this.isValid(form)) return;
 
this.$emit('preview-markdown');
this.$emit('preview');
},
 
writeMarkdownTab(event, form) {
markdownTab(event, form) {
if (event.target.blur) event.target.blur();
if (!this.isValid(form)) return;
 
this.$emit('write-markdown');
this.$emit('markdown');
},
richTab(event, form) {
if (event.target.blur) event.target.blur();
if (!this.isValid(form)) return;
this.$emit('rich');
},
toolbarButtonClicked(button) {
this.$emit('toolbarButtonClicked', button);
}
},
};
</script>
Loading
Loading
@@ -64,88 +75,113 @@
<div class="md-header">
<ul class="nav-links clearfix">
<li
:class="{ active: !previewMarkdown }"
:class="{ active: mode == 'markdown' }"
class="md-header-tab"
>
<a
class="js-write-link"
href="#md-write-holder"
tabindex="-1"
@click.prevent="writeMarkdownTab($event)"
@click.prevent="markdownTab($event)"
>
Markdown
</a>
</li>
<li
:class="{ active: mode == 'rich' }"
class="md-header-tab"
>
<a
class="js-rich-link"
href="#md-rich-holder"
tabindex="-1"
@click.prevent="richTab($event)"
>
Write
Rich
</a>
</li>
<li
:class="{ active: previewMarkdown }"
:class="{ active: mode == 'preview' }"
class="md-header-tab"
>
<a
class="js-preview-link js-md-preview-button"
class="js-preview-link"
href="#md-preview-holder"
tabindex="-1"
@click.prevent="previewMarkdownTab($event)"
@click.prevent="previewTab($event)"
>
Preview
</a>
</li>
<li
:class="{ active: !previewMarkdown }"
:class="{ active: mode != 'preview' }"
class="md-header-toolbar"
>
<toolbar-button
@click="toolbarButtonClicked"
tag="**"
button-title="Add bold text"
icon="bold"
/>
<toolbar-button
@click="toolbarButtonClicked"
tag="*"
button-title="Add italic text"
icon="italic"
/>
<toolbar-button
@click="toolbarButtonClicked"
:prepend="true"
tag="> "
button-title="Insert a quote"
icon="quote"
/>
<toolbar-button
@click="toolbarButtonClicked"
tag="`"
tag-block="```"
button-title="Insert code"
icon="code"
/>
<toolbar-button
v-if="mode == 'markdown'"
@click="toolbarButtonClicked"
tag="[{text}](url)"
tag-select="url"
button-title="Add a link"
icon="link"
/>
<toolbar-button
@click="toolbarButtonClicked"
:prepend="true"
tag="* "
button-title="Add a bullet list"
icon="list-bulleted"
/>
<toolbar-button
@click="toolbarButtonClicked"
:prepend="true"
tag="1. "
button-title="Add a numbered list"
icon="list-numbered"
/>
<toolbar-button
v-if="mode == 'markdown'"
@click="toolbarButtonClicked"
:prepend="true"
tag="* [ ] "
button-title="Add a task list"
icon="task-done"
/>
<toolbar-button
v-if="mode == 'markdown'"
:tag="mdTable"
:prepend="true"
:button-title="__('Add a table')"
icon="table"
/>
<button
v-if="mode == 'markdown'"
v-tooltip
aria-label="Go full screen"
class="toolbar-btn toolbar-fullscreen-btn js-zen-enter"
Loading
Loading
import { MarkdownSerializer, defaultMarkdownSerializer } from 'prosemirror-markdown';
const defaultNodes = defaultMarkdownSerializer.nodes;
const nodes = {
/*
blockquote
code_block
heading
horizontal_rule
bullet_list
ordered_list
list_item
paragraph
image
hard_break
text
*/
...defaultNodes,
video(state, node) {
state.write("![" + state.esc(node.attrs.alt || "") + "](" + state.esc(node.attrs.src) + ")");
state.closeBlock(node);
},
emoji(state, node) {
state.write(`:${node.attrs.name}:`);
},
reference(state, node) {
state.write(node.attrs.originalText || node.attrs.text);
},
code_block(state, node) {
const text = node.textContent;
const lang = node.attrs.lang;
// Prefixes lines with 4 spaces if the code contains a line that starts with triple backticks
if (lang == '' && text.match(/^```/gm)) {
state.wrapBlock(" ", null, node, () => state.text(text, false));
} else {
state.write("```" + lang + "\n");
state.text(text, false);
state.ensureNewLine();
state.write("```");
state.closeBlock(node);
}
},
hard_break(state, node) {
if (!state.atBlank()) state.write(" \n");
},
math(state, node) {
state.write("$`");
state.text(node.textContent, false);
state.write("`$");
},
code(state, node) {
const text = node.textContent;
let backtickCount = 1;
const backtickMatch = text.match(/`+/);
if (backtickMatch) {
backtickCount = backtickMatch[0].length + 1;
}
const backticks = Array(backtickCount + 1).join('`');
const spaceOrNoSpace = backtickCount > 1 ? ' ' : '';
state.write(backticks + spaceOrNoSpace);
state.text(text, false);
state.write(spaceOrNoSpace + backticks);
},
html(state, node) {
state.write(`<${node.attrs.tag}>\n`);
state.renderContent(node);
state.ensureNewLine();
state.write(`</${node.attrs.tag}>`);
state.closeBlock(node);
},
details(state, node) {
state.write("<details>\n");
state.renderContent(node);
state.ensureNewLine();
state.write('</details>');
state.closeBlock(node);
},
summary(state, node) {
state.write('<summary>');
state.text(node.textContent, false);
state.write('</summary>');
state.closeBlock(node);
},
}
const defaultMarks = defaultMarkdownSerializer.marks;
const marks = {
...defaultMarks, // em strong link code
bold: defaultMarks.strong,
italic: defaultMarks.em,
math: { open: '$`', close: '`$', escape: false }, // prosemirror-markdown bug: open/close are reversed!
strike: { open: "~~", close: "~~", mixable: true, expelEnclosingWhitespace: true },
inline_diff: {
mixable: true,
open(state, mark) {
return mark.attrs.addition ? '{+' : '{-';
},
close(state, mark) {
return mark.attrs.addition ? '+}' : '-}';
}
},
inline_html: {
mixable: true,
open(state, mark) {
return `<${mark.attrs.tag}${mark.attrs.title ? ` title="${state.esc(mark.attrs.title)}"` : ''}>`;
},
close(state, mark) {
return `</${mark.attrs.tag}>`;
}
},
}
export default new MarkdownSerializer(nodes, marks);
import { Mark } from 'tiptap'
import { toggleMark, markInputRule } from 'tiptap-commands'
export default class CodeMark extends Mark {
get name() {
return 'code'
}
get schema() {
return {
excludes: '_',
parseDOM: [
{ tag: 'code' },
],
toDOM: () => ['code', 0],
}
}
keys({ type }) {
return {
'Mod-`': toggleMark(type),
}
}
command({ type }) {
return toggleMark(type)
}
inputRules({ type }) {
return [
markInputRule(/(?:`)([^`]+)(?:`)$/, type),
]
}
}
import { Mark } from 'tiptap'
import { toggleMark, markInputRule } from 'tiptap-commands'
export default class InlineDiffMark 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],
}
}
command({ type }) {
return toggleMark(type)
}
inputRules({ type }) {
return [
markInputRule(/(?:\[\+|\{\+)([^\+]+)(?:\+\]|\+\})$/, type, { addition: true }),
markInputRule(/(?:\[-|\{-)([^-]+)(?:-\]|-\})$/, type, { addition: false }),
]
}
}
import { Mark } from 'tiptap'
import { toggleMark, markInputRule } from 'tiptap-commands'
const tags = 'sup sub kbd q samp var'.split(' ');
export default class InlineHTMLMark extends Mark {
get name() {
return 'inline_html'
}
get schema() {
return {
excludes: '',
attrs: {
tag: {},
title: { default: null }
},
parseDOM: [
{
tag: tags.join(', '),
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],
}
}
command({ type }) {
return toggleMark(type)
}
inputRules({ type }) {
return tags.map(tag =>
markInputRule(new RegExp(`(?:\\<${tag}\\>)([^\\<]+)(?:\\<\\/${tag}\\>)$`), type, { tag })
);
}
}
import { Mark } from 'tiptap'
import { toggleMark, markInputRule } from 'tiptap-commands'
export default class MathMark extends Mark {
get name() {
return 'math'
}
get schema() {
return {
excludes: '_',
parseDOM: [
{
tag: 'code.code.math[data-math-style=inline]',
priority: 51
},
{ tag: 'span.katex', contentElement: 'annotation[encoding="application/x-tex"]' }
],
toDOM: () => ['code', { class: 'code math', 'data-math-style': 'inline' }, 0],
}
}
command({ type }) {
return toggleMark(type)
}
inputRules({ type }) {
return [
markInputRule(/(?:\$`)([^`]+)(?:`\$)$/, type),
]
}
}
import { Mark } from 'tiptap'
import { toggleMark, markInputRule } from 'tiptap-commands'
export default class StrikeMark extends Mark {
get name() {
return 'strike'
}
get schema() {
return {
parseDOM: [
{
tag: 's',
},
{
tag: 'del',
},
{
tag: 'strike',
},
{
style: 'text-decoration',
getAttrs: value => value === 'line-through',
},
],
toDOM: () => ['del', 0],
}
}
keys({ type }) {
return {
'Mod-d': toggleMark(type),
}
}
command({ type }) {
return toggleMark(type)
}
inputRules({ type }) {
return [
markInputRule(/~~([^~]+)~~$/, type),
]
}
}
import { Node } from 'tiptap'
import { wrappingInputRule, textblockTypeInputRule, toggleWrap } from 'tiptap-commands'
export default class BlockquoteNode extends Node {
get name() {
return 'blockquote'
}
get schema() {
return {
content: 'block*',
group: 'block',
defining: true,
draggable: false,
parseDOM: [
{ tag: 'blockquote' },
],
toDOM: () => ['blockquote', 0],
}
}
command({ type, schema }) {
return toggleWrap(type, schema.nodes.paragraph)
}
keys({ type }) {
return {
'Ctrl->': toggleWrap(type),
}
}
inputRules({ type }) {
return [
wrappingInputRule(/^\s*>\s$/, type),
wrappingInputRule(/^>>>$/, type),
]
}
}
import { Node } from 'tiptap'
import { toggleBlockType, setBlockType, textblockTypeInputRule } from 'tiptap-commands'
export default class CodeBlockNode extends Node {
get name() {
return 'code_block'
}
get schema() {
return {
content: 'text*',
marks: '',
group: 'block',
code: true,
defining: true,
draggable: false,
attrs: {
lang: { default: '' }
},
parseDOM: [
{
tag: 'pre.code.highlight',
preserveWhitespace: 'full',
getAttrs: (el) => {
let lang = el.getAttribute('lang');
if (!lang || lang == 'plaintext') lang = '';
return { lang };
}
},
{
tag: 'span.katex-display',
preserveWhitespace: 'full',
contentElement: 'annotation[encoding="application/x-tex"]',
attrs: { lang: 'math' }
},
{
tag: 'svg.mermaid',
preserveWhitespace: 'full',
contentElement: 'text.source',
attrs: { lang: 'mermaid' }
}
],
toDOM: node => ['pre', { class: 'code highlight', lang: node.attrs.lang }, ['code', 0]],
}
}
command({ type, schema }) {
return toggleBlockType(type, schema.nodes.paragraph)
}
keys({ type }) {
return {
'Shift-Ctrl-\\': setBlockType(type),
}
}
inputRules({ type }) {
return [
textblockTypeInputRule(/^```$/, type),
]
}
}
import { Node } from 'tiptap'
export default class DetailsNode extends Node {
get name() {
return 'details'
}
get schema() {
return {
content: 'summary block*',
group: 'block',
defining: true,
draggable: false,
parseDOM: [
{ tag: 'details' },
],
toDOM: node => ['details', { open: true, onclick: 'return false', tabindex: '-1' }, 0],
}
}
}
import { Node } from 'tiptap'
export default class EmojiNode 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],
}
}
}
import { Node } from 'tiptap'
import { InputRule } from 'prosemirror-inputrules'
export default class HorizontalRuleNode extends Node {
get name() {
return 'horizontal_rule'
}
get schema() {
return {
group: 'block',
parseDOM: [
{ tag: 'hr' },
],
toDOM: () => ['hr'],
}
}
command({ type, attrs }) {
return (state, dispatch) => {
const { selection } = state
const position = selection.$cursor ? selection.$cursor.pos : selection.$to.pos
const node = type.create(attrs)
const transaction = state.tr.insert(position, node)
dispatch(transaction)
}
}
inputRules({ type }) {
return [
new InputRule(/^---$/, (state, match, start, end) => {
const node = type.create()
return state.tr.replaceWith(start, end, node);
})
]
}
}
import { Node } from 'tiptap'
import { placeholderImage } from '~/lazy_loader';
export default class ImageNode extends Node {
get name() {
return 'image'
}
get schema() {
return {
inline: true,
attrs: {
src: {},
alt: {
default: null,
},
title: {
default: null,
},
},
group: 'inline',
draggable: true,
parseDOM: [
{
tag: 'a.no-attachment-icon',
priority: 51,
skip: true
},
{
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],
}
}
command({ type, attrs }) {
return (state, dispatch) => {
const { selection } = state
const position = selection.$cursor ? selection.$cursor.pos : selection.$to.pos
const node = type.create(attrs)
const transaction = state.tr.insert(position, node)
dispatch(transaction)
}
}
}
import { Node } from 'tiptap'
export default class ReferenceNode 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
],
}
}
}
import { Node } from 'tiptap'
export default class SummaryNode extends Node {
get name() {
return 'summary'
}
get schema() {
return {
content: 'text*',
marks: '',
group: 'block',
parseDOM: [
{ tag: 'summary' },
],
toDOM: node => ['summary', 0],
}
}
}
import { Node } from 'tiptap'
import { placeholderImage } from '~/lazy_loader';
export default class VideoNode 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
}
],
}
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment