mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(Sticky Note Node): Support YouTube video embeds on Sticky notes (#16484)
This commit is contained in:
@@ -67,3 +67,19 @@ WithCheckboxes.args = {
|
||||
content: '__TODO__\n- [ ] Buy milk\n- [X] Buy socks\n',
|
||||
loading: false,
|
||||
};
|
||||
|
||||
const TemplateWithYoutubeEmbed: StoryFn = (args, { argTypes }) => ({
|
||||
setup: () => ({ args }),
|
||||
props: Object.keys(argTypes),
|
||||
components: {
|
||||
N8nMarkdown,
|
||||
},
|
||||
template: '<n8n-markdown v-bind="args"></n8n-markdown>',
|
||||
});
|
||||
|
||||
export const WithYoutubeEmbed = TemplateWithYoutubeEmbed.bind({});
|
||||
WithYoutubeEmbed.args = {
|
||||
content:
|
||||
"## I'm markdown \n**Please check** this out. [Guide](https://docs.n8n.io/workflows/sticky-notes/)\n@[youtube](ZCuL2e4zC_4)\n",
|
||||
loading: false,
|
||||
};
|
||||
|
||||
@@ -85,5 +85,22 @@ describe('components', () => {
|
||||
'<input type=“text” data-testid=“text-input” value=“Something”/>',
|
||||
);
|
||||
});
|
||||
|
||||
it('should render YouTube embed player', () => {
|
||||
const wrapper = render(N8nMarkdown, {
|
||||
global: {
|
||||
directives: {
|
||||
n8nHtml,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
content: '@[youtube](ZCuL2e4zC_4)\n',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toContain(
|
||||
'<p><iframe width="100%" src="https://www.youtube-nocookie.com/embed/ZCuL2e4zC_4" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen=""></iframe></p>',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import markdownTaskLists from 'markdown-it-task-lists';
|
||||
import { computed, ref } from 'vue';
|
||||
import xss, { friendlyAttrValue, whiteList } from 'xss';
|
||||
|
||||
import { markdownYoutubeEmbed, YOUTUBE_EMBED_SRC_REGEX, type YoutubeEmbedConfig } from './youtube';
|
||||
import { escapeMarkdown, toggleCheckbox } from '../../utils/markdown';
|
||||
import N8nLoading from '../N8nLoading';
|
||||
|
||||
@@ -19,6 +20,7 @@ interface Options {
|
||||
markdown: MarkdownOptions;
|
||||
linkAttributes: markdownLink.Config;
|
||||
tasklists: markdownTaskLists.Config;
|
||||
youtube: YoutubeEmbedConfig;
|
||||
}
|
||||
|
||||
interface MarkdownProps {
|
||||
@@ -58,6 +60,7 @@ const props = withDefaults(defineProps<MarkdownProps>(), {
|
||||
label: true,
|
||||
labelAfter: true,
|
||||
},
|
||||
youtube: {},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -67,11 +70,22 @@ const { options } = props;
|
||||
const md = new Markdown(options.markdown)
|
||||
.use(markdownLink, options.linkAttributes)
|
||||
.use(markdownEmoji)
|
||||
.use(markdownTaskLists, options.tasklists);
|
||||
.use(markdownTaskLists, options.tasklists)
|
||||
.use(markdownYoutubeEmbed, options.youtube);
|
||||
|
||||
const xssWhiteList = {
|
||||
...whiteList,
|
||||
label: ['class', 'for'],
|
||||
iframe: [
|
||||
'width',
|
||||
'height',
|
||||
'src',
|
||||
'title',
|
||||
'frameborder',
|
||||
'allow',
|
||||
'referrerpolicy',
|
||||
'allowfullscreen',
|
||||
],
|
||||
};
|
||||
|
||||
const htmlContent = computed(() => {
|
||||
@@ -112,6 +126,19 @@ const htmlContent = computed(() => {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
if (tag === 'iframe') {
|
||||
if (name === 'src') {
|
||||
// Only allow YouTube as src for iframes embeds
|
||||
if (YOUTUBE_EMBED_SRC_REGEX.test(value)) {
|
||||
return `src=${friendlyAttrValue(value)}`;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Return nothing, means keep the default handling measure
|
||||
return;
|
||||
},
|
||||
@@ -195,15 +222,18 @@ const onCheckboxChange = (index: number) => {
|
||||
|
||||
<template>
|
||||
<div class="n8n-markdown">
|
||||
<!-- Needed to support YouTube player embeds. HTML rendered here is sanitized. -->
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
v-if="!loading"
|
||||
ref="editor"
|
||||
v-n8n-html="htmlContent"
|
||||
:class="$style[theme]"
|
||||
@click="onClick"
|
||||
@mousedown="onMouseDown"
|
||||
@change="onChange"
|
||||
v-html="htmlContent"
|
||||
/>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<div v-else :class="$style.markdown">
|
||||
<div v-for="(_, index) in loadingBlocks" :key="index">
|
||||
<N8nLoading :loading="loading" :rows="loadingRows" animated variant="p" />
|
||||
@@ -389,6 +419,10 @@ input[type='checkbox'] + label {
|
||||
padding: var(--spacing-s);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
iframe {
|
||||
aspect-ratio: 16/9 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.spacer {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import Markdown from 'markdown-it';
|
||||
|
||||
import { markdownYoutubeEmbed, type YoutubeEmbedConfig } from './youtube';
|
||||
|
||||
describe('markdownYoutubeEmbed', () => {
|
||||
it('should render YouTube embed iframe with default options', () => {
|
||||
const options: YoutubeEmbedConfig = {};
|
||||
const md = new Markdown().use(markdownYoutubeEmbed, options);
|
||||
|
||||
const result = md.render('@[youtube](ZCuL2e4zC_4)');
|
||||
expect(result).toContain(
|
||||
'<p><iframe width="100%" src="https://www.youtube-nocookie.com/embed/ZCuL2e4zC_4" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe></p>',
|
||||
);
|
||||
});
|
||||
|
||||
it('should render YouTube embed iframe with custom options', () => {
|
||||
const options: YoutubeEmbedConfig = {
|
||||
width: 640,
|
||||
height: 360,
|
||||
title: 'Test Title',
|
||||
};
|
||||
const md = new Markdown().use(markdownYoutubeEmbed, options);
|
||||
|
||||
const result = md.render('@[youtube](ZCuL2e4zC_4)');
|
||||
expect(result).toContain(
|
||||
'<p><iframe width="640" height="360" src="https://www.youtube-nocookie.com/embed/ZCuL2e4zC_4" title="Test Title" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe></p>',
|
||||
);
|
||||
});
|
||||
|
||||
it('should render YouTube embed iframe with cookies', () => {
|
||||
const options: YoutubeEmbedConfig = {
|
||||
width: 640,
|
||||
height: 360,
|
||||
title: 'Test Title',
|
||||
nocookie: false,
|
||||
};
|
||||
const md = new Markdown().use(markdownYoutubeEmbed, options);
|
||||
|
||||
const result = md.render('@[youtube](ZCuL2e4zC_4)');
|
||||
expect(result).toContain(
|
||||
'<p><iframe width="640" height="360" src="https://www.youtube.com/embed/ZCuL2e4zC_4" title="Test Title" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe></p>',
|
||||
);
|
||||
});
|
||||
|
||||
it('should render sticky HTML content with YouTube embed', () => {
|
||||
const options: YoutubeEmbedConfig = {};
|
||||
const md = new Markdown().use(markdownYoutubeEmbed, options);
|
||||
|
||||
const result = md.render(
|
||||
"## I'm a note \n**Double click** to edit me. [Guide](https://docs.n8n.io/workflows/sticky-notes/)\n@[youtube](ZCuL2e4zC_4)",
|
||||
);
|
||||
expect(result).toContain(
|
||||
"<h2>I'm a note</h2>\n" +
|
||||
'<p><strong>Double click</strong> to edit me. <a href="https://docs.n8n.io/workflows/sticky-notes/">Guide</a>\n' +
|
||||
'<iframe width="100%" src="https://www.youtube-nocookie.com/embed/ZCuL2e4zC_4" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe></p>',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type MarkdownIt from 'markdown-it';
|
||||
import type { Token } from 'markdown-it';
|
||||
import type StateInline from 'markdown-it/lib/rules_inline/state_inline';
|
||||
|
||||
// Detects custom markdown tags in format "@[youtube](<video_id>)"
|
||||
const YOUTUBE_TAG_REGEX = /@\[youtube]\(([\w-]{11}(?:\?.*)?)\)/im;
|
||||
|
||||
export const YOUTUBE_EMBED_SRC_REGEX =
|
||||
/^https:\/\/(?:www\.)?(youtube\.com|youtube-nocookie\.com)\/embed\/[\w-]{11}(?:\?.*)?$/i;
|
||||
|
||||
export interface YoutubeEmbedConfig {
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
title?: string;
|
||||
nocookie?: boolean;
|
||||
}
|
||||
|
||||
export const markdownYoutubeEmbed = (md: MarkdownIt, options: YoutubeEmbedConfig) => {
|
||||
const opts = {
|
||||
width: '100%',
|
||||
title: 'YouTube video player',
|
||||
nocookie: true,
|
||||
...options,
|
||||
};
|
||||
|
||||
const parser = (state: StateInline, silent: boolean): boolean => {
|
||||
const { pos, src } = state;
|
||||
|
||||
// Must start with @
|
||||
if (src.charCodeAt(pos) !== 0x40 /* @ */) return false;
|
||||
|
||||
const match = YOUTUBE_TAG_REGEX.exec(src.slice(pos));
|
||||
if (!match) return false;
|
||||
|
||||
if (!silent) {
|
||||
const token = state.push('youtube_embed', '', 0);
|
||||
token.meta = { videoId: match[1] };
|
||||
}
|
||||
|
||||
state.pos += match[0].length;
|
||||
return true;
|
||||
};
|
||||
|
||||
const youtubeUrl = opts.nocookie
|
||||
? 'https://www.youtube-nocookie.com/embed/'
|
||||
: 'https://www.youtube.com/embed/';
|
||||
|
||||
md.inline.ruler.before('link', 'youtube_embed', parser);
|
||||
md.renderer.rules.youtube_embed = (tokens: Token[], idx: number): string => {
|
||||
const { videoId } = tokens[idx].meta as { videoId: string };
|
||||
|
||||
const parameters = [
|
||||
`width="${opts.width}"`,
|
||||
...(opts.height ? [`height="${opts.height}"`] : []),
|
||||
`src="${youtubeUrl}${videoId}"`,
|
||||
`title="${md.utils.escapeHtml(opts.title)}"`,
|
||||
'frameborder="0"',
|
||||
'allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"',
|
||||
'referrerpolicy="strict-origin-when-cross-origin"',
|
||||
'allowfullscreen',
|
||||
];
|
||||
|
||||
return `<iframe ${parameters.join(' ')}></iframe>`;
|
||||
};
|
||||
};
|
||||
@@ -12,6 +12,8 @@ exports[`CanvasNodeStickyNote > should render node correctly 1`] = `
|
||||
<div class="n8n-sticky sticky clickable color-1 sticky" style="height: 180px; width: 240px;" data-test-id="sticky">
|
||||
<div class="wrapper">
|
||||
<div class="n8n-markdown">
|
||||
<!-- Needed to support YouTube player embeds. HTML rendered here is sanitized. -->
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div class="sticky"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user