Feat: Add preview as plaintext toggle for Preview As Text (#7102)

## Summary

Adds a toggle to conditionally render the text as Markdown.

## Also...

Fixes some type issues across our myriad Widget types. We should
probably clean those up.

## Example


https://github.com/user-attachments/assets/24fed943-1e79-4ea4-a962-826b06d68761



This could be a good minimal testcase for dynamic widgets @AustinMroz

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7102-WIP-Feat-Add-preview-as-plaintext-toggle-for-Preview-As-Text-2bd6d73d3650810c8b25c84866c8875c)
by [Unito](https://www.unito.io)
This commit is contained in:
Alexander Brown
2025-12-02 14:37:57 -08:00
committed by GitHub
parent e887d69cdc
commit c263111eeb
8 changed files with 107 additions and 57 deletions

View File

@@ -10,7 +10,10 @@ import type {
INodeInputSlot, INodeInputSlot,
INodeOutputSlot INodeOutputSlot
} from '@/lib/litegraph/src/interfaces' } from '@/lib/litegraph/src/interfaces'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations' import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types' import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeId } from '@/renderer/core/layout/types' import type { NodeId } from '@/renderer/core/layout/types'
@@ -39,7 +42,7 @@ export interface SafeWidgetData {
type: string type: string
value: WidgetValue value: WidgetValue
label?: string label?: string
options?: Record<string, unknown> options?: IWidgetOptions<unknown>
callback?: ((value: unknown) => void) | undefined callback?: ((value: unknown) => void) | undefined
spec?: InputSpec spec?: InputSpec
slotMetadata?: WidgetSlotMetadata slotMetadata?: WidgetSlotMetadata
@@ -107,7 +110,7 @@ export function safeWidgetMapper(
type: widget.type, type: widget.type,
value: value, value: value,
label: widget.label, label: widget.label,
options: widget.options ? { ...widget.options } : undefined, options: widget.options,
callback: widget.callback, callback: widget.callback,
spec, spec,
slotMetadata: slotInfo, slotMetadata: slotInfo,

View File

@@ -24,12 +24,43 @@ useExtensionService().registerExtension({
app app
).widget as DOMWidget<HTMLTextAreaElement, string> ).widget as DOMWidget<HTMLTextAreaElement, string>
showValueWidget.options.read_only = true const showValueWidgetPlain = ComfyWidgets['STRING'](
this,
'preview',
['STRING', { multiline: true }],
app
).widget as DOMWidget<HTMLTextAreaElement, string>
const showAsPlaintextWidget = ComfyWidgets['BOOLEAN'](
this,
'previewMode',
[
'BOOLEAN',
{ label_on: 'Markdown', label_off: 'Plaintext', default: false }
],
app
)
showAsPlaintextWidget.widget.callback = (value) => {
showValueWidget.hidden = !value
showValueWidget.options.hidden = !value
showValueWidgetPlain.hidden = value
showValueWidgetPlain.options.hidden = value
}
showValueWidget.hidden = true
showValueWidget.options.hidden = true
showValueWidget.options.read_only = true
showValueWidget.element.readOnly = true showValueWidget.element.readOnly = true
showValueWidget.element.disabled = true showValueWidget.element.disabled = true
showValueWidget.serialize = false showValueWidget.serialize = false
showValueWidgetPlain.hidden = false
showValueWidgetPlain.options.hidden = false
showValueWidgetPlain.options.read_only = true
showValueWidgetPlain.element.readOnly = true
showValueWidgetPlain.element.disabled = true
showValueWidgetPlain.serialize = false
} }
const onExecuted = nodeType.prototype.onExecuted const onExecuted = nodeType.prototype.onExecuted
@@ -39,9 +70,10 @@ useExtensionService().registerExtension({
? void 0 ? void 0
: onExecuted.apply(this, [message]) : onExecuted.apply(this, [message])
const previewWidget = this.widgets?.find((w) => w.name === 'preview') const previewWidgets =
this.widgets?.filter((w) => w.name === 'preview') ?? []
if (previewWidget) { for (const previewWidget of previewWidgets) {
previewWidget.value = message.text[0] previewWidget.value = message.text[0]
} }
} }

View File

