mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +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',
|
content: '__TODO__\n- [ ] Buy milk\n- [X] Buy socks\n',
|
||||||
loading: false,
|
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”/>',
|
'<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 { computed, ref } from 'vue';
|
||||||
import xss, { friendlyAttrValue, whiteList } from 'xss';
|
import xss, { friendlyAttrValue, whiteList } from 'xss';
|
||||||
|
|
||||||
|
import { markdownYoutubeEmbed, YOUTUBE_EMBED_SRC_REGEX, type YoutubeEmbedConfig } from './youtube';
|
||||||
import { escapeMarkdown, toggleCheckbox } from '../../utils/markdown';
|
import { escapeMarkdown, toggleCheckbox } from '../../utils/markdown';
|
||||||
import N8nLoading from '../N8nLoading';
|
import N8nLoading from '../N8nLoading';
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ interface Options {
|
|||||||
markdown: MarkdownOptions;
|
markdown: MarkdownOptions;
|
||||||
linkAttributes: markdownLink.Config;
|
linkAttributes: markdownLink.Config;
|
||||||
tasklists: markdownTaskLists.Config;
|
tasklists: markdownTaskLists.Config;
|
||||||
|
youtube: YoutubeEmbedConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MarkdownProps {
|
interface MarkdownProps {
|
||||||
@@ -58,6 +60,7 @@ const props = withDefaults(defineProps<MarkdownProps>(), {
|
|||||||
label: true,
|
label: true,
|
||||||
labelAfter: true,
|
labelAfter: true,
|
||||||
},
|
},
|
||||||
|
youtube: {},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -67,11 +70,22 @@ const { options } = props;
|
|||||||
const md = new Markdown(options.markdown)
|
const md = new Markdown(options.markdown)
|
||||||
.use(markdownLink, options.linkAttributes)
|
.use(markdownLink, options.linkAttributes)
|
||||||
.use(markdownEmoji)
|
.use(markdownEmoji)
|
||||||
.use(markdownTaskLists, options.tasklists);
|
.use(markdownTaskLists, options.tasklists)
|
||||||
|
.use(markdownYoutubeEmbed, options.youtube);
|
||||||
|
|
||||||
const xssWhiteList = {
|
const xssWhiteList = {
|
||||||
...whiteList,
|
...whiteList,
|
||||||
label: ['class', 'for'],
|
label: ['class', 'for'],
|
||||||
|
iframe: [
|
||||||
|
'width',
|
||||||
|
'height',
|
||||||
|
'src',
|
||||||
|
'title',
|
||||||
|
'frameborder',
|
||||||
|
'allow',
|
||||||
|
'referrerpolicy',
|
||||||
|
'allowfullscreen',
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const htmlContent = computed(() => {
|
const htmlContent = computed(() => {
|
||||||
@@ -112,6 +126,19 @@ const htmlContent = computed(() => {
|
|||||||
return '';
|
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 nothing, means keep the default handling measure
|
||||||
return;
|
return;
|
||||||
},
|
},
|
||||||
@@ -195,15 +222,18 @@ const onCheckboxChange = (index: number) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="n8n-markdown">
|
<div class="n8n-markdown">
|
||||||
|
<!-- Needed to support YouTube player embeds. HTML rendered here is sanitized. -->
|
||||||
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
<div
|
<div
|
||||||
v-if="!loading"
|
v-if="!loading"
|
||||||
ref="editor"
|
ref="editor"
|
||||||
v-n8n-html="htmlContent"
|
|
||||||
:class="$style[theme]"
|
:class="$style[theme]"
|
||||||
@click="onClick"
|
@click="onClick"
|
||||||
@mousedown="onMouseDown"
|
@mousedown="onMouseDown"
|
||||||
@change="onChange"
|
@change="onChange"
|
||||||
|
v-html="htmlContent"
|
||||||
/>
|
/>
|
||||||
|
<!-- eslint-enable vue/no-v-html -->
|
||||||
<div v-else :class="$style.markdown">
|
<div v-else :class="$style.markdown">
|
||||||
<div v-for="(_, index) in loadingBlocks" :key="index">
|
<div v-for="(_, index) in loadingBlocks" :key="index">
|
||||||
<N8nLoading :loading="loading" :rows="loadingRows" animated variant="p" />
|
<N8nLoading :loading="loading" :rows="loadingRows" animated variant="p" />
|
||||||
@@ -389,6 +419,10 @@ input[type='checkbox'] + label {
|
|||||||
padding: var(--spacing-s);
|
padding: var(--spacing-s);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
iframe {
|
||||||
|
aspect-ratio: 16/9 auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.spacer {
|
.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="n8n-sticky sticky clickable color-1 sticky" style="height: 180px; width: 240px;" data-test-id="sticky">
|
||||||
<div class="wrapper">
|
<div class="wrapper">
|
||||||
<div class="n8n-markdown">
|
<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 class="sticky"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user