feat(Sticky Note Node): Support YouTube video embeds on Sticky notes (#16484)

This commit is contained in:
Jaakko Husso
2025-06-23 15:31:31 +03:00
committed by GitHub
parent 2efd21e083
commit f4d0b9f796
6 changed files with 194 additions and 2 deletions

View File

@@ -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,
};

View File

@@ -85,5 +85,22 @@ describe('components', () => {
'&lt;input type=“text” data-testid=“text-input” value=“Something”/&gt;',
);
});
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>',
);
});
});
});

View File

@@ -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 {

View File

@@ -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>',
);
});
});

View File

@@ -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>`;
};
};

View File

@@ -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>