Compare commits

..

1 Commits

Author SHA1 Message Date
CodeRabbit Fixer
1c85f9fa35 fix: Optimize sorting performance in useCurveEditor composable (#9115)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:24:06 +01:00
27 changed files with 400 additions and 257 deletions

View File

@@ -905,14 +905,6 @@ export function useCoreCommands(): ComfyCommand[] {
app.canvas.pasteFromClipboard()
}
},
{
id: 'Comfy.Canvas.PasteFromClipboardWithConnect',
icon: 'icon-[lucide--clipboard-paste]',
label: () => t('Paste with Connect'),
function: () => {
app.canvas.pasteFromClipboard({ connectInputs: true })
}
},
{
id: 'Comfy.Canvas.SelectAll',
icon: 'icon-[lucide--lasso-select]',
@@ -927,12 +919,6 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Delete Selected Items',
versionAdded: '1.10.5',
function: () => {
if (app.canvas.selectedItems.size === 0) {
app.canvas.canvas.dispatchEvent(
new CustomEvent('litegraph:no-items-selected', { bubbles: true })
)
return
}
app.canvas.deleteSelected()
app.canvas.setDirty(true, true)
}

View File

@@ -9,6 +9,21 @@ interface UseCurveEditorOptions {
modelValue: Ref<CurvePoint[]>
}
function insertSorted(
points: CurvePoint[],
point: CurvePoint
): [CurvePoint[], number] {
let lo = 0
let hi = points.length
while (lo < hi) {
const mid = (lo + hi) >>> 1
if (points[mid][0] < point[0]) lo = mid + 1
else hi = mid
}
const result = [...points.slice(0, lo), point, ...points.slice(lo)]
return [result, lo]
}
export function useCurveEditor({ svgRef, modelValue }: UseCurveEditorOptions) {
const dragIndex = ref(-1)
let cleanupDrag: (() => void) | null = null
@@ -77,11 +92,10 @@ export function useCurveEditor({ svgRef, modelValue }: UseCurveEditorOptions) {
if (e.ctrlKey) return
const newPoint: CurvePoint = [x, y]
const newPoints: CurvePoint[] = [...modelValue.value, newPoint]
newPoints.sort((a, b) => a[0] - b[0])
const [newPoints, insertIndex] = insertSorted(modelValue.value, newPoint)
modelValue.value = newPoints
startDrag(newPoints.indexOf(newPoint), e)
startDrag(insertIndex, e)
}
function startDrag(index: number, e: PointerEvent) {
@@ -106,11 +120,10 @@ export function useCurveEditor({ svgRef, modelValue }: UseCurveEditorOptions) {
if (dragIndex.value < 0) return
const [x, y] = svgCoords(ev)
const movedPoint: CurvePoint = [x, y]
const newPoints = [...modelValue.value]
newPoints[dragIndex.value] = movedPoint
newPoints.sort((a, b) => a[0] - b[0])
const remaining = modelValue.value.filter((_, i) => i !== dragIndex.value)
const [newPoints, newIndex] = insertSorted(remaining, movedPoint)
modelValue.value = newPoints
dragIndex.value = newPoints.indexOf(movedPoint)
dragIndex.value = newIndex
}
const endDrag = () => {

View File

@@ -189,10 +189,11 @@ export function useWorkflowActionsMenu(
addItem({
id: 'share',
label: t('breadcrumbsMenu.share'),
label: t('menuLabels.Share'),
icon: 'icon-[comfy--send]',
command: async () => {},
visible: false
disabled: true,
visible: isRoot
})
addItem({

View File

@@ -3791,6 +3791,13 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
return
}
private _noItemsSelected(): void {
const event = new CustomEvent('litegraph:no-items-selected', {
bubbles: true
})
this.canvas.dispatchEvent(event)
}
/**
* process a key event
*/
@@ -3835,6 +3842,31 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.node_panel?.close()
this.options_panel?.close()
if (this.node_panel || this.options_panel) block_default = true
} else if (e.keyCode === 65 && e.ctrlKey) {
// select all Control A
this.selectItems()
block_default = true
} else if (e.keyCode === 67 && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
// copy
if (this.selected_nodes) {
this.copyToClipboard()
block_default = true
}
} else if (e.keyCode === 86 && (e.metaKey || e.ctrlKey)) {
// paste
this.pasteFromClipboard({ connectInputs: e.shiftKey })
} else if (e.key === 'Delete' || e.key === 'Backspace') {
// delete or backspace
// @ts-expect-error EventTarget.localName is not in standard types
if (e.target.localName != 'input' && e.target.localName != 'textarea') {
if (this.selectedItems.size === 0) {
this._noItemsSelected()
return
}
this.deleteSelected()
block_default = true
}
}
// TODO
@@ -5494,12 +5526,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
* @todo Split tooltip from hover, so it can be drawn / eased separately
*/
drawLinkTooltip(ctx: CanvasRenderingContext2D, link: LinkSegment): void {
ctx.save()
const pos = link._pos
ctx.fillStyle = 'black'
ctx.beginPath()
if (this.linkMarkerShape === LinkMarkerShape.Arrow) {
ctx.save()
const transform = ctx.getTransform()
ctx.translate(pos[0], pos[1])
// Assertion: Number.isFinite guarantees this is a number.
if (Number.isFinite(link._centreAngle))
@@ -5507,7 +5538,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
ctx.moveTo(-2, -3)
ctx.lineTo(+4, 0)
ctx.lineTo(-2, +3)
ctx.restore()
ctx.setTransform(transform)
} else if (
this.linkMarkerShape == null ||
this.linkMarkerShape === LinkMarkerShape.Circle
@@ -5518,16 +5549,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// @ts-expect-error TODO: Better value typing
const { data } = link
if (data == null) {
ctx.restore()
return
}
if (data == null) return
// @ts-expect-error TODO: Better value typing
if (this.onDrawLinkTooltip?.(ctx, link, this) == true) {
ctx.restore()
return
}
if (this.onDrawLinkTooltip?.(ctx, link, this) == true) return
let text: string | null = null
@@ -5537,10 +5562,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
else if (data.toToolTip) text = data.toToolTip()
else text = `[${data.constructor.name}]`
if (text == null) {
ctx.restore()
return
}
if (text == null) return
// Hard-coded tooltip limit
text = text.substring(0, 30)
@@ -5564,7 +5586,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
ctx.textAlign = 'center'
ctx.fillStyle = '#CEC'
ctx.fillText(text, pos[0], pos[1] - 15 - h * 0.3)
ctx.restore()
}
/**

View File

@@ -91,7 +91,7 @@ export function strokeShape(
}
// Set up context
ctx.save()
const { lineWidth, strokeStyle } = ctx
ctx.lineWidth = thickness
ctx.globalAlpha = 0.8
ctx.strokeStyle = color
@@ -138,7 +138,12 @@ export function strokeShape(
// Stroke the shape
ctx.stroke()
ctx.restore()
// Reset context
ctx.lineWidth = lineWidth
ctx.strokeStyle = strokeStyle
// TODO: Store and reset value properly. Callers currently expect this behaviour (e.g. muted nodes).
ctx.globalAlpha = 1
}
/**
@@ -211,24 +216,18 @@ export function drawTextInArea({
}: IDrawTextInAreaOptions) {
const { left, right, bottom, width, centreX } = area
ctx.save()
// Text already fits
const fullWidth = ctx.measureText(text).width
if (fullWidth <= width) {
ctx.textAlign = align
const x = align === 'left' ? left : align === 'right' ? right : centreX
ctx.fillText(text, x, bottom)
ctx.restore()
return
}
// Need to truncate text
const truncated = truncateTextToWidth(ctx, text, width)
if (truncated.length === 0) {
ctx.restore()
return
}
if (truncated.length === 0) return
// Draw text - left-aligned to prevent bouncing during resize
ctx.textAlign = 'left'
@@ -239,6 +238,4 @@ export function drawTextInArea({
ctx.textAlign = 'right'
const ellipsis = truncated.at(-1)!
ctx.fillText(ellipsis, right, bottom, ctx.measureText(ellipsis).width * 0.75)
ctx.restore()
}

View File

@@ -318,9 +318,15 @@ export abstract class SubgraphIONodeBase<
| SubgraphOutput,
editorAlpha?: number
): void {
ctx.save()
const { lineWidth, strokeStyle, fillStyle, font, textBaseline } = ctx
this.drawProtected(ctx, colorContext, fromSlot, editorAlpha)
ctx.restore()
Object.assign(ctx, {
lineWidth,
strokeStyle,
fillStyle,
font,
textBaseline
})
}
/** @internal Leaves {@link ctx} dirty. */

View File

@@ -39,8 +39,8 @@ export class AssetWidget
options: DrawWidgetOptions
) {
const { width, showText = true } = options
ctx.save()
// Store original context attributes
const { fillStyle, strokeStyle, textAlign } = ctx
this.drawWidgetShape(ctx, options)
@@ -48,7 +48,8 @@ export class AssetWidget
this.drawTruncatingText({ ctx, width, leftPadding: 0, rightPadding: 0 })
}
ctx.restore()
// Restore original context attributes
Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
}
override onClick() {

View File

@@ -62,7 +62,8 @@ export abstract class BaseSteppedWidget<
ctx: CanvasRenderingContext2D,
options: DrawWidgetOptions
) {
ctx.save()
// Store original context attributes
const { fillStyle, strokeStyle, textAlign } = ctx
this.drawWidgetShape(ctx, options)
if (options.showText) {
@@ -71,6 +72,7 @@ export abstract class BaseSteppedWidget<
this.drawTruncatingText({ ctx, width: options.width })
}
ctx.restore()
// Restore original context attributes
Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
}
}

View File

@@ -13,8 +13,6 @@ export class BooleanWidget
ctx: CanvasRenderingContext2D,
options: DrawWidgetOptions
) {
ctx.save()
const { width, showText = true } = options
const { height, y } = this
const { margin } = BaseWidget
@@ -30,8 +28,6 @@ export class BooleanWidget
this.drawLabel(ctx, margin * 2)
this.drawValue(ctx, width - 40)
}
ctx.restore()
}
drawLabel(ctx: CanvasRenderingContext2D, x: number): void {

View File

@@ -25,7 +25,8 @@ export class ButtonWidget
ctx: CanvasRenderingContext2D,
{ width, showText = true, suppressPromotedOutline }: DrawWidgetOptions
) {
ctx.save()
// Store original context attributes
const { fillStyle, strokeStyle, textAlign } = ctx
const { height, y } = this
const { margin } = BaseWidget
@@ -47,7 +48,8 @@ export class ButtonWidget
// Draw button text
if (showText) this.drawLabel(ctx, width * 0.5)
ctx.restore()
// Restore original context attributes
Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
}
drawLabel(ctx: CanvasRenderingContext2D, x: number): void {

View File

@@ -1,3 +1,5 @@
import { t } from '@/i18n'
import type { IChartWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -13,7 +15,32 @@ export class ChartWidget
override type = 'chart' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'Chart')
const { width } = options
const { y, height } = this
const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = `Chart: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
}
onClick(_options: WidgetEventOptions): void {

View File

@@ -29,7 +29,7 @@ export class ColorWidget
override type = 'color' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
ctx.save()
const { fillStyle, strokeStyle, textAlign } = ctx
this.drawWidgetShape(ctx, options)
@@ -62,7 +62,7 @@ export class ColorWidget
ctx.textAlign = 'right'
ctx.fillText(this.value || '#000000', swatchX - 8, y + height * 0.7)
ctx.restore()
Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
}
onClick({ e, node, canvas }: WidgetEventOptions): void {

View File

@@ -1,3 +1,5 @@
import { t } from '@/i18n'
import type { IFileUploadWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -13,7 +15,32 @@ export class FileUploadWidget
override type = 'fileupload' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'Fileupload')
const { width } = options
const { y, height } = this
const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = `Fileupload: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
}
onClick(_options: WidgetEventOptions): void {

View File

@@ -1,3 +1,5 @@
import { t } from '@/i18n'
import type { IGalleriaWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -13,7 +15,32 @@ export class GalleriaWidget
override type = 'galleria' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'Galleria')
const { width } = options
const { y, height } = this
const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = `Galleria: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
}
onClick(_options: WidgetEventOptions): void {

View File

@@ -1,3 +1,5 @@
import { t } from '@/i18n'
import type { IImageCompareWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -13,7 +15,32 @@ export class ImageCompareWidget
override type = 'imagecompare' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'ImageCompare')
const { width } = options
const { y, height } = this
const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = `ImageCompare: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
}
onClick(_options: WidgetEventOptions): void {

View File

@@ -35,7 +35,8 @@ export class KnobWidget extends BaseWidget<IKnobWidget> implements IKnobWidget {
ctx: CanvasRenderingContext2D,
{ width, showText = true, suppressPromotedOutline }: DrawWidgetOptions
): void {
ctx.save()
// Store original context attributes
const { fillStyle, strokeStyle, textAlign } = ctx
const { y } = this
const { margin } = BaseWidget
@@ -176,7 +177,8 @@ export class KnobWidget extends BaseWidget<IKnobWidget> implements IKnobWidget {
)
}
ctx.restore()
// Restore original context attributes
Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
}
onClick(): void {

View File

@@ -1,3 +1,5 @@
import { t } from '@/i18n'
import type { IMarkdownWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -13,7 +15,32 @@ export class MarkdownWidget
override type = 'markdown' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'Markdown')
const { width } = options
const { y, height } = this
const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = `Markdown: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
}
onClick(_options: WidgetEventOptions): void {

View File

@@ -1,3 +1,5 @@
import { t } from '@/i18n'
import type { IMultiSelectWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -13,7 +15,32 @@ export class MultiSelectWidget
override type = 'multiselect' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'MultiSelect')
const { width } = options
const { y, height } = this
const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = `MultiSelect: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
}
onClick(_options: WidgetEventOptions): void {

View File

@@ -1,3 +1,5 @@
import { t } from '@/i18n'
import type { ISelectButtonWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -13,7 +15,32 @@ export class SelectButtonWidget
override type = 'selectbutton' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'SelectButton')
const { width } = options
const { y, height } = this
const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = `SelectButton: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
}
onClick(_options: WidgetEventOptions): void {

View File

@@ -22,7 +22,8 @@ export class SliderWidget
ctx: CanvasRenderingContext2D,
{ width, showText = true, suppressPromotedOutline }: DrawWidgetOptions
) {
ctx.save()
// Store original context attributes
const { fillStyle, strokeStyle, textAlign } = ctx
const { height, y } = this
const { margin } = BaseWidget
@@ -66,7 +67,8 @@ export class SliderWidget
)
}
ctx.restore()
// Restore original context attributes
Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
}
/**

View File

@@ -24,8 +24,8 @@ export class TextWidget
options: DrawWidgetOptions
) {
const { width, showText = true } = options
ctx.save()
// Store original context attributes
const { fillStyle, strokeStyle, textAlign } = ctx
this.drawWidgetShape(ctx, options)
@@ -33,7 +33,8 @@ export class TextWidget
this.drawTruncatingText({ ctx, width, leftPadding: 0, rightPadding: 0 })
}
ctx.restore()
// Restore original context attributes
Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
}
override onClick({ e, node, canvas }: WidgetEventOptions) {

View File

@@ -1262,7 +1262,6 @@
"Move Selected Nodes Right": "Move Selected Nodes Right",
"Move Selected Nodes Up": "Move Selected Nodes Up",
"Paste": "Paste",
"Paste with Connect": "Paste with Connect",
"Reset View": "Reset View",
"Resize Selected Nodes": "Resize Selected Nodes",
"Select All": "Select All",
@@ -2604,8 +2603,7 @@
"deleteWorkflow": "Delete Workflow",
"deleteBlueprint": "Delete Blueprint",
"enterNewName": "Enter new name",
"missingNodesWarning": "Workflow contains unsupported nodes (highlighted red).",
"share": "Share"
"missingNodesWarning": "Workflow contains unsupported nodes (highlighted red)."
},
"shortcuts": {
"shortcuts": "Shortcuts",

View File

@@ -208,52 +208,5 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
key: 'Escape'
},
commandId: 'Comfy.Graph.ExitSubgraph'
},
{
combo: {
ctrl: true,
key: 'a'
},
commandId: 'Comfy.Canvas.SelectAll',
targetElementId: 'graph-canvas-container'
},
{
combo: {
ctrl: true,
key: 'c'
},
commandId: 'Comfy.Canvas.CopySelected',
targetElementId: 'graph-canvas-container'
},
{
combo: {
ctrl: true,
key: 'v'
},
commandId: 'Comfy.Canvas.PasteFromClipboard',
targetElementId: 'graph-canvas-container'
},
{
combo: {
ctrl: true,
shift: true,
key: 'v'
},
commandId: 'Comfy.Canvas.PasteFromClipboardWithConnect',
targetElementId: 'graph-canvas-container'
},
{
combo: {
key: 'Delete'
},
commandId: 'Comfy.Canvas.DeleteSelectedItems',
targetElementId: 'graph-canvas-container'
},
{
combo: {
key: 'Backspace'
},
commandId: 'Comfy.Canvas.DeleteSelectedItems',
targetElementId: 'graph-canvas-container'
}
]

View File

@@ -1,11 +1,22 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
vi.mock('@/scripts/app', () => {
return {
app: {
canvas: {
processKey: vi.fn()
}
}
}
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(() => [])
@@ -25,15 +36,13 @@ function createTestKeyboardEvent(
ctrlKey?: boolean
altKey?: boolean
metaKey?: boolean
shiftKey?: boolean
} = {}
): KeyboardEvent {
const {
target = document.body,
ctrlKey = false,
altKey = false,
metaKey = false,
shiftKey = false
metaKey = false
} = options
const event = new KeyboardEvent('keydown', {
@@ -41,7 +50,6 @@ function createTestKeyboardEvent(
ctrlKey,
altKey,
metaKey,
shiftKey,
bubbles: true,
cancelable: true
})
@@ -52,10 +60,8 @@ function createTestKeyboardEvent(
return event
}
describe('keybindingService - Canvas Keybindings', () => {
describe('keybindingService - Event Forwarding', () => {
let keybindingService: ReturnType<typeof useKeybindingService>
let canvasContainer: HTMLDivElement
let canvasChild: HTMLCanvasElement
beforeEach(() => {
vi.clearAllMocks()
@@ -70,156 +76,94 @@ describe('keybindingService - Canvas Keybindings', () => {
typeof useDialogStore
>)
canvasContainer = document.createElement('div')
canvasContainer.id = 'graph-canvas-container'
canvasChild = document.createElement('canvas')
canvasContainer.appendChild(canvasChild)
document.body.appendChild(canvasContainer)
keybindingService = useKeybindingService()
keybindingService.registerCoreKeybindings()
})
afterEach(() => {
canvasContainer.remove()
})
it('should execute DeleteSelectedItems for Delete key on canvas', async () => {
const event = createTestKeyboardEvent('Delete', {
target: canvasChild
})
it('should forward Delete key to canvas when no keybinding exists', async () => {
const event = createTestKeyboardEvent('Delete')
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.DeleteSelectedItems'
)
expect(vi.mocked(app.canvas.processKey)).toHaveBeenCalledWith(event)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
})
it('should execute DeleteSelectedItems for Backspace key on canvas', async () => {
const event = createTestKeyboardEvent('Backspace', {
target: canvasChild
})
it('should forward Backspace key to canvas when no keybinding exists', async () => {
const event = createTestKeyboardEvent('Backspace')
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.DeleteSelectedItems'
)
expect(vi.mocked(app.canvas.processKey)).toHaveBeenCalledWith(event)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
})
it('should not execute DeleteSelectedItems when typing in input field', async () => {
it('should not forward Delete key when typing in input field', async () => {
const inputElement = document.createElement('input')
const event = createTestKeyboardEvent('Delete', { target: inputElement })
await keybindingService.keybindHandler(event)
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
})
it('should not execute DeleteSelectedItems when typing in textarea', async () => {
it('should not forward Delete key when typing in textarea', async () => {
const textareaElement = document.createElement('textarea')
const event = createTestKeyboardEvent('Delete', {
target: textareaElement
})
const event = createTestKeyboardEvent('Delete', { target: textareaElement })
await keybindingService.keybindHandler(event)
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
})
it('should execute SelectAll for Ctrl+A on canvas', async () => {
const event = createTestKeyboardEvent('a', {
ctrlKey: true,
target: canvasChild
})
it('should not forward Delete key when canvas processKey is not available', async () => {
// Temporarily replace processKey with undefined - testing edge case
const originalProcessKey = vi.mocked(app.canvas).processKey
vi.mocked(app.canvas).processKey = undefined!
await keybindingService.keybindHandler(event)
const event = createTestKeyboardEvent('Delete')
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.SelectAll'
)
try {
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
} finally {
// Restore processKey for other tests
vi.mocked(app.canvas).processKey = originalProcessKey
}
})
it('should execute CopySelected for Ctrl+C on canvas', async () => {
const event = createTestKeyboardEvent('c', {
ctrlKey: true,
target: canvasChild
})
it('should not forward Delete key when canvas is not available', async () => {
const originalCanvas = vi.mocked(app).canvas
vi.mocked(app).canvas = null!
await keybindingService.keybindHandler(event)
const event = createTestKeyboardEvent('Delete')
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.CopySelected'
)
try {
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
} finally {
// Restore canvas for other tests
vi.mocked(app).canvas = originalCanvas
}
})
it('should execute PasteFromClipboard for Ctrl+V on canvas', async () => {
const event = createTestKeyboardEvent('v', {
ctrlKey: true,
target: canvasChild
})
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.PasteFromClipboard'
)
})
it('should execute PasteFromClipboardWithConnect for Ctrl+Shift+V on canvas', async () => {
const event = createTestKeyboardEvent('v', {
ctrlKey: true,
shiftKey: true,
target: canvasChild
})
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.PasteFromClipboardWithConnect'
)
})
it('should execute graph-canvas bindings by normalizing to graph-canvas-container', async () => {
const event = createTestKeyboardEvent('=', {
altKey: true,
target: canvasChild
})
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.ZoomIn'
)
})
it('should not execute graph-canvas bindings when target is outside canvas', async () => {
const outsideDiv = document.createElement('div')
document.body.appendChild(outsideDiv)
const event = createTestKeyboardEvent('=', {
altKey: true,
target: outsideDiv
})
it('should not forward non-canvas keys', async () => {
const event = createTestKeyboardEvent('Enter')
await keybindingService.keybindHandler(event)
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
outsideDiv.remove()
})
it('should not execute canvas commands when target is outside canvas container', async () => {
const outsideDiv = document.createElement('div')
document.body.appendChild(outsideDiv)
const event = createTestKeyboardEvent('Delete', {
target: outsideDiv
})
it('should not forward when modifier keys are pressed', async () => {
const event = createTestKeyboardEvent('Delete', { ctrlKey: true })
await keybindingService.keybindHandler(event)
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
outsideDiv.remove()
})
})

View File

@@ -1,5 +1,6 @@
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
@@ -14,6 +15,16 @@ export function useKeybindingService() {
const settingStore = useSettingStore()
const dialogStore = useDialogStore()
function shouldForwardToCanvas(event: KeyboardEvent): boolean {
if (event.ctrlKey || event.altKey || event.metaKey) {
return false
}
const canvasKeys = ['Delete', 'Backspace']
return canvasKeys.includes(event.key)
}
async function keybindHandler(event: KeyboardEvent) {
const keyCombo = KeyComboImpl.fromEvent(event)
if (keyCombo.isModifier) {
@@ -33,17 +44,7 @@ export function useKeybindingService() {
}
const keybinding = keybindingStore.getKeybinding(keyCombo)
if (keybinding) {
const targetElementId =
keybinding.targetElementId === 'graph-canvas'
? 'graph-canvas-container'
: keybinding.targetElementId
if (targetElementId) {
const container = document.getElementById(targetElementId)
if (!container?.contains(target)) {
return
}
}
if (keybinding && keybinding.targetElementId !== 'graph-canvas') {
if (
event.key === 'Escape' &&
!event.ctrlKey &&
@@ -73,6 +74,18 @@ export function useKeybindingService() {
return
}
if (!keybinding && shouldForwardToCanvas(event)) {
const canvas = app.canvas
if (
canvas &&
canvas.processKey &&
typeof canvas.processKey === 'function'
) {
canvas.processKey(event)
return
}
}
if (event.ctrlKey || event.altKey || event.metaKey) {
return
}

View File

@@ -432,7 +432,7 @@ export class CanvasPathRenderer {
const angle = Math.atan2(posB.y - posA.y, posB.x - posA.x)
// Draw arrow triangle (matching original shape)
ctx.save()
const transform = ctx.getTransform()
ctx.translate(posA.x, posA.y)
ctx.rotate(angle)
ctx.fillStyle = color
@@ -441,7 +441,7 @@ export class CanvasPathRenderer {
ctx.lineTo(0, +7)
ctx.lineTo(+5, -3)
ctx.fill()
ctx.restore()
ctx.setTransform(transform)
}
}
@@ -803,19 +803,20 @@ export class CanvasPathRenderer {
): void {
if (!link.centerPos) return
ctx.save()
ctx.beginPath()
if (
context.style.centerMarkerShape === 'arrow' &&
link.centerAngle !== undefined
) {
const transform = ctx.getTransform()
ctx.translate(link.centerPos.x, link.centerPos.y)
ctx.rotate(link.centerAngle)
// The math is off, but it currently looks better in chromium (from original)
ctx.moveTo(-3.2, -5)
ctx.lineTo(7, 0)
ctx.lineTo(-3.2, 5)
ctx.setTransform(transform)
} else {
// Default to circle
ctx.arc(link.centerPos.x, link.centerPos.y, 5, 0, Math.PI * 2)
@@ -823,14 +824,15 @@ export class CanvasPathRenderer {
// Apply disabled pattern or color
if (link.disabled && context.patterns?.disabled) {
const { fillStyle, globalAlpha } = ctx
ctx.fillStyle = context.patterns.disabled
ctx.globalAlpha = 0.75
ctx.fill()
ctx.globalAlpha = globalAlpha
ctx.fillStyle = fillStyle
} else {
ctx.fillStyle = color
ctx.fill()
}
ctx.restore()
}
}

View File

@@ -676,6 +676,20 @@ export class ComfyApp {
e.stopImmediatePropagation()
return
}
// Ctrl+C Copy
if (e.key === 'c' && (e.metaKey || e.ctrlKey)) {
return
}
// Ctrl+V Paste
if (
(e.key === 'v' || e.key == 'V') &&
(e.metaKey || e.ctrlKey) &&
!e.shiftKey
) {
return
}
}
// Fall through to Litegraph defaults