Skip to content

Commit 3283805

Browse files
author
Dobromir Hristov
authored
Add support for @Video directive (#407)
closes rdar://97715316
1 parent 0e106af commit 3283805

File tree

8 files changed

+241
-5
lines changed

8 files changed

+241
-5
lines changed

src/components/ContentNode.vue

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import Reference from './ContentNode/Reference.vue';
2222
import Table from './ContentNode/Table.vue';
2323
import StrikeThrough from './ContentNode/StrikeThrough.vue';
2424
import Small from './ContentNode/Small.vue';
25+
import BlockVideo from './ContentNode/BlockVideo.vue';
2526
2627
const BlockType = {
2728
aside: 'aside',
@@ -35,6 +36,7 @@ const BlockType = {
3536
unorderedList: 'unorderedList',
3637
dictionaryExample: 'dictionaryExample',
3738
small: 'small',
39+
video: 'video',
3840
};
3941
4042
const InlineType = {
@@ -198,7 +200,9 @@ function renderNode(createElement, references) {
198200
if ((title && abstract.length) || abstract.length) {
199201
// if there is a `title`, it should be above, otherwise below
200202
figureContent.splice(title ? 0 : 1, 0,
201-
createElement(FigureCaption, { props: { title } }, renderChildren(abstract)));
203+
createElement(FigureCaption, {
204+
props: { title, centered: !title },
205+
}, renderChildren(abstract)));
202206
}
203207
return createElement(Figure, { props: { anchor } }, figureContent);
204208
};
@@ -282,6 +286,19 @@ function renderNode(createElement, references) {
282286
createElement(Small, {}, renderChildren(node.inlineContent)),
283287
]);
284288
}
289+
case BlockType.video: {
290+
if (node.metadata && node.metadata.abstract) {
291+
return renderFigure(node);
292+
}
293+
294+
return references[node.identifier] ? (
295+
createElement(BlockVideo, {
296+
props: {
297+
identifier: node.identifier,
298+
},
299+
})
300+
) : null;
301+
}
285302
case InlineType.codeVoice:
286303
return createElement(CodeVoice, {}, (
287304
node.code
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<!--
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2021 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See https://swift.org/LICENSE.txt for license information
8+
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
-->
10+
11+
<template>
12+
<Asset
13+
:identifier="identifier"
14+
:video-autoplays="false"
15+
:video-muted="false"
16+
:showsReplayButton="!isClientMobile"
17+
:showsVideoControls="isClientMobile"
18+
/>
19+
</template>
20+
21+
<script>
22+
import Asset from 'docc-render/components/Asset.vue';
23+
import isClientMobile from 'docc-render/mixins/isClientMobile';
24+
25+
export default {
26+
name: 'BlockVideo',
27+
mixins: [isClientMobile],
28+
components: { Asset },
29+
props: {
30+
identifier: {
31+
type: String,
32+
required: true,
33+
},
34+
},
35+
};
36+
</script>
37+
38+
<style lang="scss" scoped>
39+
/deep/ video {
40+
display: block;
41+
margin-left: auto;
42+
margin-right: auto;
43+
object-fit: contain;
44+
max-width: 100%;
45+
}
46+
</style>

src/components/ContentNode/FigureCaption.vue

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
-->
1010

1111
<template>
12-
<figcaption class="caption">
12+
<figcaption class="caption" :class="{ centered }">
1313
<strong v-if="title">{{ title }}</strong>&nbsp;<slot />
1414
</figcaption>
1515
</template>
@@ -22,6 +22,10 @@ export default {
2222
type: String,
2323
required: false,
2424
},
25+
centered: {
26+
type: Boolean,
27+
default: false,
28+
},
2529
},
2630
};
2731
</script>
@@ -31,6 +35,14 @@ export default {
3135
3236
.caption {
3337
@include font-styles(documentation-figcaption);
38+
39+
&:last-child {
40+
margin-top: $stacked-margin-xlarge;
41+
}
42+
43+
&.centered {
44+
text-align: center;
45+
}
3446
}
3547
3648
/deep/ p {

src/components/ReplayableVideoAsset.vue

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,21 @@
2828
@click.prevent="replay"
2929
>
3030
{{ text }}
31-
<InlineReplayIcon class="replay-icon icon-inline" />
31+
<InlineReplayIcon v-if="played" class="replay-icon icon-inline" />
32+
<PlayIcon v-else class="replay-icon icon-inline" />
3233
</a>
3334
</div>
3435
</template>
3536

3637
<script>
3738
import VideoAsset from 'docc-render/components/VideoAsset.vue';
3839
import InlineReplayIcon from 'theme/components/Icons/InlineReplayIcon.vue';
40+
import PlayIcon from 'theme/components/Icons/PlayIcon.vue';
3941
4042
export default {
4143
name: 'ReplayableVideoAsset',
4244
components: {
45+
PlayIcon,
4346
InlineReplayIcon,
4447
VideoAsset,
4548
},

tests/unit/components/ContentNode.spec.js

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import Table from 'docc-render/components/ContentNode/Table.vue';
2323
import LinkableHeading from 'docc-render/components/ContentNode/LinkableHeading.vue';
2424
import StrikeThrough from 'docc-render/components/ContentNode/StrikeThrough.vue';
2525
import Small from '@/components/ContentNode/Small.vue';
26+
import BlockVideo from '@/components/ContentNode/BlockVideo.vue';
2627

2728
const { TableHeaderStyle } = ContentNode.constants;
2829

@@ -475,12 +476,13 @@ describe('ContentNode', () => {
475476
expect(caption.exists()).toBe(true);
476477
expect(caption.contains('p')).toBe(true);
477478
expect(caption.props('title')).toBeFalsy();
479+
expect(caption.props('centered')).toBe(true);
478480
expect(caption.text()).toContain('blah');
479481
// assert figurerecaption is below the image
480482
expect(figure.html()).toMatchInlineSnapshot(`
481483
<figure-stub>
482484
<inlineimage-stub alt="" variants="[object Object],[object Object]"></inlineimage-stub>
483-
<figurecaption-stub>
485+
<figurecaption-stub centered="true">
484486
<p>blah</p>
485487
</figurecaption-stub>
486488
</figure-stub>
@@ -531,6 +533,102 @@ describe('ContentNode', () => {
531533
});
532534
});
533535

536+
describe('with type="video"', () => {
537+
const identifier = 'video.mp4';
538+
const references = {
539+
[identifier]: {
540+
identifier,
541+
variants: [
542+
{
543+
traits: ['2x', 'light'],
544+
url: '',
545+
size: { width: 1202, height: 630 },
546+
},
547+
],
548+
},
549+
};
550+
551+
it('renders an `BlockVideo`', () => {
552+
const wrapper = mountWithItem({
553+
type: 'video',
554+
identifier,
555+
}, references);
556+
557+
const inlineVideo = wrapper.find('.content').find(BlockVideo);
558+
expect(inlineVideo.exists()).toBe(true);
559+
expect(inlineVideo.props('identifier')).toEqual(identifier);
560+
});
561+
562+
it('does not crash with missing video reference data', () => {
563+
expect(() => mountWithItem({
564+
type: 'video',
565+
identifier,
566+
}, {})).not.toThrow();
567+
});
568+
569+
it('renders a `Figure`/`FigureCaption` with metadata', () => {
570+
const metadata = {
571+
anchor: 'foo',
572+
abstract: [{
573+
type: 'paragraph',
574+
inlineContent: [{ type: 'text', text: 'blah' }],
575+
}],
576+
};
577+
const wrapper = mountWithItem({
578+
type: 'video',
579+
identifier,
580+
metadata,
581+
}, references);
582+
583+
const figure = wrapper.find(Figure);
584+
expect(figure.exists()).toBe(true);
585+
expect(figure.props('anchor')).toBe('foo');
586+
expect(figure.contains(BlockVideo)).toBe(true);
587+
588+
const caption = wrapper.find(FigureCaption);
589+
expect(caption.exists()).toBe(true);
590+
expect(caption.contains('p')).toBe(true);
591+
expect(caption.props('title')).toBe(metadata.title);
592+
expect(caption.props('centered')).toBe(true);
593+
expect(caption.text()).toContain('blah');
594+
});
595+
596+
it('renders a `Figure`/`FigureCaption` without an anchor, with text under the video', () => {
597+
const metadata = {
598+
abstract: [{
599+
type: 'paragraph',
600+
inlineContent: [{ type: 'text', text: 'blah' }],
601+
}],
602+
};
603+
const wrapper = mountWithItem({
604+
type: 'video',
605+
identifier,
606+
metadata,
607+
}, references);
608+
609+
const figure = wrapper.find(Figure);
610+
expect(figure.exists()).toBe(true);
611+
expect(figure.props('anchor')).toBeFalsy();
612+
expect(figure.contains(BlockVideo)).toBe(true);
613+
614+
const caption = wrapper.find(FigureCaption);
615+
expect(caption.exists()).toBe(true);
616+
expect(caption.contains('p')).toBe(true);
617+
expect(caption.props('title')).toBeFalsy();
618+
expect(caption.props('centered')).toBe(true);
619+
expect(caption.text()).toContain('blah');
620+
// assert figurerecaption is below the image
621+
expect(figure.html()).toMatchInlineSnapshot(`
622+
<figure-stub>
623+
<blockvideo-stub identifier="video.mp4"></blockvideo-stub>
624+
<figurecaption-stub centered="true">
625+
<p>blah</p>
626+
</figurecaption-stub>
627+
</figure-stub>
628+
`);
629+
});
630+
});
631+
534632
describe('with type="link"', () => {
535633
it('renders a <a>', () => {
536634
const wrapper = mountWithItem({
@@ -969,6 +1067,7 @@ describe('ContentNode', () => {
9691067
const caption = figure.find(FigureCaption);
9701068
expect(caption.exists()).toBe(true);
9711069
expect(caption.props('title')).toBe(metadata.title);
1070+
expect(caption.props('centered')).toBe(false);
9721071
expect(caption.contains('p')).toBe(true);
9731072
expect(caption.text()).toContain('blah');
9741073
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* This source file is part of the Swift.org open source project
3+
*
4+
* Copyright (c) 2021 Apple Inc. and the Swift project authors
5+
* Licensed under Apache License v2.0 with Runtime Library Exception
6+
*
7+
* See https://swift.org/LICENSE.txt for license information
8+
* See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import BlockVideo from '@/components/ContentNode/BlockVideo.vue';
12+
import { shallowMount } from '@vue/test-utils';
13+
import Asset from '@/components/Asset.vue';
14+
import isClientMobile from 'docc-render/mixins/isClientMobile';
15+
16+
jest.mock('docc-render/mixins/isClientMobile');
17+
18+
isClientMobile.computed.isClientMobile.mockReturnValue(false);
19+
20+
const defaultProps = {
21+
identifier: 'foo',
22+
};
23+
24+
const createWrapper = () => shallowMount(BlockVideo, {
25+
propsData: defaultProps,
26+
});
27+
28+
describe('BlockVideo', () => {
29+
it('renders an Asset on desktop', () => {
30+
const wrapper = createWrapper();
31+
expect(wrapper.find(Asset).props()).toEqual({
32+
identifier: defaultProps.identifier,
33+
videoAutoplays: false,
34+
videoMuted: false,
35+
showsReplayButton: true,
36+
showsVideoControls: false,
37+
});
38+
});
39+
40+
it('renders an Asset, on a mobile device', () => {
41+
isClientMobile.computed.isClientMobile.mockReturnValue(true);
42+
const wrapper = createWrapper();
43+
expect(wrapper.find(Asset).props()).toEqual({
44+
identifier: defaultProps.identifier,
45+
videoAutoplays: false,
46+
videoMuted: false,
47+
showsReplayButton: false,
48+
showsVideoControls: true,
49+
});
50+
});
51+
});

tests/unit/components/ContentNode/FigureCaption.spec.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,10 @@ describe('FigureCaption', () => {
2727
expect(wrapper.is('figcaption')).toBe(true);
2828
expect(wrapper.text()).toBe('Blah');
2929
});
30+
31+
it('renders a <figcaption> centered', () => {
32+
const slots = { default: '<p>Blah</p>' };
33+
const wrapper = shallowMount(FigureCaption, { slots, propsData: { centered: true } });
34+
expect(wrapper.classes()).toContain('centered');
35+
});
3036
});

tests/unit/components/ReplayableVideoAsset.spec.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { shallowMount } from '@vue/test-utils';
1212
import ReplayableVideoAsset from 'docc-render/components/ReplayableVideoAsset.vue';
1313
import VideoAsset from 'docc-render/components/VideoAsset.vue';
1414
import InlineReplayIcon from 'theme/components/Icons/InlineReplayIcon.vue';
15+
import PlayIcon from '@/components/Icons/PlayIcon.vue';
1516
import { flushPromises } from '../../../test-utils';
1617

1718
const variants = [{ traits: ['dark', '1x'], url: 'https://www.example.com/myvideo.mp4' }];
@@ -62,11 +63,12 @@ describe('ReplayableVideoAsset', () => {
6263
expect(replayButton.exists()).toBe(true);
6364
expect(replayButton.classes('visible')).toBe(false);
6465

65-
expect(replayButton.find('.replay-icon').is(InlineReplayIcon)).toBe(true);
66+
expect(replayButton.find('.replay-icon').is(PlayIcon)).toBe(true);
6667
const video = wrapper.find(VideoAsset);
6768
video.vm.$emit('ended');
6869

6970
expect(replayButton.classes('visible')).toBe(true);
71+
expect(wrapper.find('.replay-icon').is(InlineReplayIcon)).toBe(true);
7072

7173
// When the video is playing, the replay button should be hidden.
7274
replayButton.trigger('click');

0 commit comments

Comments
 (0)