Commit a1f037cc authored by Stan Hu's avatar Stan Hu

Merge branch '332087-render-audio-in-content-editor' into 'master'

Render audio in content editor

See merge request gitlab-org/gitlab!68598
parents a149b49d 933fe8e1
/* eslint-disable class-methods-use-this */ /* eslint-disable class-methods-use-this */
/* eslint-disable @gitlab/require-i18n-strings */
import { defaultMarkdownSerializer } from 'prosemirror-markdown'; import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { Node } from 'tiptap'; import { Node } from 'tiptap';
import { HIGHER_PARSE_RULE_PRIORITY } from '../constants';
/** /**
* Abstract base class for playable media, like video and audio. * Abstract base class for playable media, like video and audio.
...@@ -32,34 +30,34 @@ export default class Playable extends Node { ...@@ -32,34 +30,34 @@ export default class Playable extends Node {
const parseDOM = [ const parseDOM = [
{ {
tag: `.${this.mediaType}-container`, tag: `.media-container`,
skip: true, getAttrs: (el) => ({
}, src: el.querySelector('audio,video').src,
{ alt: el.querySelector('audio,video').dataset.title,
tag: `.${this.mediaType}-container p`, }),
priority: HIGHER_PARSE_RULE_PRIORITY,
ignore: true,
},
{
tag: `${this.mediaType}[src]`,
getAttrs: (el) => ({ src: el.src, alt: el.dataset.title }),
}, },
]; ];
const toDOM = (node) => [ const toDOM = (node) => [
this.mediaType, 'span',
{ { class: 'media-container' },
src: node.attrs.src, [
controls: true, this.options.mediaType,
'data-setup': '{}', {
'data-title': node.attrs.alt, src: node.attrs.src,
...this.extraElementAttrs, controls: true,
}, 'data-setup': '{}',
'data-title': node.attrs.alt,
...this.extraElementAttrs,
},
],
['a', { href: node.attrs.src }, node.attrs.alt],
]; ];
return { return {
attrs, attrs,
group: 'block', group: 'inline',
inline: true,
draggable: true, draggable: true,
parseDOM, parseDOM,
toDOM, toDOM,
...@@ -68,6 +66,5 @@ export default class Playable extends Node { ...@@ -68,6 +66,5 @@ export default class Playable extends Node {
toMarkdown(state, node) { toMarkdown(state, node) {
defaultMarkdownSerializer.nodes.image(state, node); defaultMarkdownSerializer.nodes.image(state, node);
state.closeBlock(node);
} }
} }
import Playable from './playable';
export default Playable.extend({
defaultOptions: {
...Playable.options,
mediaType: 'audio',
},
});
import { Node } from '@tiptap/core';
const queryPlayableElement = (element, mediaType) => element.querySelector(mediaType);
export default Node.create({
name: 'playable',
group: 'inline',
inline: true,
draggable: true,
addAttributes() {
return {
src: {
default: null,
parseHTML: (element) => {
const playable = queryPlayableElement(element, this.options.mediaType);
return {
src: playable.src,
};
},
},
canonicalSrc: {
default: null,
parseHTML: (element) => {
const playable = queryPlayableElement(element, this.options.mediaType);
return {
canonicalSrc: playable.dataset.canonicalSrc,
};
},
},
alt: {
default: null,
parseHTML: (element) => {
const playable = queryPlayableElement(element, this.options.mediaType);
return {
alt: playable.dataset.title,
};
},
},
};
},
parseHTML() {
return [
{
tag: '.media-container',
},
];
},
renderHTML({ node }) {
return [
'span',
{ class: 'media-container' },
[
this.options.mediaType,
{
src: node.attrs.src,
controls: true,
'data-setup': '{}',
'data-title': node.attrs.alt,
...this.extraElementAttrs,
},
],
['a', { href: node.attrs.src }, node.attrs.alt],
];
},
});
...@@ -2,6 +2,7 @@ import { Editor } from '@tiptap/vue-2'; ...@@ -2,6 +2,7 @@ import { Editor } from '@tiptap/vue-2';
import { isFunction } from 'lodash'; import { isFunction } from 'lodash';
import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants'; import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
import Attachment from '../extensions/attachment'; import Attachment from '../extensions/attachment';
import Audio from '../extensions/audio';
import Blockquote from '../extensions/blockquote'; import Blockquote from '../extensions/blockquote';
import Bold from '../extensions/bold'; import Bold from '../extensions/bold';
import BulletList from '../extensions/bullet_list'; import BulletList from '../extensions/bullet_list';
...@@ -63,6 +64,7 @@ export const createContentEditor = ({ ...@@ -63,6 +64,7 @@ export const createContentEditor = ({
const builtInContentEditorExtensions = [ const builtInContentEditorExtensions = [
Attachment.configure({ uploadsPath, renderMarkdown }), Attachment.configure({ uploadsPath, renderMarkdown }),
Audio,
Blockquote, Blockquote,
Bold, Bold,
BulletList, BulletList,
......
...@@ -3,6 +3,7 @@ import { ...@@ -3,6 +3,7 @@ import {
defaultMarkdownSerializer, defaultMarkdownSerializer,
} from 'prosemirror-markdown/src/to_markdown'; } from 'prosemirror-markdown/src/to_markdown';
import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model'; import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model';
import Audio from '../extensions/audio';
import Blockquote from '../extensions/blockquote'; import Blockquote from '../extensions/blockquote';
import Bold from '../extensions/bold'; import Bold from '../extensions/bold';
import BulletList from '../extensions/bullet_list'; import BulletList from '../extensions/bullet_list';
...@@ -40,6 +41,8 @@ import { ...@@ -40,6 +41,8 @@ import {
openTag, openTag,
closeTag, closeTag,
renderOrderedList, renderOrderedList,
renderImage,
renderPlayable,
} from './serialization_helpers'; } from './serialization_helpers';
const defaultSerializerConfig = { const defaultSerializerConfig = {
...@@ -92,6 +95,7 @@ const defaultSerializerConfig = { ...@@ -92,6 +95,7 @@ const defaultSerializerConfig = {
}, },
nodes: { nodes: {
[Audio.name]: renderPlayable,
[Blockquote.name]: (state, node) => { [Blockquote.name]: (state, node) => {
if (node.attrs.multiline) { if (node.attrs.multiline) {
state.write('>>>'); state.write('>>>');
...@@ -120,12 +124,7 @@ const defaultSerializerConfig = { ...@@ -120,12 +124,7 @@ const defaultSerializerConfig = {
[HardBreak.name]: renderHardBreak, [HardBreak.name]: renderHardBreak,
[Heading.name]: defaultMarkdownSerializer.nodes.heading, [Heading.name]: defaultMarkdownSerializer.nodes.heading,
[HorizontalRule.name]: defaultMarkdownSerializer.nodes.horizontal_rule, [HorizontalRule.name]: defaultMarkdownSerializer.nodes.horizontal_rule,
[Image.name]: (state, node) => { [Image.name]: renderImage,
const { alt, canonicalSrc, src, title } = node.attrs;
const quotedTitle = title ? ` ${state.quote(title)}` : '';
state.write(`![${state.esc(alt || '')}](${state.esc(canonicalSrc || src)}${quotedTitle})`);
},
[ListItem.name]: defaultMarkdownSerializer.nodes.list_item, [ListItem.name]: defaultMarkdownSerializer.nodes.list_item,
[OrderedList.name]: renderOrderedList, [OrderedList.name]: renderOrderedList,
[Paragraph.name]: defaultMarkdownSerializer.nodes.paragraph, [Paragraph.name]: defaultMarkdownSerializer.nodes.paragraph,
......
...@@ -286,3 +286,14 @@ export function renderHardBreak(state, node, parent, index) { ...@@ -286,3 +286,14 @@ export function renderHardBreak(state, node, parent, index) {
} }
} }
} }
export function renderImage(state, node) {
const { alt, canonicalSrc, src, title } = node.attrs;
const quotedTitle = title ? ` ${state.quote(title)}` : '';
state.write(`![${state.esc(alt || '')}](${state.esc(canonicalSrc || src)}${quotedTitle})`);
}
export function renderPlayable(state, node) {
renderImage(state, node);
}
...@@ -41,6 +41,12 @@ ...@@ -41,6 +41,12 @@
} }
} }
.media-container {
display: inline-flex;
flex-direction: column;
margin-bottom: $gl-spacing-scale-2;
}
img:not(.emoji) { img:not(.emoji) {
margin: 0 0 8px; margin: 0 0 8px;
} }
......
...@@ -52,7 +52,7 @@ module Banzai ...@@ -52,7 +52,7 @@ module Banzai
doc.document.create_element(media_type, media_element_attrs) doc.document.create_element(media_type, media_element_attrs)
end end
def download_paragraph(doc, element) def download_link(doc, element)
link_content = element['title'] || element['alt'] link_content = element['title'] || element['alt']
link_element_attrs = { link_element_attrs = {
...@@ -67,19 +67,15 @@ module Banzai ...@@ -67,19 +67,15 @@ module Banzai
link_element_attrs['data-canonical-src'] = element['data-canonical-src'] link_element_attrs['data-canonical-src'] = element['data-canonical-src']
end end
link = doc.document.create_element('a', link_content, link_element_attrs) doc.document.create_element('a', link_content, link_element_attrs)
doc.document.create_element('p').tap do |paragraph|
paragraph.children = link
end
end end
def media_node(doc, element) def media_node(doc, element)
container_element_attrs = { class: "#{media_type}-container" } container_element_attrs = { class: "media-container #{media_type}-container" }
doc.document.create_element( "div", container_element_attrs).tap do |container| doc.document.create_element('span', container_element_attrs).tap do |container|
container.add_child(media_element(doc, element)) container.add_child(media_element(doc, element))
container.add_child(download_paragraph(doc, element)) container.add_child(download_link(doc, element))
end end
end end
end end
......
...@@ -145,3 +145,15 @@ ...@@ -145,3 +145,15 @@
context: project_wiki context: project_wiki
markdown: |- markdown: |-
Hi @gitlab - thank you for reporting this ~bug (#1) we hope to fix it in %1.1 as part of !1 Hi @gitlab - thank you for reporting this ~bug (#1) we hope to fix it in %1.1 as part of !1
- name: audio
markdown: '![Sample Audio](https://gitlab.com/gitlab.mp3)'
- name: audio_in_lists
markdown: |-
* ![Sample Audio](https://gitlab.com/1.mp3)
* ![Sample Audio](https://gitlab.com/2.mp3)
1. ![Sample Audio](https://gitlab.com/1.mp3)
2. ![Sample Audio](https://gitlab.com/2.mp3)
* [x] ![Sample Audio](https://gitlab.com/1.mp3)
* [x] ![Sample Audio](https://gitlab.com/2.mp3)
...@@ -25,18 +25,14 @@ RSpec.describe Banzai::Filter::AudioLinkFilter do ...@@ -25,18 +25,14 @@ RSpec.describe Banzai::Filter::AudioLinkFilter do
it 'replaces the image tag with an audio tag' do it 'replaces the image tag with an audio tag' do
container = filter(image).children.first container = filter(image).children.first
expect(container.name).to eq 'div' expect(container.name).to eq 'span'
expect(container['class']).to eq 'audio-container' expect(container['class']).to eq 'media-container audio-container'
audio, paragraph = container.children audio, link = container.children
expect(audio.name).to eq 'audio' expect(audio.name).to eq 'audio'
expect(audio['src']).to eq src expect(audio['src']).to eq src
expect(paragraph.name).to eq 'p'
link = paragraph.children.first
expect(link.name).to eq 'a' expect(link.name).to eq 'a'
expect(link['href']).to eq src expect(link['href']).to eq src
expect(link['target']).to eq '_blank' expect(link['target']).to eq '_blank'
...@@ -105,15 +101,13 @@ RSpec.describe Banzai::Filter::AudioLinkFilter do ...@@ -105,15 +101,13 @@ RSpec.describe Banzai::Filter::AudioLinkFilter do
image = %(<img src="#{proxy_src}" data-canonical-src="#{canonical_src}"/>) image = %(<img src="#{proxy_src}" data-canonical-src="#{canonical_src}"/>)
container = filter(image).children.first container = filter(image).children.first
expect(container['class']).to eq 'audio-container' expect(container['class']).to eq 'media-container audio-container'
audio, paragraph = container.children audio, link = container.children
expect(audio['src']).to eq proxy_src expect(audio['src']).to eq proxy_src
expect(audio['data-canonical-src']).to eq canonical_src expect(audio['data-canonical-src']).to eq canonical_src
link = paragraph.children.first
expect(link['href']).to eq proxy_src expect(link['href']).to eq proxy_src
end end
end end
......
...@@ -25,20 +25,16 @@ RSpec.describe Banzai::Filter::VideoLinkFilter do ...@@ -25,20 +25,16 @@ RSpec.describe Banzai::Filter::VideoLinkFilter do
it 'replaces the image tag with a video tag' do it 'replaces the image tag with a video tag' do
container = filter(image).children.first container = filter(image).children.first
expect(container.name).to eq 'div' expect(container.name).to eq 'span'
expect(container['class']).to eq 'video-container' expect(container['class']).to eq 'media-container video-container'
video, paragraph = container.children video, link = container.children
expect(video.name).to eq 'video' expect(video.name).to eq 'video'
expect(video['src']).to eq src expect(video['src']).to eq src
expect(video['width']).to eq "400" expect(video['width']).to eq "400"
expect(video['preload']).to eq 'metadata' expect(video['preload']).to eq 'metadata'
expect(paragraph.name).to eq 'p'
link = paragraph.children.first
expect(link.name).to eq 'a' expect(link.name).to eq 'a'
expect(link['href']).to eq src expect(link['href']).to eq src
expect(link['target']).to eq '_blank' expect(link['target']).to eq '_blank'
...@@ -107,15 +103,13 @@ RSpec.describe Banzai::Filter::VideoLinkFilter do ...@@ -107,15 +103,13 @@ RSpec.describe Banzai::Filter::VideoLinkFilter do
image = %(<img src="#{proxy_src}" data-canonical-src="#{canonical_src}"/>) image = %(<img src="#{proxy_src}" data-canonical-src="#{canonical_src}"/>)
container = filter(image).children.first container = filter(image).children.first
expect(container['class']).to eq 'video-container' expect(container['class']).to eq 'media-container video-container'
video, paragraph = container.children video, link = container.children
expect(video['src']).to eq proxy_src expect(video['src']).to eq proxy_src
expect(video['data-canonical-src']).to eq canonical_src expect(video['data-canonical-src']).to eq canonical_src
link = paragraph.children.first
expect(link['href']).to eq proxy_src expect(link['href']).to eq proxy_src
end end
end end
......
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