Merge sidebar-documentation onto main

The sidebar-documentation branch has diverged enough from main to
merging non-trivial

Remove deprecated type usage.
Move localization to language file.
This commit is contained in:
Austin Mroz
2024-11-27 17:53:35 -06:00
12 changed files with 450 additions and 16 deletions

View File

@@ -0,0 +1,130 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
const nodeDef = {
title: 'TestNodeAdvancedDoc'
}
test.describe('Documentation Sidebar', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Floating')
await comfyPage.loadWorkflow('default')
})
test.afterEach(async ({ comfyPage }) => {
const currentThemeId = await comfyPage.menu.getThemeId()
if (currentThemeId !== 'dark') {
await comfyPage.menu.toggleTheme()
}
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Sidebar registered', async ({ comfyPage }) => {
await expect(
comfyPage.page.locator('.documentation-tab-button')
).toBeVisible()
})
test('Parses help for basic node', async ({ comfyPage }) => {
await comfyPage.page.locator('.documentation-tab-button').click()
const docPane = comfyPage.page.locator('.sidebar-content-container')
//Check that each independently parsed element exists
await expect(docPane).toContainText('Load Checkpoint')
await expect(docPane).toContainText('Loads a diffusion model')
await expect(docPane).toContainText('The name of the checkpoint')
await expect(docPane).toContainText('The VAE model used')
})
test('Responds to hovering over node', async ({ comfyPage }) => {
await comfyPage.page.locator('.documentation-tab-button').click()
const docPane = comfyPage.page.locator('.sidebar-content-container')
await comfyPage.page.mouse.move(321, 593)
const tooltipTimeout = 500
await comfyPage.page.waitForTimeout(tooltipTimeout + 16)
await expect(comfyPage.page.locator('.node-tooltip')).not.toBeVisible()
await expect(
comfyPage.page.locator('.sidebar-content-container>div>div:nth-child(4)')
).toBeFocused()
})
test('Updates when a new node is selected', async ({ comfyPage }) => {
await comfyPage.page.locator('.documentation-tab-button').click()
const docPane = comfyPage.page.locator('.sidebar-content-container')
await comfyPage.page.mouse.click(557, 440)
await expect(docPane).not.toContainText('Load Checkpoint')
await expect(docPane).toContainText('CLIP Text Encode (Prompt)')
await expect(docPane).toContainText('The text to be encoded')
await expect(docPane).toContainText(
'A conditioning containing the embedded text'
)
})
test('Responds to a change in theme', async ({ comfyPage }) => {
await comfyPage.page.locator('.documentation-tab-button').click()
const docPane = comfyPage.page.locator('.sidebar-content-container')
await comfyPage.menu.toggleTheme()
await expect(docPane).toHaveScreenshot(
'documentation-sidebar-light-theme.png'
)
})
})
test.describe('Advanced Description tests', () => {
test.beforeEach(async ({ comfyPage }) => {
//register test node and add to graph
await comfyPage.page.evaluate(async (node) => {
const app = window['app']
await app.registerNodeDef(node.name, node)
app.addNodeOnGraph(node)
}, advDocNode)
await comfyPage.setSetting('Comfy.UseNewMenu', 'Floating')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Description displays as raw html', async ({ comfyPage }) => {
await comfyPage.page.locator('.documentation-tab-button').click()
const docPane = comfyPage.page.locator('.sidebar-content-container>div')
await expect(docPane).toHaveJSProperty(
'innerHTML',
advDocNode.description[1]
)
})
test('selected function', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
const app = window['app']
const desc =
LiteGraph.registered_node_types['Test_AdvancedDescription'].nodeData
.description
desc[2].select = (element, name, value) => {
element.children[0].innerText = name + ' ' + value
}
})
await comfyPage.page.locator('.documentation-tab-button').click()
const docPane = comfyPage.page.locator('.sidebar-content-container')
await comfyPage.page.mouse.move(307, 80)
const tooltipTimeout = 500
await comfyPage.page.waitForTimeout(tooltipTimeout + 16)
await expect(docPane).toContainText('int_input 0')
})
})
const advDocNode = {
display_name: 'Node With Advanced Description',
name: 'Test_AdvancedDescription',
input: {
required: {
int_input: [
'INT',
{ tooltip: "an input tooltip that won't be displayed in sidebar" }
]
}
},
output: ['INT'],
output_name: ['int_output'],
output_tooltips: ["An output tooltip that won't be displayed in the sidebar"],
output_is_list: false,
description: [
'A node with description in the advanced format',
`
A long form description that will be displayed in the sidebar.
<div doc_title="INT">Can include arbitrary html</div>
<div doc_title="int_input">or out of order widgets</div>
`,
{}
]
}

