Implement a legacy canvas widget for vue mode (#6011)

![updated-legacy-widget](https://github.com/user-attachments/assets/3f0a1623-9445-4059-acbb-086baec54980)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6011-Implement-a-legacy-canvas-widget-for-vue-mode-2896d73d36508127a5d1debcccb519a0)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
AustinMroz
2025-10-16 18:50:19 -07:00
committed by GitHub
parent e48e11e434
commit 15b1b91b16
6 changed files with 125 additions and 21 deletions

View File

@@ -2801,7 +2801,7 @@ export class LGraphCanvas
// Widget
const widget = node.getWidgetOnPos(x, y)
if (widget) {
this.#processWidgetClick(e, node, widget)
this.processWidgetClick(e, node, widget)
this.node_widget = [node, widget]
} else {
// Node background
@@ -2981,13 +2981,12 @@ export class LGraphCanvas
this.dirty_canvas = true
}
#processWidgetClick(
processWidgetClick(
e: CanvasPointerEvent,
node: LGraphNode,
widget: IBaseWidget
widget: IBaseWidget,
pointer = this.pointer
) {
const { pointer } = this
// Custom widget - CanvasPointer
if (typeof widget.onPointerDown === 'function') {
const handled = widget.onPointerDown(pointer, node, this)

View File

@@ -363,6 +363,14 @@ export interface IBaseWidget<
lowQuality?: boolean
): void
/**
* Compatibility method for widgets implementing the draw
* method when displayed in non-canvas renderers.
* Set by the current renderer implementation.
* When called, performs a draw operation.
*/
triggerDraw?: () => void
/**
* Compute the size of the widget. Overrides {@link IBaseWidget.computeSize}.
* @param width The width of the widget.

View File

@@ -24,7 +24,7 @@
<!-- Widget Input Slot Dot -->
<div
class="opacity-0 transition-opacity duration-150 group-hover:opacity-100"
class="z-10 opacity-0 transition-opacity duration-150 group-hover:opacity-100"
>
<InputSlot
:slot-data="{
@@ -63,7 +63,8 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue'
import WidgetInputText from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue'
// Import widget components directly
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
import {
getComponent,
shouldRenderAsVue
@@ -129,7 +130,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
const vueComponent =
getComponent(widget.type, widget.name) ||
(widget.isDOMWidget ? WidgetDOM : WidgetInputText)
(widget.isDOMWidget ? WidgetDOM : WidgetLegacy)
const slotMetadata = widget.slotMetadata

View File

@@ -0,0 +1,106 @@
<script setup lang="ts">
import { useResizeObserver } from '@vueuse/core'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const props = defineProps<{
widget: SimplifiedWidget<void>
readonly?: boolean
}>()
const canvasEl = ref()
const canvas: LGraphCanvas = useCanvasStore().canvas as LGraphCanvas
let node: LGraphNode | undefined
let widgetInstance: IBaseWidget | undefined
let pointer: CanvasPointer | undefined
const scaleFactor = 2
onMounted(() => {
node =
canvas?.graph?.getNodeById(
canvasEl.value.parentElement.attributes['node-id'].value
) ?? undefined
if (!node) return
widgetInstance = node.widgets?.find((w) => w.name === props.widget.name)
if (!widgetInstance) return
canvasEl.value.width *= scaleFactor
if (!widgetInstance.triggerDraw)
widgetInstance.callback = useChainCallback(
widgetInstance.callback,
function (this: IBaseWidget) {
this?.triggerDraw?.()
}
)
widgetInstance.triggerDraw = draw
useResizeObserver(canvasEl.value.parentElement, draw)
watch(() => useColorPaletteStore().activePaletteId, draw)
pointer = new CanvasPointer(canvasEl.value)
})
onBeforeUnmount(() => {
if (widgetInstance) widgetInstance.triggerDraw = () => {}
})
function draw() {
if (!widgetInstance || !node) return
const width = canvasEl.value.parentElement.clientWidth
const height = widgetInstance.computeSize
? widgetInstance.computeSize(width)[1]
: 20
widgetInstance.y = 0
canvasEl.value.height = (height + 2) * scaleFactor
canvasEl.value.width = width * scaleFactor
const ctx = canvasEl.value?.getContext('2d')
if (!ctx) return
ctx.scale(scaleFactor, scaleFactor)
widgetInstance.draw?.(ctx, node, width, 1, height)
}
function translateEvent(e: PointerEvent): asserts e is CanvasPointerEvent {
if (!node) return
canvas.adjustMouseEvent(e)
canvas.graph_mouse[0] = e.offsetX + node.pos[0]
canvas.graph_mouse[1] = e.offsetY + node.pos[1]
}
//See LGraphCanvas.processWidgetClick
function handleDown(e: PointerEvent) {
if (!node || !widgetInstance || !pointer) return
translateEvent(e)
pointer.down(e)
if (widgetInstance.mouse)
pointer.onDrag = (e) =>
widgetInstance!.mouse?.(e, [e.offsetX, e.offsetY], node!)
//NOTE: a mouseUp event is already registed under pointer.finally
canvas.processWidgetClick(e, node, widgetInstance, pointer)
}
function handleUp(e: PointerEvent) {
if (!pointer) return
translateEvent(e)
e.click_time = e.timeStamp - (pointer?.eDown?.timeStamp ?? 0)
pointer.up(e)
}
function handleMove(e: PointerEvent) {
if (!pointer) return
translateEvent(e)
pointer.move(e)
}
</script>
<template>
<div class="relative mx-[-12px] min-w-0 basis-0">
<canvas
ref="canvasEl"
class="absolute mt-[-13px] w-full cursor-crosshair"
@pointerdown="handleDown"
@pointerup="handleUp"
@pointermove="handleMove"
/>
</div>
</template>

View File

@@ -14,6 +14,7 @@ import WidgetGalleria from '../components/WidgetGalleria.vue'
import WidgetImageCompare from '../components/WidgetImageCompare.vue'
import WidgetInputNumber from '../components/WidgetInputNumber.vue'
import WidgetInputText from '../components/WidgetInputText.vue'
import WidgetLegacy from '../components/WidgetLegacy.vue'
import WidgetMarkdown from '../components/WidgetMarkdown.vue'
import WidgetMultiSelect from '../components/WidgetMultiSelect.vue'
import WidgetRecordAudio from '../components/WidgetRecordAudio.vue'
@@ -114,6 +115,7 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
'markdown',
{ component: WidgetMarkdown, aliases: ['MARKDOWN'], essential: false }
],
['legacy', { component: WidgetLegacy, aliases: [], essential: true }],
[
'audiorecord',
{
@@ -161,19 +163,11 @@ export const getComponent = (type: string, name: string): Component | null => {
return widgets.get(canonicalType)?.component || null
}
const isSupported = (type: string): boolean => {
const canonicalType = getCanonicalType(type)
return widgets.has(canonicalType)
}
export const isEssential = (type: string): boolean => {
const canonicalType = getCanonicalType(type)
return widgets.get(canonicalType)?.essential || false
}
export const shouldRenderAsVue = (widget: Partial<SafeWidgetData>): boolean => {
if (widget.options?.canvasOnly) return false
if (widget.isDOMWidget) return true
if (!widget.type) return false
return isSupported(widget.type)
return !widget.options?.canvasOnly && !!widget.type
}

View File

@@ -123,10 +123,6 @@ describe('widgetRegistry', () => {
expect(shouldRenderAsVue({ type: 'combo' })).toBe(true)
})
it('should return false for unknown types', () => {
expect(shouldRenderAsVue({ type: 'unknown_type' })).toBe(false)
})
it('should respect options while checking type', () => {
const widget = { type: 'text', options: { someOption: 'value' } }
expect(shouldRenderAsVue(widget)).toBe(true)