fix(editor): Make search work for "rendered" display type (#16910)

This commit is contained in:
Suguru Inoue
2025-07-09 19:29:16 +02:00
committed by GitHub
parent 803f0f687b
commit f252a39197
8 changed files with 240 additions and 40 deletions

View File

@@ -1885,7 +1885,12 @@ defineExpose({ enterEditMode });
</Suspense>
<Suspense v-else-if="hasNodeRun && displayMode === 'ai'">
<LazyRunDataAi render-type="rendered" :compact="compact" :content="parsedAiContent" />
<LazyRunDataAi
render-type="rendered"
:compact="compact"
:content="parsedAiContent"
:search="search"
/>
</Suspense>
<Suspense v-else-if="(hasNodeRun || hasPreviewSchema) && isSchemaView">

View File

@@ -7,6 +7,10 @@ import {
type NodeConnectionType,
type Workflow,
} from 'n8n-workflow';
import { splitTextBySearch } from '@/utils/stringUtils';
import { escapeHtml } from 'xss';
import type MarkdownIt from 'markdown-it';
import { unescapeAll } from 'markdown-it/lib/common/utils';
export interface AIResult {
node: string;
@@ -198,3 +202,52 @@ export function getConsumedTokens(outputRun: IAiDataContent | undefined): LlmTok
return tokenUsage;
}
export function createHtmlFragmentWithSearchHighlight(
text: string,
search: string | undefined,
): string {
const escaped = escapeHtml(text);
return search
? splitTextBySearch(escaped, search)
.map((part) => (part.isMatched ? `<mark>${part.content}</mark>` : part.content))
.join('')
: escaped;
}
export function createSearchHighlightPlugin(search: string | undefined) {
return (md: MarkdownIt) => {
md.renderer.rules.text = (tokens, idx) =>
createHtmlFragmentWithSearchHighlight(tokens[idx].content, search);
md.renderer.rules.code_inline = (tokens, idx, _, __, slf) =>
`<code${slf.renderAttrs(tokens[idx])}>${createHtmlFragmentWithSearchHighlight(tokens[idx].content, search)}</code>`;
md.renderer.rules.code_block = (tokens, idx, _, __, slf) =>
`<pre${slf.renderAttrs(tokens[idx])}><code>${createHtmlFragmentWithSearchHighlight(tokens[idx].content, search)}</code></pre>\n`;
md.renderer.rules.fence = (tokens, idx, options, _, slf) => {
const token = tokens[idx];
const info = token.info ? unescapeAll(token.info).trim() : '';
let langName = '';
let langAttrs = '';
if (info) {
const arr = info.split(/(\s+)/g);
langName = arr[0];
langAttrs = arr.slice(2).join('');
}
const highlighted =
options.highlight?.(token.content, langName, langAttrs) ??
createHtmlFragmentWithSearchHighlight(token.content, search);
if (highlighted.indexOf('<pre') === 0) {
return highlighted + '\n';
}
return `<pre><code${slf.renderAttrs(token)}>${highlighted}</code></pre>\n`;
};
};
}

View File

@@ -46,6 +46,29 @@ describe('RunDataParsedAiContent', () => {
expect(rendered.getByText('markdown', { selector: 'em' })).toBeInTheDocument();
});
it('highlight matches to the search keyword in markdown', () => {
const rendered = renderComponent({
renderType: 'rendered',
content: [
{
raw: {},
parsedContent: {
type: 'markdown',
data: 'The **quick** brown fox jumps over the ~~lazy~~ dog',
parsed: true,
},
},
],
search: 'the',
});
const marks = rendered.container.querySelectorAll('mark');
expect(marks).toHaveLength(2);
expect(marks[0]).toHaveTextContent('The');
expect(marks[1]).toHaveTextContent('the');
});
it('renders AI content parsed as JSON', () => {
const rendered = renderComponent({
renderType: 'rendered',
@@ -71,4 +94,83 @@ describe('RunDataParsedAiContent', () => {
rendered.getByText('{ "key": "value" }', { selector: 'code', exact: false }),
).toBeInTheDocument();
});
it('highlight matches to the search keyword in inline code in markdown', () => {
const rendered = renderComponent({
renderType: 'rendered',
content: [
{
raw: {},
parsedContent: {
type: 'markdown',
data: 'The `quick brown fox` jumps over the lazy dog',
parsed: true,
},
},
],
search: 'fox',
});
const marks = rendered.container.querySelectorAll('mark');
expect(marks).toHaveLength(1);
expect(marks[0]).toHaveTextContent('fox');
});
it('highlight matches to the search keyword in a code block in markdown', () => {
const rendered = renderComponent({
renderType: 'rendered',
content: [
{
raw: {},
parsedContent: {
type: 'markdown',
data: 'Code:\n\n quickFox.jump({ over: lazyDog });\n',
parsed: true,
},
},
],
search: 'fox',
});
const marks = rendered.container.querySelectorAll('mark');
expect(marks).toHaveLength(1);
expect(marks[0]).toHaveTextContent('Fox');
});
it('highlight matches to the search keyword in fence syntax in markdown', () => {
const rendered = renderComponent({
renderType: 'rendered',
content: [
{
raw: {},
parsedContent: {
type: 'markdown',
data: '```\nquickFox.jump({ over: lazyDog });\n```',
parsed: true,
},
},
],
search: 'fox',
});
const marks = rendered.container.querySelectorAll('mark');
expect(marks).toHaveLength(1);
expect(marks[0]).toHaveTextContent('Fox');
});
it('highlight matches to the search keyword in raw data', () => {
const rendered = renderComponent({
renderType: 'rendered',
content: [{ raw: { key: 'value' }, parsedContent: null }],
search: 'key',
});
const marks = rendered.container.querySelectorAll('mark');
expect(marks).toHaveLength(1);
expect(marks[0]).toHaveTextContent('key');
});
});

View File

@@ -7,14 +7,18 @@ import { N8nIconButton } from '@n8n/design-system';
import { type IDataObject } from 'n8n-workflow';
import VueMarkdown from 'vue-markdown-render';
import hljs from 'highlight.js/lib/core';
import { computed } from 'vue';
import { createSearchHighlightPlugin } from '@/components/RunDataAi/utils';
const {
content,
compact = false,
renderType,
search,
} = defineProps<{
content: ParsedAiContent;
compact?: boolean;
search?: string;
renderType: 'rendered' | 'json';
}>();
@@ -22,6 +26,8 @@ const i18n = useI18n();
const clipboard = useClipboard();
const { showMessage } = useToast();
const vueMarkdownPlugins = computed(() => [createSearchHighlightPlugin(search)]);
function isJsonString(text: string) {
try {
JSON.parse(text);
@@ -39,7 +45,7 @@ const markdownOptions = {
} catch {}
}
return ''; // use external default escaping
return undefined; // use external default escaping
},
};
@@ -108,17 +114,20 @@ function onCopyToClipboard(object: IDataObject | IDataObject[]) {
:source="jsonToMarkdown(parsedContent.data as JsonMarkdown)"
:class="$style.markdown"
:options="markdownOptions"
:plugins="vueMarkdownPlugins"
/>
<VueMarkdown
v-else-if="parsedContent.type === 'markdown'"
:source="parsedContent.data"
:class="$style.markdown"
:options="markdownOptions"
:plugins="vueMarkdownPlugins"
/>
<p
<TextWithHighlights
v-else-if="parsedContent.type === 'text'"
:class="$style.runText"
v-text="parsedContent.data"
:content="String(parsedContent.data)"
:search="search"
/>
</template>
<!-- We weren't able to parse text or raw switch -->
@@ -131,7 +140,11 @@ function onCopyToClipboard(object: IDataObject | IDataObject[]) {
icon="files"
@click="onCopyToClipboard(raw)"
/>
<VueMarkdown :source="jsonToMarkdown(raw as JsonMarkdown)" :class="$style.markdown" />
<VueMarkdown
:source="jsonToMarkdown(raw as JsonMarkdown)"
:class="$style.markdown"
:plugins="vueMarkdownPlugins"
/>
</div>
</div>
</div>

View File

@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { splitTextBySearch } from '@/utils/stringUtils';
import type { GenericValue } from 'n8n-workflow';
import { computed } from 'vue';
@@ -7,26 +8,6 @@ const props = defineProps<{
search?: string;
}>();
const splitTextBySearch = (
text = '',
search = '',
): Array<{ tag: 'span' | 'mark'; content: string }> => {
if (!search) {
return [
{
tag: 'span',
content: text,
},
];
}
const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
const pattern = new RegExp(`(${escapeRegExp(search)})`, 'i');
const splitText = text.split(pattern);
return splitText.map((t) => ({ tag: pattern.test(t) ? 'mark' : 'span', content: t }));
};
const parts = computed(() => {
return props.search && typeof props.content === 'string'
? splitTextBySearch(props.content, props.search)
@@ -37,9 +18,9 @@ const parts = computed(() => {
<template>
<span v-if="parts.length && typeof props.content === 'string'">
<template v-for="(part, index) in parts">
<mark v-if="part.tag === 'mark' && part.content" :key="`mark-${index}`">{{
part.content
}}</mark>
<mark v-if="part.isMatched && part.content" :key="`mark-${index}`">
{{ part.content }}
</mark>
<span v-else-if="part.content" :key="`span-${index}`">{{ part.content }}</span>
</template>
</span>

View File

@@ -2762,17 +2762,11 @@ exports[`VirtualSchema.vue > should expand all nodes when searching 1`] = `
>
<!--v-if-->
<mark>
John
</mark>
<!--v-if-->
</span>
</div>
@@ -2900,17 +2894,11 @@ exports[`VirtualSchema.vue > should expand all nodes when searching 1`] = `
>
<!--v-if-->
<mark>
John
</mark>
<!--v-if-->
</span>
</div>

View File

@@ -0,0 +1,35 @@
import { describe, expect } from 'vitest';
import { splitTextBySearch } from './stringUtils';
describe(splitTextBySearch, () => {
it('should return one element with isMatched=false if search text is empty', () => {
expect(splitTextBySearch('The quick brown fox jumps over the lazy dog', '')).toEqual([
{ isMatched: false, content: 'The quick brown fox jumps over the lazy dog' },
]);
});
it('should split given text by matches to the search', () => {
expect(splitTextBySearch('The quick brown fox jumps over the lazy dog', 'quick')).toEqual([
{ isMatched: false, content: 'The ' },
{ isMatched: true, content: 'quick' },
{ isMatched: false, content: ' brown fox jumps over the lazy dog' },
]);
});
it('should match case insensitive', () => {
expect(splitTextBySearch('The quick brown fox jumps over the lazy dog', 'Quick')).toEqual([
{ isMatched: false, content: 'The ' },
{ isMatched: true, content: 'quick' },
{ isMatched: false, content: ' brown fox jumps over the lazy dog' },
]);
});
it('should match all occurrences', () => {
expect(splitTextBySearch('The quick brown fox jumps over the lazy dog', 'the')).toEqual([
{ isMatched: true, content: 'The' },
{ isMatched: false, content: ' quick brown fox jumps over ' },
{ isMatched: true, content: 'the' },
{ isMatched: false, content: ' lazy dog' },
]);
});
});

View File

@@ -0,0 +1,23 @@
/**
* Split given text by the search term
*
* @param text Text to split
* @param search The search term
* @returns An array containing splitted text, each containing text fragment and the match flag.
*/
export function splitTextBySearch(
text: string,
search: string,
): Array<{ isMatched: boolean; content: string }> {
if (!search) {
return [{ isMatched: false, content: text }];
}
const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
const pattern = new RegExp(`(${escapeRegExp(search)})`, 'i');
return text
.split(pattern)
.map((part) => ({ isMatched: pattern.test(part), content: part }))
.filter((part) => part.content !== '');
}