View File

@@ -10,20 +10,23 @@
</template>
<script setup lang="ts">
import { nextTick, ref } from 'vue'
import { nextTick, ref, watch } from 'vue'
import { LiteGraph } from '@comfyorg/litegraph'
import { app as comfyApp } from '@/scripts/app'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useHoveredItemStore } from '@/stores/graphStore'
import { useEventListener } from '@vueuse/core'
let idleTimeout: number
const nodeDefStore = useNodeDefStore()
const hoveredItemStore = useHoveredItemStore()
const tooltipRef = ref<HTMLDivElement>()
const tooltipText = ref('')
const left = ref<string>()
const top = ref<string>()
const hideTooltip = () => (tooltipText.value = null)
const clearHovered = () => (hoveredItemStore.value = null)
const showTooltip = async (tooltip: string | null | undefined) => {
if (!tooltip) return
@@ -43,6 +46,36 @@ const showTooltip = async (tooltip: string | null | undefined) => {
top.value = comfyApp.canvas.mouse[1] + rect.height + 'px'
}
}
watch(hoveredItemStore, (hoveredItem) => {
if (!hoveredItem.value) {
return hideTooltip()
}
const item = hoveredItem.value
const nodeDef =
nodeDefStore.nodeDefsByName[item.node.type] ??
LiteGraph.registered_node_types[item.node.type]?.nodeData
if (item.type == 'Title') {
let description = nodeDef.description
if (Array.isArray(description)) {
description = description[0]
}
return showTooltip(description)
} else if (item.type == 'Input') {
showTooltip(nodeDef.input.getInput(item.inputName)?.tooltip)
} else if (item.type == 'Output') {
showTooltip(nodeDef?.output?.all?.[item.outputSlot]?.tooltip)
} else if (item.type == 'Widget') {
showTooltip(
item.widget.tooltip ??
(
nodeDef.input.optional?.[item.widget.name] ??
nodeDef.input.required?.[item.widget.name]
)?.tooltip
)
} else {
hideTooltip()
}
})
const onIdle = () => {
const { canvas } = comfyApp
@@ -50,13 +83,15 @@ const onIdle = () => {
if (!node) return
const ctor = node.constructor as { title_mode?: 0 | 1 | 2 | 3 }
const nodeDef = nodeDefStore.nodeDefsByName[node.type]
const nodeDef =
nodeDefStore.nodeDefsByName[node.type] ??
LiteGraph.registered_node_types[node.type]?.nodeData
if (
ctor.title_mode !== LiteGraph.NO_TITLE &&
canvas.graph_mouse[1] < node.pos[1] // If we are over a node, but not within the node then we are on its title
) {
return showTooltip(nodeDef.description)
hoveredItemStore.value = { node, type: 'Title' }
}
if (node.flags?.collapsed) return
@@ -69,7 +104,7 @@ const onIdle = () => {
)
if (inputSlot !== -1) {
const inputName = node.inputs[inputSlot].name
return showTooltip(nodeDef.input.getInput(inputName)?.tooltip)
hoveredItemStore.value = { node, type: 'Input', inputName }
}
const outputSlot = canvas.isOverNodeOutput(
@@ -79,20 +114,18 @@ const onIdle = () => {
[0, 0]
)
if (outputSlot !== -1) {
return showTooltip(nodeDef.output.all?.[outputSlot]?.tooltip)
hoveredItemStore.value = { node, type: 'Output', outputSlot }
}
const widget = comfyApp.canvas.getWidgetAtCursor()
// Dont show for DOM widgets, these use native browser tooltips as we dont get proper mouse events on these
if (widget && !widget.element) {
return showTooltip(
widget.tooltip ?? nodeDef.input.getInput(widget.name)?.tooltip
)
hoveredItemStore.value = { node, type: 'Widget', widget }
}
}
const onMouseMove = (e: MouseEvent) => {
hideTooltip()
clearHovered()
clearTimeout(idleTimeout)
if ((e.target as Node).nodeName !== 'CANVAS') return
@@ -100,7 +133,7 @@ const onMouseMove = (e: MouseEvent) => {
}
useEventListener(window, 'mousemove', onMouseMove)
useEventListener(window, 'click', hideTooltip)
useEventListener(window, 'click', clearHovered)
</script>
<style lang="css" scoped>

View File

@@ -74,7 +74,11 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
backgroundColor: litegraphColors.WIDGET_BGCOLOR
}"
>
{{ nodeDef.description }}
{{
Array.isArray(nodeDef.description)
? nodeDef.description[0]
: nodeDef.description
}}
</div>
</div>
</template>

View File

@@ -0,0 +1,223 @@
<template>
<div v-if="!hasAnyDoc()">No documentation available</div>
<div v-else-if="rawDoc" ref="docElement" v-html="rawDoc"></div>
<div v-else ref="docElement">
<div class="doc-node">{{ title }}</div>
<div>{{ description }}</div>
<div v-if="inputs.length" class="doc-section">Inputs</div>
<div
v-if="inputs.length"
v-for="input in inputs"
tabindex="-1"
class="doc-item"
>
{{ input[0] }}
<div>{{ input[1] }}</div>
</div>
<div v-if="outputs.length" class="doc-section">Outputs</div>
<div
v-if="outputs.length"
v-for="output in outputs"
tabindex="-1"
class="doc-item"
>
{{ output[0] }}
<div>{{ output[1] }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { app } from '@/scripts/app'
import { useCanvasStore } from '@/stores/graphStore'
import { useHoveredItemStore } from '@/stores/graphStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
const nodeDefStore = useNodeDefStore()
const hoveredItemStore = useHoveredItemStore()
const canvasStore = useCanvasStore()
const docElement = ref(null)
let def
const rawDoc = ref(null)
const description = ref(null)
const title = ref(null)
const inputs = ref([])
const outputs = ref([])
function setCollapse(el, doCollapse) {
if (doCollapse) {
el.children[0].children[0].innerHTML = '+'
Object.assign(el.children[1].style, {
color: '#CCC',
overflowX: 'hidden',
width: '0px',
minWidth: 'calc(100% - 20px)',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
})
for (let child of el.children[1].children) {
if (child.style.display != 'none') {
child.origDisplay = child.style.display
}
child.style.display = 'none'
}
} else {
el.children[0].children[0].innerHTML = '-'
Object.assign(el.children[1].style, {
color: '',
overflowX: '',
width: '100%',
minWidth: '',
textOverflow: '',
whiteSpace: ''
})
for (let child of el.children[1].children) {
child.style.display = child.origDisplay
}
}
}
function collapseOnClick() {
let doCollapse = this.children[0].innerHTML == '-'
setCollapse(this.parentElement, doCollapse)
}
function selectHelp(name: string, value?: string) {
if (!docElement.value || !name) {
return null
}
if (def.description[2]?.select) {
return def.description[2].select(docElement.value, name, value)
}
//attempt to navigate to name in help
function collapseUnlessMatch(items, t) {
var match = items.querySelector('[doc_title="' + t + '"]')
if (!match) {
for (let i of items.children) {
if (i.innerHTML.slice(0, t.length + 5).includes(t)) {
match = i
break
}
}
}
if (!match) {
return null
}
//For longer documentation items with fewer collapsable elements,
//scroll to make sure the entirety of the selected item is visible
match.scrollIntoView({ block: 'nearest' })
//The previous floating help implementation would try to scroll the window
//itself if the display was partiall offscreen. As the sidebar documentation
//does not pan with the canvas, this should no longer be needed
//window.scrollTo(0, 0)
for (let i of items.querySelectorAll('.doc_collapse')) {
if (i.contains(match)) {
setCollapse(i, false)
} else {
setCollapse(i, true)
}
}
return match
}
let target = collapseUnlessMatch(docElement.value, name)
if (target) {
target.focus()
if (value) {
collapseUnlessMatch(target, value)
}
}
}
function updateNode(node?) {
node ||= app?.canvas?.current_node
if (!node) {
// Graph has no nodes
return
}
def = LiteGraph.getNodeType(node.type).nodeData
title.value = def.display_name
if (Array.isArray(def.description)) {
rawDoc.value = def.description[1]
outputs.value = []
inputs.value = []
return
} else {
rawDoc.value = null
}
description.value = def.description
let input_temp = []
for (let k in def?.input?.required) {
if (def.input.required[k][1]?.tooltip) {
input_temp.push([k, def.input.required[k][1].tooltip])
}
}
for (let k in def?.optional?.required) {
if (def.input.optional[k][1]?.tooltip) {
input_temp.push([k, def.input.optional[k][1].tooltip])
}
}
inputs.value = input_temp
if (def.output_tooltips) {
const outputs_temp = []
const output_name = def.output_name || def.output
for (let i = 0; i < def.output_tooltips.length; i++) {
outputs_temp[i] = [output_name[i], def.output_tooltips[i]]
}
outputs.value = outputs_temp
} else {
outputs.value = []
}
}
function hasAnyDoc() {
return def?.description || inputs.value.length || outputs.value.length
}
watch(hoveredItemStore, (hoveredItem) => {
if (!hoveredItem.value) {
return
}
const item = hoveredItem.value
if (item.node.id != app?.canvas?.current_node.id) {
return
}
const nodeDef = nodeDefStore.nodeDefsByName[item.node.type]
if (item.type == 'DESCRIPTION') {
return
} else if (item.type == 'Input') {
selectHelp(item.inputName)
hoveredItem.value = null
} else if (item.type == 'Output') {
selectHelp(nodeDef?.output?.all?.[item.outputSlot]?.name)
hoveredItem.value = null
} else if (item.type == 'Widget') {
selectHelp(item.widget.name, item.widget.value)
hoveredItem.value = null
}
})
watch(() => canvasStore?.canvas?.current_node, updateNode)
updateNode()
</script>
<style scoped>
.doc-node {
font-size: 1.5em;
}
.doc-section {
background-color: var(--comfy-menu-bg);
}
.doc-item div {
margin-inline-start: 1vw;
}
@keyframes selectAnimation {
0% {
background-color: #5555;
}
80% {
background-color: #5555;
}
100% {
background-color: #0000;
}
}
.doc-item:focus {
animation: selectAnimation 2s;
}
</style>

View File

@@ -0,0 +1,18 @@
import { useQueuePendingTaskCountStore } from '@/stores/queueStore'
import { markRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import DocumentationSidebarTab from '@/components/sidebar/tabs/DocumentationSidebarTab.vue'
import type { SidebarTabExtension } from '@/types/extensionTypes'
export const useDocumentationSidebarTab = (): SidebarTabExtension => {
const { t } = useI18n()
const queuePendingTaskCountStore = useQueuePendingTaskCountStore()
return {
id: 'documentation',
icon: 'mdi mdi-help',
title: t('sideToolbar.documentation'),
tooltip: t('sideToolbar.documentation'),
component: markRaw(DocumentationSidebarTab),
type: 'vue'
}
}

View File

@@ -158,6 +158,7 @@ export default {
},
modelLibrary: 'Model Library',
downloads: 'Downloads',
documentation: 'Display documentation for nodes',
queueTab: {
showFlatList: 'Show Flat List',
backToAllTasks: 'Back to All Tasks',

View File

@@ -137,6 +137,7 @@ export class ComfyApp {
canvas: LGraphCanvas
dragOverNode: LGraphNode | null
canvasEl: HTMLCanvasElement
tooltipCallback?: (node: LGraphNode, name: string, value?: string) => boolean
// x, y, scale
zoom_drag_start: [number, number, number] | null
lastNodeErrors: any[] | null

View File

@@ -1,4 +1,5 @@
import { LGraphNode, LGraphGroup, LGraphCanvas } from '@comfyorg/litegraph'
import type { ComfyNodeItem } from '@/types/comfy'
import { defineStore } from 'pinia'
import { shallowRef } from 'vue'
@@ -10,6 +11,14 @@ export const useTitleEditorStore = defineStore('titleEditor', () => {
}
})
export const useHoveredItemStore = defineStore('hoveredItem', () => {
const hoveredItem = shallowRef<ComfyNodeItem | null>(null)
return {
hoveredItem
}
})
export const useCanvasStore = defineStore('canvas', () => {
/**
* The LGraphCanvas instance.

View File

@@ -5,7 +5,8 @@ import {
import {
type ComfyNodeDef,
type ComfyInputsSpec as ComfyInputsSpecSchema,
type InputSpec
type InputSpec,
type DescriptionSpec
} from '@/types/apiTypes'
import { defineStore } from 'pinia'
import { ComfyWidgetConstructor } from '@/scripts/widgets'
@@ -158,7 +159,7 @@ export class ComfyNodeDefImpl {
display_name: string
category: string
python_module: string
description: string
description: DescriptionSpec
deprecated: boolean
experimental: boolean
input: ComfyInputsSpec

View File

@@ -2,6 +2,7 @@ import { useModelLibrarySidebarTab } from '@/hooks/sidebarTabs/modelLibrarySideb
import { useNodeLibrarySidebarTab } from '@/hooks/sidebarTabs/nodeLibrarySidebarTab'
import { useQueueSidebarTab } from '@/hooks/sidebarTabs/queueSidebarTab'
import { useWorkflowsSidebarTab } from '@/hooks/sidebarTabs/workflowsSidebarTab'
import { useDocumentationSidebarTab } from '@/hooks/sidebarTabs/documentationSidebarTab'
import { SidebarTabExtension } from '@/types/extensionTypes'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
@@ -57,6 +58,7 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
registerSidebarTab(useNodeLibrarySidebarTab())
registerSidebarTab(useModelLibrarySidebarTab())
registerSidebarTab(useWorkflowsSidebarTab())
registerSidebarTab(useDocumentationSidebarTab())
}
return {

View File

@@ -356,6 +356,11 @@ const zComfyComboOutput = z.array(z.any())
const zComfyOutputTypesSpec = z.array(
z.union([zComfyNodeDataType, zComfyComboOutput])
)
const zDescriptionSpec = z.union([
z.string(),
z.tuple([z.string(), z.string()]),
z.tuple([z.string(), z.string(), z.record(z.string(), z.any())])
])
const zComfyNodeDef = z.object({
input: zComfyInputsSpec.optional(),
@@ -365,7 +370,7 @@ const zComfyNodeDef = z.object({
output_tooltips: z.array(z.string()).optional(),
name: z.string(),
display_name: z.string(),
description: z.string(),
description: zDescriptionSpec,
category: z.string(),
output_node: z.boolean(),
python_module: z.string(),
@@ -378,6 +383,7 @@ export type InputSpec = z.infer<typeof zInputSpec>
export type ComfyInputsSpec = z.infer<typeof zComfyInputsSpec>
export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>
export type DescriptionSpec = z.infer<typeof zDescriptionSpec>
export function validateComfyNodeDef(
data: any,

View File

@@ -1,6 +1,6 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import type { LGraphNode, IWidget } from '@comfyorg/litegraph'
import type { ComfyApp } from '@/scripts/app'
import type { ComfyNodeDef } from '@/types/apiTypes'
import type { ComfyNodeDef, DescriptionSpec } from '@/types/apiTypes'
import type { Keybinding } from '@/types/keyBindingTypes'
import type { ComfyCommand } from '@/stores/commandStore'
import type { SettingParams } from '@/types/settingTypes'
@@ -159,3 +159,9 @@ export interface ComfyExtension {
[key: string]: any
}
export type ComfyNodeItem =
| { node: LGraphNode; type: 'Title' }
| { node: LGraphNode; type: 'Output'; outputSlot: number }
| { node: LGraphNode; type: 'Input'; inputName: string }
| { node: LGraphNode; type: 'Widget'; widget: IWidget }