From 3bbae6176327f7e252535ec7dfdf3768ad1b8b27 Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Fri, 16 Jan 2026 21:13:23 -0800 Subject: [PATCH] Decouple node help between sidebar and right panel (#8110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary When the node library is open and you click on the node toolbar info button, this causes the node library info panel & right panel node info to show the same details. ## Changes - Extract useNodeHelpContent composable so NodeHelpContent fetches its own content, allowing multiple panels to show help independently - Remove sync behavior from NodeHelpPage that caused left sidebar to change when selecting different graph nodes since we want to prioritise right panel for this behavior - Add telemetry tracking for node library help button to identify how frequently this is used ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8110-Decouple-node-help-between-sidebar-and-right-panel-2ea6d73d365081a9b3afd25aa51b34bd) by [Unito](https://www.unito.io) --- .../graph/selectionToolbox/InfoButton.test.ts | 114 +----- src/components/node/NodeHelpContent.vue | 10 +- .../rightSidePanel/info/TabInfo.vue | 12 - .../tabs/nodeLibrary/NodeHelpPage.test.ts | 100 ----- .../sidebar/tabs/nodeLibrary/NodeHelpPage.vue | 19 - .../sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue | 10 +- src/composables/useNodeHelpContent.test.ts | 381 ++++++++++++++++++ src/composables/useNodeHelpContent.ts | 79 ++++ src/stores/workspace/nodeHelpStore.test.ts | 356 +--------------- src/stores/workspace/nodeHelpStore.ts | 49 +-- 10 files changed, 487 insertions(+), 643 deletions(-) delete mode 100644 src/components/sidebar/tabs/nodeLibrary/NodeHelpPage.test.ts create mode 100644 src/composables/useNodeHelpContent.test.ts create mode 100644 src/composables/useNodeHelpContent.ts diff --git a/src/components/graph/selectionToolbox/InfoButton.test.ts b/src/components/graph/selectionToolbox/InfoButton.test.ts index da2a13831..61e292920 100644 --- a/src/components/graph/selectionToolbox/InfoButton.test.ts +++ b/src/components/graph/selectionToolbox/InfoButton.test.ts @@ -6,67 +6,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createI18n } from 'vue-i18n' import InfoButton from '@/components/graph/selectionToolbox/InfoButton.vue' -// NOTE: The component import must come after mocks so they take effect. -import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' -import { useNodeDefStore } from '@/stores/nodeDefStore' +import Button from '@/components/ui/button/Button.vue' -const mockLGraphNode = { - type: 'TestNode', - title: 'Test Node' -} - -vi.mock('@/utils/litegraphUtil', () => ({ - isLGraphNode: vi.fn(() => true) +const { openPanelMock } = vi.hoisted(() => ({ + openPanelMock: vi.fn() })) -vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({ - useNodeLibrarySidebarTab: () => ({ - id: 'node-library' - }) -})) - -const openHelpMock = vi.fn() -const closeHelpMock = vi.fn() -const nodeHelpState: { currentHelpNode: any } = { currentHelpNode: null } -vi.mock('@/stores/workspace/nodeHelpStore', () => ({ - useNodeHelpStore: () => ({ - openHelp: (def: any) => { - nodeHelpState.currentHelpNode = def - openHelpMock(def) - }, - closeHelp: () => { - nodeHelpState.currentHelpNode = null - closeHelpMock() - }, - get currentHelpNode() { - return nodeHelpState.currentHelpNode - }, - get isHelpOpen() { - return nodeHelpState.currentHelpNode !== null - } - }) -})) - -const toggleSidebarTabMock = vi.fn((id: string) => { - sidebarState.activeSidebarTabId = - sidebarState.activeSidebarTabId === id ? null : id -}) -const sidebarState: { activeSidebarTabId: string | null } = { - activeSidebarTabId: 'other-tab' -} -vi.mock('@/stores/workspace/sidebarTabStore', () => ({ - useSidebarTabStore: () => ({ - get activeSidebarTabId() { - return sidebarState.activeSidebarTabId - }, - toggleSidebarTab: toggleSidebarTabMock +vi.mock('@/stores/workspace/rightSidePanelStore', () => ({ + useRightSidePanelStore: () => ({ + openPanel: openPanelMock }) })) describe('InfoButton', () => { - let canvasStore: ReturnType - let nodeDefStore: ReturnType - const i18n = createI18n({ legacy: false, locale: 'en', @@ -81,9 +33,6 @@ describe('InfoButton', () => { beforeEach(() => { setActivePinia(createPinia()) - canvasStore = useCanvasStore() - nodeDefStore = useNodeDefStore() - vi.clearAllMocks() }) @@ -92,58 +41,15 @@ describe('InfoButton', () => { global: { plugins: [i18n, PrimeVue], directives: { tooltip: Tooltip }, - stubs: { - 'i-lucide:info': true, - Button: { - template: - '', - props: ['severity', 'text', 'class'], - emits: ['click'] - } - } + components: { Button } } }) } - it('should handle click without errors', async () => { - const mockNodeDef = { - nodePath: 'test/node', - display_name: 'Test Node' - } - canvasStore.selectedItems = [mockLGraphNode] as any - vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any) + it('should open the info panel on click', async () => { const wrapper = mountComponent() - const button = wrapper.find('button') + const button = wrapper.find('[data-testid="info-button"]') await button.trigger('click') - expect(button.exists()).toBe(true) - }) - - it('should have correct CSS classes', () => { - const mockNodeDef = { - nodePath: 'test/node', - display_name: 'Test Node' - } - canvasStore.selectedItems = [mockLGraphNode] as any - vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any) - - const wrapper = mountComponent() - const button = wrapper.find('button') - - expect(button.classes()).toContain('help-button') - expect(button.attributes('severity')).toBe('secondary') - }) - - it('should have correct tooltip', () => { - const mockNodeDef = { - nodePath: 'test/node', - display_name: 'Test Node' - } - canvasStore.selectedItems = [mockLGraphNode] as any - vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any) - - const wrapper = mountComponent() - const button = wrapper.find('button') - - expect(button.exists()).toBe(true) + expect(openPanelMock).toHaveBeenCalledWith('info') }) }) diff --git a/src/components/node/NodeHelpContent.vue b/src/components/node/NodeHelpContent.vue index 8cf058f81..ad1f75e3d 100644 --- a/src/components/node/NodeHelpContent.vue +++ b/src/components/node/NodeHelpContent.vue @@ -70,17 +70,17 @@ diff --git a/src/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue b/src/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue index fc3cabfcc..325cf44f5 100644 --- a/src/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue +++ b/src/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue @@ -57,7 +57,7 @@ variant="muted-textonly" size="icon-sm" :aria-label="$t('g.learnMore')" - @click.stop="props.openNodeHelp(nodeDef)" + @click.stop="onHelpClick" > @@ -85,6 +85,7 @@ import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue' import NodePreview from '@/components/node/NodePreview.vue' import Button from '@/components/ui/button/Button.vue' import { useSettingStore } from '@/platform/settings/settingStore' +import { useTelemetry } from '@/platform/telemetry' import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore' import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore' import { useSubgraphStore } from '@/stores/subgraphStore' @@ -112,6 +113,13 @@ const sidebarLocation = computed<'left' | 'right'>(() => const toggleBookmark = async () => { await nodeBookmarkStore.toggleBookmark(nodeDef.value) } + +const onHelpClick = () => { + useTelemetry()?.trackUiButtonClicked({ + button_id: 'node_library_help_button' + }) + props.openNodeHelp(nodeDef.value) +} const editBlueprint = async () => { if (!props.node.data) throw new Error( diff --git a/src/composables/useNodeHelpContent.test.ts b/src/composables/useNodeHelpContent.test.ts new file mode 100644 index 000000000..02b3d102f --- /dev/null +++ b/src/composables/useNodeHelpContent.test.ts @@ -0,0 +1,381 @@ +import { flushPromises } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick, ref } from 'vue' + +import { useNodeHelpContent } from '@/composables/useNodeHelpContent' +import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore' + +function createMockNode( + overrides: Partial +): ComfyNodeDefImpl { + return { + name: 'TestNode', + display_name: 'Test Node', + description: 'A test node', + category: 'test', + python_module: 'comfy.test_node', + inputs: {}, + outputs: [], + deprecated: false, + experimental: false, + output_node: false, + api_node: false, + ...overrides + } as ComfyNodeDefImpl +} + +vi.mock('@/scripts/api', () => ({ + api: { + fileURL: vi.fn((url) => url) + } +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + locale: ref('en') + }) +})) + +vi.mock('@/types/nodeSource', () => ({ + NodeSourceType: { + Core: 'core', + CustomNodes: 'custom_nodes' + }, + getNodeSource: vi.fn((pythonModule) => { + if (pythonModule?.startsWith('custom_nodes.')) { + return { type: 'custom_nodes' } + } + return { type: 'core' } + }) +})) + +describe('useNodeHelpContent', () => { + const mockCoreNode = createMockNode({ + name: 'TestNode', + display_name: 'Test Node', + description: 'A test node', + python_module: 'comfy.test_node' + }) + + const mockCustomNode = createMockNode({ + name: 'CustomNode', + display_name: 'Custom Node', + description: 'A custom node', + python_module: 'custom_nodes.test_module.custom@1.0.0' + }) + + const mockFetch = vi.fn() + + beforeEach(() => { + mockFetch.mockReset() + vi.stubGlobal('fetch', mockFetch) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('should generate correct baseUrl for core nodes', async () => { + const nodeRef = ref(mockCoreNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => '# Test' + }) + + const { baseUrl } = useNodeHelpContent(nodeRef) + await nextTick() + + expect(baseUrl.value).toBe(`/docs/${mockCoreNode.name}/`) + }) + + it('should generate correct baseUrl for custom nodes', async () => { + const nodeRef = ref(mockCustomNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => '# Test' + }) + + const { baseUrl } = useNodeHelpContent(nodeRef) + await nextTick() + + expect(baseUrl.value).toBe('/extensions/test_module/docs/') + }) + + it('should render markdown content correctly', async () => { + const nodeRef = ref(mockCoreNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => '# Test Help\nThis is test help content' + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(renderedHelpHtml.value).toContain('This is test help content') + }) + + it('should handle fetch errors and fall back to description', async () => { + const nodeRef = ref(mockCoreNode) + mockFetch.mockResolvedValueOnce({ + ok: false, + statusText: 'Not Found' + }) + + const { error, renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(error.value).toBe('Not Found') + expect(renderedHelpHtml.value).toContain(mockCoreNode.description) + }) + + it('should include alt attribute for images', async () => { + const nodeRef = ref(mockCustomNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => '![image](test.jpg)' + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(renderedHelpHtml.value).toContain('alt="image"') + }) + + it('should prefix relative video src in custom nodes', async () => { + const nodeRef = ref(mockCustomNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => '' + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(renderedHelpHtml.value).toContain( + 'src="/extensions/test_module/docs/video.mp4"' + ) + }) + + it('should prefix relative video src for core nodes with node-specific base URL', async () => { + const nodeRef = ref(mockCoreNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => '' + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(renderedHelpHtml.value).toContain( + `src="/docs/${mockCoreNode.name}/video.mp4"` + ) + }) + + it('should handle loading state', async () => { + const nodeRef = ref(mockCoreNode) + mockFetch.mockImplementationOnce(() => new Promise(() => {})) // Never resolves + + const { isLoading } = useNodeHelpContent(nodeRef) + await nextTick() + + expect(isLoading.value).toBe(true) + }) + + it('should try fallback URL for custom nodes', async () => { + const nodeRef = ref(mockCustomNode) + mockFetch + .mockResolvedValueOnce({ + ok: false, + statusText: 'Not Found' + }) + .mockResolvedValueOnce({ + ok: true, + text: async () => '# Fallback content' + }) + + useNodeHelpContent(nodeRef) + await flushPromises() + + expect(mockFetch).toHaveBeenCalledTimes(2) + expect(mockFetch).toHaveBeenCalledWith( + '/extensions/test_module/docs/CustomNode/en.md' + ) + expect(mockFetch).toHaveBeenCalledWith( + '/extensions/test_module/docs/CustomNode.md' + ) + }) + + it('should prefix relative source src in custom nodes', async () => { + const nodeRef = ref(mockCustomNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => + '' + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(renderedHelpHtml.value).toContain( + 'src="/extensions/test_module/docs/video.mp4"' + ) + }) + + it('should prefix relative source src for core nodes with node-specific base URL', async () => { + const nodeRef = ref(mockCoreNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => + '' + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(renderedHelpHtml.value).toContain( + `src="/docs/${mockCoreNode.name}/video.webm"` + ) + }) + + it('should prefix relative img src in raw HTML for custom nodes', async () => { + const nodeRef = ref(mockCustomNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => '# Test\nTest image' + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(renderedHelpHtml.value).toContain( + 'src="/extensions/test_module/docs/image.png"' + ) + expect(renderedHelpHtml.value).toContain('alt="Test image"') + }) + + it('should prefix relative img src in raw HTML for core nodes', async () => { + const nodeRef = ref(mockCoreNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => '# Test\nTest image' + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(renderedHelpHtml.value).toContain( + `src="/docs/${mockCoreNode.name}/image.png"` + ) + expect(renderedHelpHtml.value).toContain('alt="Test image"') + }) + + it('should not prefix absolute img src in raw HTML', async () => { + const nodeRef = ref(mockCustomNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => 'Absolute' + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(renderedHelpHtml.value).toContain('src="/absolute/image.png"') + expect(renderedHelpHtml.value).toContain('alt="Absolute"') + }) + + it('should not prefix external img src in raw HTML', async () => { + const nodeRef = ref(mockCustomNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => + 'External' + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(renderedHelpHtml.value).toContain( + 'src="https://example.com/image.png"' + ) + expect(renderedHelpHtml.value).toContain('alt="External"') + }) + + it('should handle various quote styles in media src attributes', async () => { + const nodeRef = ref(mockCoreNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => `# Media Test + +Testing quote styles in properly formed HTML: + + + +Double quotes +Single quotes + + + +The MEDIA_SRC_REGEX handles both single and double quotes in img, video and source tags.` + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + // All media src attributes should be prefixed correctly + // Note: marked normalizes quotes to double quotes in output + expect(renderedHelpHtml.value).toContain( + `src="/docs/${mockCoreNode.name}/video1.mp4"` + ) + expect(renderedHelpHtml.value).toContain( + `src="/docs/${mockCoreNode.name}/video2.mp4"` + ) + expect(renderedHelpHtml.value).toContain( + `src="/docs/${mockCoreNode.name}/image1.png"` + ) + expect(renderedHelpHtml.value).toContain( + `src="/docs/${mockCoreNode.name}/image2.png"` + ) + expect(renderedHelpHtml.value).toContain( + `src="/docs/${mockCoreNode.name}/video3.mp4"` + ) + expect(renderedHelpHtml.value).toContain( + `src="/docs/${mockCoreNode.name}/video3.webm"` + ) + }) + + it('should ignore stale requests when node changes', async () => { + const nodeRef = ref(mockCoreNode) + let resolveFirst: (value: unknown) => void + const firstRequest = new Promise((resolve) => { + resolveFirst = resolve + }) + + mockFetch + .mockImplementationOnce(() => firstRequest) + .mockResolvedValueOnce({ + ok: true, + text: async () => '# Second node content' + }) + + const { helpContent } = useNodeHelpContent(nodeRef) + await nextTick() + + // Change node before first request completes + nodeRef.value = mockCustomNode + await nextTick() + await flushPromises() + + // Now resolve the first (stale) request + resolveFirst!({ + ok: true, + text: async () => '# First node content' + }) + await flushPromises() + + // Should have second node's content, not first + expect(helpContent.value).toBe('# Second node content') + }) +}) diff --git a/src/composables/useNodeHelpContent.ts b/src/composables/useNodeHelpContent.ts new file mode 100644 index 000000000..81ef9ae47 --- /dev/null +++ b/src/composables/useNodeHelpContent.ts @@ -0,0 +1,79 @@ +import type { MaybeRefOrGetter } from 'vue' +import { computed, ref, toValue, watch } from 'vue' +import { useI18n } from 'vue-i18n' + +import { nodeHelpService } from '@/services/nodeHelpService' +import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore' +import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil' +import { getNodeHelpBaseUrl } from '@/workbench/utils/nodeHelpUtil' + +/** + * Composable for fetching and rendering node help content. + * Creates independent state for each usage, allowing multiple panels + * to show help content without interfering with each other. + * + * @param nodeRef - Reactive reference to the node to show help for + * @returns Reactive help content state and rendered HTML + */ +export function useNodeHelpContent( + nodeRef: MaybeRefOrGetter +) { + const { locale } = useI18n() + + const helpContent = ref('') + const isLoading = ref(false) + const error = ref(null) + + let currentRequest: Promise | null = null + + const baseUrl = computed(() => { + const node = toValue(nodeRef) + if (!node) return '' + return getNodeHelpBaseUrl(node) + }) + + const renderedHelpHtml = computed(() => { + return renderMarkdownToHtml(helpContent.value, baseUrl.value) + }) + + // Watch for node changes and fetch help content + watch( + () => toValue(nodeRef), + async (node) => { + helpContent.value = '' + error.value = null + + if (node) { + isLoading.value = true + const request = (currentRequest = nodeHelpService.fetchNodeHelp( + node, + locale.value || 'en' + )) + + try { + const content = await request + if (currentRequest !== request) return + helpContent.value = content + } catch (e: unknown) { + if (currentRequest !== request) return + error.value = e instanceof Error ? e.message : String(e) + helpContent.value = node.description || '' + } finally { + if (currentRequest === request) { + currentRequest = null + isLoading.value = false + } + } + } + }, + { immediate: true } + ) + + return { + helpContent, + isLoading, + error, + baseUrl, + renderedHelpHtml + } +} diff --git a/src/stores/workspace/nodeHelpStore.test.ts b/src/stores/workspace/nodeHelpStore.test.ts index dc8b7b466..0514e9029 100644 --- a/src/stores/workspace/nodeHelpStore.test.ts +++ b/src/stores/workspace/nodeHelpStore.test.ts @@ -1,74 +1,9 @@ -import { flushPromises } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { nextTick } from 'vue' +import { beforeEach, describe, expect, it } from 'vitest' import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore' -vi.mock('@/scripts/api', () => ({ - api: { - fileURL: vi.fn((url) => url) - } -})) - -vi.mock('@/i18n', () => ({ - i18n: { - global: { - locale: { - value: 'en' - } - } - } -})) - -vi.mock('@/types/nodeSource', () => ({ - NodeSourceType: { - Core: 'core', - CustomNodes: 'custom_nodes' - }, - getNodeSource: vi.fn((pythonModule) => { - if (pythonModule?.startsWith('custom_nodes.')) { - return { type: 'custom_nodes' } - } - return { type: 'core' } - }) -})) - -vi.mock('dompurify', () => ({ - default: { - sanitize: vi.fn((html) => html) - } -})) - -vi.mock('marked', () => ({ - marked: { - parse: vi.fn((markdown, options) => { - if (options?.renderer) { - if (markdown.includes('![')) { - const matches = markdown.match(/!\[(.*?)\]\((.*?)\)/) - if (matches) { - const [, text, href] = matches - return options.renderer.image({ href, text, title: '' }) - } - } - } - return `

${markdown}

` - }) - }, - Renderer: class Renderer { - image = vi.fn( - ({ href, title, text }) => - `${text}` - ) - link = vi.fn( - ({ href, title, text }) => - `${text}` - ) - } -})) - describe('nodeHelpStore', () => { - // Define a mock node for testing const mockCoreNode = { name: 'TestNode', display_name: 'Test Node', @@ -78,23 +13,8 @@ describe('nodeHelpStore', () => { python_module: 'comfy.test_node' } - const mockCustomNode = { - name: 'CustomNode', - display_name: 'Custom Node', - description: 'A custom node', - inputs: {}, - outputs: [], - python_module: 'custom_nodes.test_module.custom@1.0.0' - } - - // Mock fetch responses - const mockFetch = vi.fn() - global.fetch = mockFetch - beforeEach(() => { - // Setup Pinia setActivePinia(createPinia()) - mockFetch.mockReset() }) it('should initialize with empty state', () => { @@ -122,278 +42,4 @@ describe('nodeHelpStore', () => { expect(nodeHelpStore.currentHelpNode).toBeNull() expect(nodeHelpStore.isHelpOpen).toBe(false) }) - - it('should generate correct baseUrl for core nodes', async () => { - const nodeHelpStore = useNodeHelpStore() - - nodeHelpStore.openHelp(mockCoreNode as any) - await nextTick() - - expect(nodeHelpStore.baseUrl).toBe(`/docs/${mockCoreNode.name}/`) - }) - - it('should generate correct baseUrl for custom nodes', async () => { - const nodeHelpStore = useNodeHelpStore() - - nodeHelpStore.openHelp(mockCustomNode as any) - await nextTick() - - expect(nodeHelpStore.baseUrl).toBe('/extensions/test_module/docs/') - }) - - it('should render markdown content correctly', async () => { - const nodeHelpStore = useNodeHelpStore() - - mockFetch.mockResolvedValueOnce({ - ok: true, - text: async () => '# Test Help\nThis is test help content' - }) - - nodeHelpStore.openHelp(mockCoreNode as any) - await flushPromises() - - expect(nodeHelpStore.renderedHelpHtml).toContain( - 'This is test help content' - ) - }) - - it('should handle fetch errors and fall back to description', async () => { - const nodeHelpStore = useNodeHelpStore() - - mockFetch.mockResolvedValueOnce({ - ok: false, - statusText: 'Not Found' - }) - - nodeHelpStore.openHelp(mockCoreNode as any) - await flushPromises() - - expect(nodeHelpStore.error).toBe('Not Found') - expect(nodeHelpStore.renderedHelpHtml).toContain(mockCoreNode.description) - }) - - it('should include alt attribute for images', async () => { - const nodeHelpStore = useNodeHelpStore() - - mockFetch.mockResolvedValueOnce({ - ok: true, - text: async () => '![image](test.jpg)' - }) - - nodeHelpStore.openHelp(mockCustomNode as any) - await flushPromises() - expect(nodeHelpStore.renderedHelpHtml).toContain('alt="image"') - }) - - it('should prefix relative video src in custom nodes', async () => { - const nodeHelpStore = useNodeHelpStore() - - mockFetch.mockResolvedValueOnce({ - ok: true, - text: async () => '' - }) - - nodeHelpStore.openHelp(mockCustomNode as any) - await flushPromises() - expect(nodeHelpStore.renderedHelpHtml).toContain( - 'src="/extensions/test_module/docs/video.mp4"' - ) - }) - - it('should prefix relative video src for core nodes with node-specific base URL', async () => { - const nodeHelpStore = useNodeHelpStore() - - mockFetch.mockResolvedValueOnce({ - ok: true, - text: async () => '' - }) - - nodeHelpStore.openHelp(mockCoreNode as any) - await flushPromises() - expect(nodeHelpStore.renderedHelpHtml).toContain( - `src="/docs/${mockCoreNode.name}/video.mp4"` - ) - }) - - it('should prefix relative source src in custom nodes', async () => { - const nodeHelpStore = useNodeHelpStore() - - mockFetch.mockResolvedValueOnce({ - ok: true, - text: async () => - '' - }) - - nodeHelpStore.openHelp(mockCustomNode as any) - await flushPromises() - expect(nodeHelpStore.renderedHelpHtml).toContain( - 'src="/extensions/test_module/docs/video.mp4"' - ) - }) - - it('should prefix relative source src for core nodes with node-specific base URL', async () => { - const nodeHelpStore = useNodeHelpStore() - - mockFetch.mockResolvedValueOnce({ - ok: true, - text: async () => - '' - }) - - nodeHelpStore.openHelp(mockCoreNode as any) - await flushPromises() - expect(nodeHelpStore.renderedHelpHtml).toContain( - `src="/docs/${mockCoreNode.name}/video.webm"` - ) - }) - - it('should handle loading state', async () => { - const nodeHelpStore = useNodeHelpStore() - - mockFetch.mockImplementationOnce(() => new Promise(() => {})) // Never resolves - - nodeHelpStore.openHelp(mockCoreNode as any) - await nextTick() - - expect(nodeHelpStore.isLoading).toBe(true) - }) - - it('should try fallback URL for custom nodes', async () => { - const nodeHelpStore = useNodeHelpStore() - - mockFetch - .mockResolvedValueOnce({ - ok: false, - statusText: 'Not Found' - }) - .mockResolvedValueOnce({ - ok: true, - text: async () => '# Fallback content' - }) - - nodeHelpStore.openHelp(mockCustomNode as any) - await flushPromises() - - expect(mockFetch).toHaveBeenCalledTimes(2) - expect(mockFetch).toHaveBeenCalledWith( - '/extensions/test_module/docs/CustomNode/en.md' - ) - expect(mockFetch).toHaveBeenCalledWith( - '/extensions/test_module/docs/CustomNode.md' - ) - }) - - it('should prefix relative img src in raw HTML for custom nodes', async () => { - const nodeHelpStore = useNodeHelpStore() - - mockFetch.mockResolvedValueOnce({ - ok: true, - text: async () => '# Test\nTest image' - }) - - nodeHelpStore.openHelp(mockCustomNode as any) - await flushPromises() - expect(nodeHelpStore.renderedHelpHtml).toContain( - 'src="/extensions/test_module/docs/image.png"' - ) - expect(nodeHelpStore.renderedHelpHtml).toContain('alt="Test image"') - }) - - it('should prefix relative img src in raw HTML for core nodes', async () => { - const nodeHelpStore = useNodeHelpStore() - - mockFetch.mockResolvedValueOnce({ - ok: true, - text: async () => '# Test\nTest image' - }) - - nodeHelpStore.openHelp(mockCoreNode as any) - await flushPromises() - expect(nodeHelpStore.renderedHelpHtml).toContain( - `src="/docs/${mockCoreNode.name}/image.png"` - ) - expect(nodeHelpStore.renderedHelpHtml).toContain('alt="Test image"') - }) - - it('should not prefix absolute img src in raw HTML', async () => { - const nodeHelpStore = useNodeHelpStore() - - mockFetch.mockResolvedValueOnce({ - ok: true, - text: async () => 'Absolute' - }) - - nodeHelpStore.openHelp(mockCustomNode as any) - await flushPromises() - expect(nodeHelpStore.renderedHelpHtml).toContain( - 'src="/absolute/image.png"' - ) - expect(nodeHelpStore.renderedHelpHtml).toContain('alt="Absolute"') - }) - - it('should not prefix external img src in raw HTML', async () => { - const nodeHelpStore = useNodeHelpStore() - - mockFetch.mockResolvedValueOnce({ - ok: true, - text: async () => - 'External' - }) - - nodeHelpStore.openHelp(mockCustomNode as any) - await flushPromises() - expect(nodeHelpStore.renderedHelpHtml).toContain( - 'src="https://example.com/image.png"' - ) - expect(nodeHelpStore.renderedHelpHtml).toContain('alt="External"') - }) - - it('should handle various quote styles in media src attributes', async () => { - const nodeHelpStore = useNodeHelpStore() - - mockFetch.mockResolvedValueOnce({ - ok: true, - text: async () => `# Media Test - -Testing quote styles in properly formed HTML: - - - -Double quotes -Single quotes - - - -The MEDIA_SRC_REGEX handles both single and double quotes in img, video and source tags.` - }) - - nodeHelpStore.openHelp(mockCoreNode as any) - await flushPromises() - - // Check that all media elements with different quote styles are prefixed correctly - // Double quotes remain as double quotes - expect(nodeHelpStore.renderedHelpHtml).toContain( - `src="/docs/${mockCoreNode.name}/video1.mp4"` - ) - expect(nodeHelpStore.renderedHelpHtml).toContain( - `src="/docs/${mockCoreNode.name}/image1.png"` - ) - expect(nodeHelpStore.renderedHelpHtml).toContain( - `src="/docs/${mockCoreNode.name}/video3.mp4"` - ) - - // Single quotes remain as single quotes in the output - expect(nodeHelpStore.renderedHelpHtml).toContain( - `src='/docs/${mockCoreNode.name}/video2.mp4'` - ) - expect(nodeHelpStore.renderedHelpHtml).toContain( - `src='/docs/${mockCoreNode.name}/image2.png'` - ) - expect(nodeHelpStore.renderedHelpHtml).toContain( - `src='/docs/${mockCoreNode.name}/video3.webm'` - ) - }) }) diff --git a/src/stores/workspace/nodeHelpStore.ts b/src/stores/workspace/nodeHelpStore.ts index a04d6d829..4e6765040 100644 --- a/src/stores/workspace/nodeHelpStore.ts +++ b/src/stores/workspace/nodeHelpStore.ts @@ -1,18 +1,11 @@ import { defineStore } from 'pinia' -import { computed, ref, watch } from 'vue' +import { computed, ref } from 'vue' -import { i18n } from '@/i18n' -import { nodeHelpService } from '@/services/nodeHelpService' import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore' -import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil' -import { getNodeHelpBaseUrl } from '@/workbench/utils/nodeHelpUtil' export const useNodeHelpStore = defineStore('nodeHelp', () => { const currentHelpNode = ref(null) const isHelpOpen = computed(() => currentHelpNode.value !== null) - const helpContent = ref('') - const isLoading = ref(false) - const errorMsg = ref(null) function openHelp(nodeDef: ComfyNodeDefImpl) { currentHelpNode.value = nodeDef @@ -22,48 +15,10 @@ export const useNodeHelpStore = defineStore('nodeHelp', () => { currentHelpNode.value = null } - // Base URL for relative assets in node docs markdown - const baseUrl = computed(() => { - const node = currentHelpNode.value - if (!node) return '' - return getNodeHelpBaseUrl(node) - }) - - // Watch for help node changes and fetch its docs markdown - watch( - () => currentHelpNode.value, - async (node) => { - helpContent.value = '' - errorMsg.value = null - - if (node) { - isLoading.value = true - try { - const locale = i18n.global.locale.value || 'en' - helpContent.value = await nodeHelpService.fetchNodeHelp(node, locale) - } catch (e: any) { - errorMsg.value = e.message - helpContent.value = node.description || '' - } finally { - isLoading.value = false - } - } - }, - { immediate: true } - ) - - const renderedHelpHtml = computed(() => { - return renderMarkdownToHtml(helpContent.value, baseUrl.value) - }) - return { currentHelpNode, isHelpOpen, openHelp, - closeHelp, - baseUrl, - renderedHelpHtml, - isLoading, - error: errorMsg + closeHelp } })