mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
fix(editor): Chat UI feedback (no-changelog) (#22678)
This commit is contained in:
+2
-1
@@ -33,6 +33,7 @@ defineProps<{
|
||||
menu: Array<Item | Divider>;
|
||||
disabled?: boolean;
|
||||
teleport?: boolean;
|
||||
submenuClass?: string;
|
||||
}>();
|
||||
|
||||
const menuRef = ref<typeof ElMenu | null>(null);
|
||||
@@ -91,7 +92,7 @@ defineExpose({
|
||||
:index="ROOT_MENU_INDEX"
|
||||
:class="$style.trigger"
|
||||
:popper-offset="-10"
|
||||
:popper-class="$style.submenu"
|
||||
:popper-class="[$style.submenu, submenuClass ?? ''].join(' ')"
|
||||
:disabled
|
||||
:teleported="teleport"
|
||||
>
|
||||
|
||||
+21
-1
@@ -14,7 +14,7 @@ import { N8nButton, N8nHeading, N8nInput, N8nInputLabel, N8nSpinner } from '@n8n
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { assert } from '@n8n/utils/assert';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue';
|
||||
import type { CredentialsMap } from '../chat.types';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import ToolsSelector from './ToolsSelector.vue';
|
||||
@@ -48,7 +48,9 @@ const systemPrompt = ref('');
|
||||
const selectedModel = ref<ChatHubBaseLLMModel | null>(null);
|
||||
const isSaving = ref(false);
|
||||
const isDeleting = ref(false);
|
||||
const isOpened = ref(false);
|
||||
const tools = ref<INode[]>([]);
|
||||
const nameInputRef = useTemplateRef('nameInput');
|
||||
|
||||
const agentSelectedCredentials = ref<CredentialsMap>({});
|
||||
const credentialIdForSelectedModelProvider = computed(
|
||||
@@ -91,6 +93,10 @@ const canSelectTools = computed(
|
||||
() => selectedAgent.value?.metadata.capabilities.functionCalling ?? false,
|
||||
);
|
||||
|
||||
modalBus.value.once('opened', () => {
|
||||
isOpened.value = true;
|
||||
});
|
||||
|
||||
// If the agent doesn't support tools anymore, reset tools
|
||||
watch(
|
||||
selectedAgent,
|
||||
@@ -120,6 +126,18 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
[isOpened, isLoadingAgent, nameInputRef],
|
||||
async ([opened, isLoading, name]) => {
|
||||
if (opened && !isLoading) {
|
||||
// autofocus attribute doesn't work in modal
|
||||
// https://github.com/element-plus/element-plus/issues/15250
|
||||
name?.focus();
|
||||
}
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
);
|
||||
|
||||
function onCredentialSelected(provider: ChatHubProvider, credentialId: string | null) {
|
||||
agentSelectedCredentials.value = {
|
||||
...agentSelectedCredentials.value,
|
||||
@@ -249,6 +267,7 @@ function onSelectTools() {
|
||||
:required="true"
|
||||
>
|
||||
<N8nInput
|
||||
ref="nameInput"
|
||||
id="agent-name"
|
||||
v-model="name"
|
||||
:placeholder="i18n.baseText('chatHub.agent.editor.name.placeholder')"
|
||||
@@ -374,6 +393,7 @@ function onSelectTools() {
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.footer {
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { useClipboard } from '@/app/composables/useClipboard';
|
||||
import ChatAgentAvatar from '@/features/ai/chatHub/components/ChatAgentAvatar.vue';
|
||||
import ChatTypingIndicator from '@/features/ai/chatHub/components/ChatTypingIndicator.vue';
|
||||
import { useChatHubMarkdownOptions } from '@/features/ai/chatHub/composables/useChatHubMarkdownOptions';
|
||||
import type { ChatMessageId, ChatModelDto } from '@n8n/api-types';
|
||||
import { N8nButton, N8nIcon, N8nInput } from '@n8n/design-system';
|
||||
import { useSpeechSynthesis } from '@vueuse/core';
|
||||
import type MarkdownIt from 'markdown-it';
|
||||
import markdownLink from 'markdown-it-link-attributes';
|
||||
import { computed, onBeforeMount, ref, useTemplateRef, watch } from 'vue';
|
||||
import { computed, onBeforeMount, ref, useCssModule, useTemplateRef, watch } from 'vue';
|
||||
import VueMarkdown from 'vue-markdown-render';
|
||||
import type { ChatMessage } from '../chat.types';
|
||||
import ChatMessageActions from './ChatMessageActions.vue';
|
||||
@@ -19,6 +16,7 @@ import { buildChatAttachmentUrl } from '@/features/ai/chatHub/chat.api';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import CopyButton from '@/features/ai/chatHub/components/CopyButton.vue';
|
||||
|
||||
const { message, compact, isEditing, isStreaming, minHeight, cachedAgentDisplayName } =
|
||||
defineProps<{
|
||||
@@ -41,16 +39,16 @@ const emit = defineEmits<{
|
||||
switchAlternative: [messageId: ChatMessageId];
|
||||
}>();
|
||||
|
||||
const clipboard = useClipboard();
|
||||
const chatStore = useChatStore();
|
||||
const rootStore = useRootStore();
|
||||
const { isCtrlKeyPressed } = useDeviceSupport();
|
||||
const i18n = useI18n();
|
||||
const styles = useCssModule();
|
||||
|
||||
const editedText = ref('');
|
||||
const hoveredCodeBlockActions = ref<HTMLElement | null>(null);
|
||||
const textareaRef = useTemplateRef('textarea');
|
||||
const justCopied = ref(false);
|
||||
const { markdownOptions, forceReRenderKey } = useChatHubMarkdownOptions();
|
||||
const markdown = useChatHubMarkdownOptions(styles.codeBlockActions);
|
||||
const messageContent = computed(() => message.content);
|
||||
|
||||
const speech = useSpeechSynthesis(messageContent, {
|
||||
@@ -81,14 +79,11 @@ const attachments = computed(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
async function handleCopy() {
|
||||
const text = message.content;
|
||||
await clipboard.copy(text);
|
||||
justCopied.value = true;
|
||||
setTimeout(() => {
|
||||
justCopied.value = false;
|
||||
}, 1000);
|
||||
}
|
||||
const hoveredCodeBlockContent = computed(() => {
|
||||
const idx = hoveredCodeBlockActions.value?.getAttribute('data-markdown-token-idx');
|
||||
|
||||
return idx ? markdown.codeBlockContents.value?.get(idx) : undefined;
|
||||
});
|
||||
|
||||
function handleEdit() {
|
||||
emit('startEdit');
|
||||
@@ -133,14 +128,18 @@ function handleSwitchAlternative(messageId: ChatMessageId) {
|
||||
emit('switchAlternative', messageId);
|
||||
}
|
||||
|
||||
const linksNewTabPlugin = (vueMarkdownItInstance: MarkdownIt) => {
|
||||
vueMarkdownItInstance.use(markdownLink, {
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
rel: 'noopener',
|
||||
},
|
||||
});
|
||||
};
|
||||
function handleMouseMove(e: MouseEvent | FocusEvent) {
|
||||
const container =
|
||||
e.target instanceof HTMLElement || e.target instanceof SVGElement
|
||||
? e.target.closest('pre')?.querySelector(`.${styles.codeBlockActions}`)
|
||||
: null;
|
||||
|
||||
hoveredCodeBlockActions.value = container instanceof HTMLElement ? container : null;
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
hoveredCodeBlockActions.value = null;
|
||||
}
|
||||
|
||||
// Watch for isEditing prop changes to initialize edit mode
|
||||
watch(
|
||||
@@ -219,7 +218,11 @@ onBeforeMount(() => {
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div :class="[$style.chatMessage, { [$style.errorMessage]: message.status === 'error' }]">
|
||||
<div
|
||||
:class="[$style.chatMessage, { [$style.errorMessage]: message.status === 'error' }]"
|
||||
@mousemove="handleMouseMove"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<div v-if="attachments.length > 0" :class="$style.attachments">
|
||||
<ChatFile
|
||||
v-for="(attachment, index) in attachments"
|
||||
@@ -232,27 +235,25 @@ onBeforeMount(() => {
|
||||
<div v-if="message.type === 'human'">{{ message.content }}</div>
|
||||
<VueMarkdown
|
||||
v-else
|
||||
:key="forceReRenderKey"
|
||||
:key="markdown.forceReRenderKey"
|
||||
:class="[$style.chatMessageMarkdown, 'chat-message-markdown']"
|
||||
:source="
|
||||
message.status === 'error' && !message.content
|
||||
? i18n.baseText('chatHub.message.error.unknown')
|
||||
: message.content
|
||||
"
|
||||
:options="markdownOptions"
|
||||
:plugins="[linksNewTabPlugin]"
|
||||
:options="markdown.options"
|
||||
:plugins="markdown.plugins.value"
|
||||
/>
|
||||
</div>
|
||||
<ChatTypingIndicator v-if="isStreaming" :class="$style.typingIndicator" />
|
||||
<ChatMessageActions
|
||||
v-else
|
||||
:just-copied="justCopied"
|
||||
:is-speech-synthesis-available="speech.isSupported.value"
|
||||
:is-speaking="speech.isPlaying.value"
|
||||
:class="$style.actions"
|
||||
:message="message"
|
||||
:alternatives="message.alternatives"
|
||||
@copy="handleCopy"
|
||||
@edit="handleEdit"
|
||||
@regenerate="handleRegenerate"
|
||||
@read-aloud="handleReadAloud"
|
||||
@@ -260,6 +261,12 @@ onBeforeMount(() => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Teleport
|
||||
v-if="hoveredCodeBlockActions && hoveredCodeBlockContent"
|
||||
:to="hoveredCodeBlockActions"
|
||||
>
|
||||
<CopyButton :content="hoveredCodeBlockContent" />
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -400,6 +407,22 @@ onBeforeMount(() => {
|
||||
padding: var(--chat--spacing);
|
||||
background: var(--chat--message--pre--background);
|
||||
border-radius: var(--chat--border-radius);
|
||||
position: relative;
|
||||
|
||||
code:last-of-type {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
& .codeBlockActions {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
margin: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
& ~ pre {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
|
||||
+3
-21
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { VIEWS } from '@/app/constants';
|
||||
import type { ChatMessage } from '@/features/ai/chatHub/chat.types';
|
||||
import CopyButton from '@/features/ai/chatHub/components/CopyButton.vue';
|
||||
import type { ChatMessageId } from '@n8n/api-types';
|
||||
import { N8nIconButton, N8nLink, N8nText, N8nTooltip } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
@@ -10,8 +11,7 @@ import { useRouter } from 'vue-router';
|
||||
const i18n = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const { justCopied, message, alternatives, isSpeaking, isSpeechSynthesisAvailable } = defineProps<{
|
||||
justCopied: boolean;
|
||||
const { message, alternatives, isSpeaking, isSpeechSynthesisAvailable } = defineProps<{
|
||||
message: ChatMessage;
|
||||
alternatives: ChatMessageId[];
|
||||
isSpeechSynthesisAvailable: boolean;
|
||||
@@ -19,17 +19,12 @@ const { justCopied, message, alternatives, isSpeaking, isSpeechSynthesisAvailabl
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
copy: [];
|
||||
edit: [];
|
||||
regenerate: [];
|
||||
switchAlternative: [messageId: ChatMessageId];
|
||||
readAloud: [];
|
||||
}>();
|
||||
|
||||
const copyTooltip = computed(() => {
|
||||
return justCopied ? i18n.baseText('generic.copied') : i18n.baseText('generic.copy');
|
||||
});
|
||||
|
||||
const currentAlternativeIndex = computed(() => {
|
||||
return alternatives.findIndex((id) => id === message.id);
|
||||
});
|
||||
@@ -44,10 +39,6 @@ const executionUrl = computed(() => {
|
||||
return undefined;
|
||||
});
|
||||
|
||||
function handleCopy() {
|
||||
emit('copy');
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
emit('edit');
|
||||
}
|
||||
@@ -63,16 +54,7 @@ function handleReadAloud() {
|
||||
|
||||
<template>
|
||||
<div :class="$style.actions">
|
||||
<N8nTooltip placement="bottom" :show-after="300">
|
||||
<N8nIconButton
|
||||
:icon="justCopied ? 'check' : 'copy'"
|
||||
type="tertiary"
|
||||
size="medium"
|
||||
text
|
||||
@click="handleCopy"
|
||||
/>
|
||||
<template #content>{{ copyTooltip }}</template>
|
||||
</N8nTooltip>
|
||||
<CopyButton :content="message.content" />
|
||||
<N8nTooltip
|
||||
v-if="isSpeechSynthesisAvailable && message.type === 'ai'"
|
||||
placement="bottom"
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useClipboard } from '@/app/composables/useClipboard';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { N8nIconButton, N8nTooltip } from '@n8n/design-system';
|
||||
|
||||
const { content } = defineProps<{ content: string }>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const clipboard = useClipboard();
|
||||
const justCopied = ref(false);
|
||||
const copyTooltip = computed(() => {
|
||||
return justCopied.value ? i18n.baseText('generic.copied') : i18n.baseText('generic.copy');
|
||||
});
|
||||
|
||||
async function handleCopy() {
|
||||
await clipboard.copy(content);
|
||||
justCopied.value = true;
|
||||
setTimeout(() => {
|
||||
justCopied.value = false;
|
||||
}, 1000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nTooltip placement="bottom" :show-after="300">
|
||||
<N8nIconButton
|
||||
:icon="justCopied ? 'check' : 'copy'"
|
||||
type="tertiary"
|
||||
size="medium"
|
||||
text
|
||||
:class="$style.button"
|
||||
tabindex="0"
|
||||
@click="handleCopy"
|
||||
/>
|
||||
<template #content>{{ copyTooltip }}</template>
|
||||
</N8nTooltip>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.button {
|
||||
& g,
|
||||
& path {
|
||||
color: var(--color--text--tint-1);
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -42,6 +42,8 @@ import { useProjectsStore } from '@/features/collaboration/projects/projects.sto
|
||||
import { truncateBeforeLast } from '@n8n/utils';
|
||||
|
||||
const NEW_AGENT_MENU_ID = 'agent::new';
|
||||
const MAX_AGENT_NAME_CHARS = 30;
|
||||
const MAX_AGENT_NAME_CHARS_MENU = 45;
|
||||
|
||||
const {
|
||||
selectedAgent,
|
||||
@@ -97,15 +99,20 @@ const isCredentialsMissing = computed(
|
||||
|
||||
const menu = computed(() => {
|
||||
const menuItems: (typeof N8nNavigationDropdown)['menu'] = [];
|
||||
const fullNamesMap: Record<string, string> = {};
|
||||
|
||||
if (includeCustomAgents) {
|
||||
const customAgents = isLoading.value
|
||||
? []
|
||||
: [...agents.value['custom-agent'].models, ...agents.value['n8n'].models].map((agent) => ({
|
||||
id: stringifyModel(agent.model),
|
||||
title: agent.name,
|
||||
disabled: false,
|
||||
}));
|
||||
: [...agents.value['custom-agent'].models, ...agents.value['n8n'].models].map((agent) => {
|
||||
const id = stringifyModel(agent.model);
|
||||
fullNamesMap[id] = agent.name;
|
||||
return {
|
||||
id,
|
||||
title: truncateBeforeLast(agent.name, MAX_AGENT_NAME_CHARS_MENU),
|
||||
disabled: false,
|
||||
};
|
||||
});
|
||||
|
||||
menuItems.push({
|
||||
id: 'custom-agents',
|
||||
@@ -200,11 +207,16 @@ const menu = computed(() => {
|
||||
(m) => 'model' in agent.model && m.model === agent.model.model,
|
||||
),
|
||||
)
|
||||
.map<ComponentProps<typeof N8nNavigationDropdown>['menu'][number]>((agent) => ({
|
||||
id: stringifyModel(agent.model),
|
||||
title: agent.name,
|
||||
disabled: false,
|
||||
}))
|
||||
.map<ComponentProps<typeof N8nNavigationDropdown>['menu'][number]>((agent) => {
|
||||
const id = stringifyModel(agent.model);
|
||||
fullNamesMap[id] = agent.name;
|
||||
return {
|
||||
id,
|
||||
title: truncateBeforeLast(agent.name, MAX_AGENT_NAME_CHARS_MENU),
|
||||
disabled: false,
|
||||
};
|
||||
})
|
||||
.filter((item, index, self) => self.findIndex((i) => i.id === item.id) === index)
|
||||
: error
|
||||
? [{ id: `${provider}::error`, value: null, disabled: true, title: error }]
|
||||
: [];
|
||||
@@ -232,7 +244,7 @@ const menu = computed(() => {
|
||||
});
|
||||
}
|
||||
|
||||
return menuItems;
|
||||
return { items: menuItems, fullNames: fullNamesMap };
|
||||
});
|
||||
|
||||
const selectedLabel = computed(
|
||||
@@ -347,7 +359,13 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nNavigationDropdown ref="dropdownRef" :menu="menu" teleport @select="onSelect">
|
||||
<N8nNavigationDropdown
|
||||
ref="dropdownRef"
|
||||
:submenu-class="$style.component"
|
||||
:menu="menu.items"
|
||||
teleport
|
||||
@select="onSelect"
|
||||
>
|
||||
<template #item-icon="{ item }">
|
||||
<CredentialIcon
|
||||
v-if="item.id in PROVIDER_CREDENTIAL_TYPE_MAP"
|
||||
@@ -358,7 +376,7 @@ defineExpose({
|
||||
<N8nAvatar
|
||||
v-else-if="item.id.startsWith('n8n::') || item.id.startsWith('custom-agent::')"
|
||||
:class="$style.avatarIcon"
|
||||
:first-name="item.title"
|
||||
:first-name="menu.fullNames[item.id] || item.title"
|
||||
size="xsmall"
|
||||
/>
|
||||
</template>
|
||||
@@ -372,10 +390,10 @@ defineExpose({
|
||||
/>
|
||||
<div :class="$style.selected">
|
||||
<div>
|
||||
{{ truncateBeforeLast(selectedLabel, 30) }}
|
||||
{{ truncateBeforeLast(selectedLabel, MAX_AGENT_NAME_CHARS) }}
|
||||
</div>
|
||||
<N8nText v-if="credentialsName" size="xsmall" color="text-light">
|
||||
{{ truncateBeforeLast(credentialsName, 30) }}
|
||||
{{ truncateBeforeLast(credentialsName, MAX_AGENT_NAME_CHARS) }}
|
||||
</N8nText>
|
||||
<N8nText v-else-if="isCredentialsMissing" size="xsmall" color="danger">
|
||||
<N8nIcon
|
||||
@@ -392,6 +410,13 @@ defineExpose({
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.component {
|
||||
& :global(.el-popper) {
|
||||
/* Enforce via text truncation instead */
|
||||
max-width: unset !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdownButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+42
-4
@@ -1,6 +1,8 @@
|
||||
/* eslint-disable @typescript-eslint/consistent-type-imports */
|
||||
import { type HLJSApi } from 'highlight.js';
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import type MarkdownIt from 'markdown-it';
|
||||
import markdownLink from 'markdown-it-link-attributes';
|
||||
|
||||
let hljsInstance: HLJSApi | undefined;
|
||||
let asyncImport:
|
||||
@@ -11,10 +13,11 @@ let asyncImport:
|
||||
| { status: 'uninitialized' }
|
||||
| { status: 'done' } = { status: 'uninitialized' };
|
||||
|
||||
export function useChatHubMarkdownOptions() {
|
||||
export function useChatHubMarkdownOptions(codeBlockActionsClassName: string) {
|
||||
const forceReRenderKey = ref(0);
|
||||
const codeBlockContents = ref<Map<string, string>>();
|
||||
|
||||
const markdownOptions = {
|
||||
const options = {
|
||||
highlight(str: string, lang: string) {
|
||||
if (!lang) {
|
||||
return ''; // use external default escaping
|
||||
@@ -65,5 +68,40 @@ export function useChatHubMarkdownOptions() {
|
||||
}
|
||||
}
|
||||
|
||||
return { markdownOptions, forceReRenderKey };
|
||||
const plugins = computed(() => {
|
||||
const linksNewTabPlugin = (vueMarkdownItInstance: MarkdownIt) => {
|
||||
vueMarkdownItInstance.use(markdownLink, {
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
rel: 'noopener',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const codeBlockPlugin = (vueMarkdownItInstance: MarkdownIt) => {
|
||||
const defaultFenceRenderer = vueMarkdownItInstance.renderer.rules.fence;
|
||||
|
||||
codeBlockContents.value = new Map();
|
||||
|
||||
vueMarkdownItInstance.renderer.rules.fence = (tokens, idx, options, env, self) => {
|
||||
const defaultRendered =
|
||||
defaultFenceRenderer?.(tokens, idx, options, env, self) ??
|
||||
self.renderToken(tokens, idx, options);
|
||||
|
||||
const content = tokens[idx]?.content.trim();
|
||||
|
||||
if (content) {
|
||||
codeBlockContents.value?.set(String(idx), content);
|
||||
}
|
||||
|
||||
return defaultRendered.replace(
|
||||
'</pre>',
|
||||
`<div data-markdown-token-idx="${idx}" class="${codeBlockActionsClassName}"></div></pre>`,
|
||||
);
|
||||
};
|
||||
};
|
||||
return [linksNewTabPlugin, codeBlockPlugin];
|
||||
});
|
||||
|
||||
return { options, forceReRenderKey, plugins, codeBlockContents };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user