@@ -14,7 +14,11 @@ import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputSpec } from '@/schemas/nodeDefSchema' import type { InputSpec } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app' import { app } from '@/scripts/app'
import { ComfyWidgets, addValueControlWidgets } from '@/scripts/widgets' import {
ComfyWidgets,
addValueControlWidgets,
isValidWidgetType
} from '@/scripts/widgets'
import { CONFIG, GET_CONFIG } from '@/services/litegraphService' import { CONFIG, GET_CONFIG } from '@/services/litegraphService'
import { mergeInputSpec } from '@/utils/nodeDefUtil' import { mergeInputSpec } from '@/utils/nodeDefUtil'
import { applyTextReplacements } from '@/utils/searchAndReplace' import { applyTextReplacements } from '@/utils/searchAndReplace'
@@ -223,8 +227,8 @@ export class PrimitiveNode extends LGraphNode {
// Store current size as addWidget resizes the node // Store current size as addWidget resizes the node
const [oldWidth, oldHeight] = this.size const [oldWidth, oldHeight] = this.size
let widget: IBaseWidget | undefined let widget: IBaseWidget
if (type in ComfyWidgets) { if (isValidWidgetType(type)) {
widget = (ComfyWidgets[type](this, 'value', inputData, app) || {}).widget widget = (ComfyWidgets[type](this, 'value', inputData, app) || {}).widget
} else { } else {
// @ts-expect-error InputSpec is not typed correctly // @ts-expect-error InputSpec is not typed correctly

View File

@@ -4027,7 +4027,9 @@ export class LGraphNode
w: IBaseWidget w: IBaseWidget
}[] = [] }[] = []
for (const w of this.widgets) { const visibleWidgets = this.widgets.filter((w) => !w.hidden)
for (const w of visibleWidgets) {
if (w.computeSize) { if (w.computeSize) {
const height = w.computeSize()[1] + 4 const height = w.computeSize()[1] + 4
w.computedHeight = height w.computedHeight = height
@@ -4066,7 +4068,7 @@ export class LGraphNode
// Position widgets // Position widgets
let y = startY let y = startY
for (const w of this.widgets) { for (const w of visibleWidgets) {
w.y = y w.y = y
y += w.computedHeight ?? 0 y += w.computedHeight ?? 0
} }

View File

@@ -60,7 +60,6 @@ const nodeData = computed<VueNodeData>(() => {
? input.options[0] ? input.options[0]
: '', : '',
options: { options: {
...input,
hidden: input.hidden, hidden: input.hidden,
advanced: input.advanced, advanced: input.advanced,
values: input.type === 'COMBO' ? input.options : undefined // For combo widgets values: input.type === 'COMBO' ? input.options : undefined // For combo widgets

View File

@@ -16,46 +16,51 @@
@pointermove="handleWidgetPointerEvent" @pointermove="handleWidgetPointerEvent"
@pointerup="handleWidgetPointerEvent" @pointerup="handleWidgetPointerEvent"
> >
<div <template
v-for="(widget, index) in processedWidgets" v-for="(widget, index) in processedWidgets"
:key="`widget-${index}-${widget.name}`" :key="`widget-${index}-${widget.name}`"
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch has-[.widget-expands]:flex-1"
> >
<!-- Widget Input Slot Dot -->
<div <div
:class=" v-if="!widget.simplified.options?.hidden"
cn( :data-is-hidden="`hidden: ${widget.simplified.options?.hidden}`"
'z-10 w-3 opacity-0 transition-opacity duration-150 group-hover:opacity-100 flex items-center', class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch has-[.widget-expands]:flex-1"
widget.slotMetadata?.linked && 'opacity-100'
)
"
> >
<InputSlot <!-- Widget Input Slot Dot -->
v-if="widget.slotMetadata"
:slot-data="{ <div
name: widget.name, :class="
type: widget.type, cn(
boundingRect: [0, 0, 0, 0] 'z-10 w-3 opacity-0 transition-opacity duration-150 group-hover:opacity-100 flex items-center',
}" widget.slotMetadata?.linked && 'opacity-100'
)
"
>
<InputSlot
v-if="widget.slotMetadata"
:slot-data="{
name: widget.name,
type: widget.type,
boundingRect: [0, 0, 0, 0]
}"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:index="widget.slotMetadata.index"
:socketless="widget.simplified.spec?.socketless"
dot-only
/>
</div>
<!-- Widget Component -->
<component
:is="widget.vueComponent"
v-tooltip.left="widget.tooltipConfig"
:widget="widget.simplified"
:model-value="widget.value"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''" :node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:index="widget.slotMetadata.index" :node-type="nodeType"
:socketless="widget.simplified.spec?.socketless" class="flex-1 col-span-2"
dot-only @update:model-value="widget.updateHandler"
/> />
</div> </div>
<!-- Widget Component --> </template>
<component
:is="widget.vueComponent"
v-tooltip.left="widget.tooltipConfig"
:widget="widget.simplified"
:model-value="widget.value"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:node-type="nodeType"
class="flex-1 col-span-2"
@update:model-value="widget.updateHandler"
/>
</div>
</div> </div>
</template> </template>
@@ -132,25 +137,20 @@ const processedWidgets = computed((): ProcessedWidget[] => {
const result: ProcessedWidget[] = [] const result: ProcessedWidget[] = []
for (const widget of widgets) { for (const widget of widgets) {
// Skip if widget is in the hidden list for this node type
if (widget.options?.hidden) continue
if (widget.options?.canvasOnly) continue
if (!widget.type) continue
if (!shouldRenderAsVue(widget)) continue if (!shouldRenderAsVue(widget)) continue
const vueComponent = const vueComponent =
getComponent(widget.type, widget.name) || getComponent(widget.type, widget.name) ||
(widget.isDOMWidget ? WidgetDOM : WidgetLegacy) (widget.isDOMWidget ? WidgetDOM : WidgetLegacy)
const slotMetadata = widget.slotMetadata const { slotMetadata, options } = widget
let widgetOptions = widget.options
// Core feature: Disable Vue widgets when their input slots are connected // Core feature: Disable Vue widgets when their input slots are connected
// This prevents conflicting input sources - when a slot is linked to another // This prevents conflicting input sources - when a slot is linked to another
// node's output, the widget should be read-only to avoid data conflicts // node's output, the widget should be read-only to avoid data conflicts
if (slotMetadata?.linked) { const widgetOptions = slotMetadata?.linked
widgetOptions = { ...widget.options, disabled: true } ? { ...options, disabled: true }
} : options
const simplified: SimplifiedWidget = { const simplified: SimplifiedWidget = {
name: widget.name, name: widget.name,
@@ -162,7 +162,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
spec: widget.spec spec: widget.spec
} }
const updateHandler = (value: WidgetValue) => { function updateHandler(value: WidgetValue) {
// Update the widget value directly // Update the widget value directly
widget.value = value widget.value = value

View File

@@ -285,7 +285,7 @@ export function addValueControlWidgets(
return widgets return widgets
} }
export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = { export const ComfyWidgets = {
INT: transformWidgetConstructorV2ToV1(useIntWidget()), INT: transformWidgetConstructorV2ToV1(useIntWidget()),
FLOAT: transformWidgetConstructorV2ToV1(useFloatWidget()), FLOAT: transformWidgetConstructorV2ToV1(useFloatWidget()),
BOOLEAN: transformWidgetConstructorV2ToV1(useBooleanWidget()), BOOLEAN: transformWidgetConstructorV2ToV1(useBooleanWidget()),
@@ -299,4 +299,10 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
GALLERIA: transformWidgetConstructorV2ToV1(useGalleriaWidget()), GALLERIA: transformWidgetConstructorV2ToV1(useGalleriaWidget()),
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget()), TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget()),
...dynamicWidgets ...dynamicWidgets
} as const
export function isValidWidgetType(
key: unknown
): key is keyof typeof ComfyWidgets {
return ComfyWidgets[key as keyof typeof ComfyWidgets] !== undefined
} }

View File

@@ -6,6 +6,7 @@ import {
shouldRenderAsVue, shouldRenderAsVue,
FOR_TESTING FOR_TESTING
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry' } from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
const { const {
WidgetAudioUI, WidgetAudioUI,
@@ -121,7 +122,10 @@ describe('widgetRegistry', () => {
}) })
it('should respect options while checking type', () => { it('should respect options while checking type', () => {
const widget = { type: 'text', options: { someOption: 'value' } } const widget: Partial<SafeWidgetData> = {
type: 'text',
options: { precision: 5 }
}
expect(shouldRenderAsVue(widget)).toBe(true) expect(shouldRenderAsVue(widget)).toBe(true)
}) })
}) })