Compare commits

...

4 Commits

Author SHA1 Message Date
CodeRabbit Fixer
6db4c9bd47 fix: fix(useChart): deep watch for data mutations and rebuild on chart type change (#9804)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:05:38 +01:00
Christian Byrne
8b53d5c807 fix: preserve input asset previews across execution updates (#9123)
## Summary

Fix input asset previews (images/videos) disappearing from
LoadImage/LoadVideo nodes after execution and a browser tab switch.

## Changes

- **What**: Guard `setOutputsByLocatorId` in `imagePreviewStore` to
preserve existing input-type preview images (`type: 'input'`) when the
incoming execution output has no images. Execution outputs with actual
images still overwrite as expected.

## Review Focus

- The guard only applies when existing output is an input preview (`type
=== 'input'` for all images) AND incoming output has no images — this is
the exact scenario where execution clobbers upload widget previews.
- Root cause: execution results from the backend overwrite the upload
widget's synthetic preview for LoadImage/LoadVideo nodes (which produce
no output images). Combined with the deferred resize-observer
re-observation from PR #8805, returning to a hidden tab reads the
now-empty store entry.
2026-03-12 08:50:14 -07:00
Dante
39ce4a23cc fix: skip node metadata paste when media node is selected (#9773)
## Summary

- When a media node (LoadImage/LoadAudio/LoadVideo) is selected and the
clipboard contains stale node metadata from a prior Ctrl+C, pasting
skips the node-metadata deserialization so that the paste falls through
to litegraph's default handler instead of incorrectly pasting the old
copied node.
- Fixes Comfy-Org/ComfyUI#12896

## Root Cause

The paste handler in `usePaste.ts` checks clipboard `text/html` for
`data-metadata` (serialized node data) **before** falling through to
litegraph's default paste. When a user copies a node, then copies a web
image, the browser clipboard may retain the old `data-metadata` in
`text/html` while the image data is not available as a
`DataTransferItem` file. This causes the stale node to be pasted instead
of the image.

## Fix

Skip `pasteClipboardItems()` when a media node is selected, allowing the
paste to fall through to litegraph's default handler which can handle
the clipboard content appropriately.

## Test plan

- [x] Added unit test verifying node metadata paste is skipped when
media node is selected
- [x] Manual: Copy a node → copy a web image → select LoadImage node →
Ctrl+V → verify image is pasted, not the node


## AS IS 


https://github.com/user-attachments/assets/210d77d3-5c49-4e38-91b7-b9d9ea0e7ca0



## TO BE



https://github.com/user-attachments/assets/b68e4582-0c57-48b8-9ed9-0b3243bb1554




🤖 Generated with [Claude Code](https://claude.com/claude-code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9773-fix-skip-node-metadata-paste-when-media-node-is-selected-3216d73d3650814d92dadcd0c0ec79c7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-12 08:44:11 -07:00
Johnpaul Chiwetelu
ef477d0381 feat: Warn when binding browser-reserved shortcuts (#9406)
## Summary

Show a non-blocking warning in the keybinding edit dialog when users try
to bind shortcuts that browsers intercept (e.g. Ctrl+T, Ctrl+W, F12).

## Changes

- **What**: Add `RESERVED_BY_BROWSER` set of known browser-intercepted
shortcuts, `isBrowserReserved` getter on `KeyComboImpl`, and a warning
`<Message>` in the keybinding edit dialog. Users can still save the
binding.

## Review Focus

Whether the list of browser-reserved shortcuts is comprehensive enough,
and whether a non-blocking warning (vs blocking) is the right UX choice.

## Before


https://github.com/user-attachments/assets/5abfc062-5ed1-4fcd-b394-ff98221d82a8

## After



https://github.com/user-attachments/assets/12a49e24-051f-4579-894a-164dbf1cb7b7


Fixes #1087

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9406-feat-Warn-when-binding-browser-reserved-shortcuts-31a6d73d36508162a021e88ab76914f6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Kelly Yang <124ykl@gmail.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com>
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: Alexander Brown <DrJKL0424@gmail.com>
Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Deep Mehta <42841935+deepme987@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: Yourz <crazilou@vip.qq.com>
Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-12 16:31:09 +01:00
18 changed files with 764 additions and 134 deletions

View File

@@ -0,0 +1,47 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "LoadImage",
"pos": [50, 50],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": null },
{ "name": "MASK", "type": "MASK", "links": null }
],
"properties": { "Node name for S&R": "LoadImage" },
"widgets_values": ["example.png", "image"]
},
{
"id": 2,
"type": "KSampler",
"pos": [500, 50],
"size": [315, 262],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": null }],
"properties": { "Node name for S&R": "KSampler" },
"widgets_values": [0, "randomize", 20, 8, "euler", "normal", 1]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": { "offset": [0, 0], "scale": 1 }
},
"version": 0.4
}

View File

@@ -333,7 +333,7 @@ test.describe('Settings', () => {
await editKeybindingButton.click()
// Set new keybinding
const input = comfyPage.page.getByPlaceholder('Press keys for new binding')
const input = comfyPage.page.getByPlaceholder('Enter your keybind')
await input.press('Alt+n')
const requestPromise = comfyPage.page.waitForRequest(
@@ -345,7 +345,7 @@ test.describe('Settings', () => {
// Save keybinding
const saveButton = comfyPage.page
.getByLabel('New Blank Workflow')
.getByLabel('Modify keybinding')
.getByText('Save')
await saveButton.click()

View File

@@ -0,0 +1,58 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe(
'Image paste priority over stale node metadata',
{ tag: ['@node'] },
() => {
test('Should not paste copied node when a LoadImage node is selected and clipboard has stale node metadata', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/load_image_with_ksampler')
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(initialCount).toBe(2)
// Copy the KSampler node (puts data-metadata in clipboard)
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await ksamplerNodes[0].copy()
// Select the LoadImage node
const loadImageNodes =
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
await loadImageNodes[0].click('title')
// Simulate pasting when clipboard has stale node metadata (text/html
// with data-metadata) but no image file items. This replicates the bug
// scenario: user copied a node, then copied a web image (which replaces
// clipboard files but may leave stale text/html with node metadata).
await comfyPage.page.evaluate(() => {
const nodeData = { nodes: [{ type: 'KSampler', id: 99 }] }
const base64 = btoa(JSON.stringify(nodeData))
const html =
'<meta charset="utf-8"><div><span data-metadata="' +
base64 +
'"></span></div><span style="white-space:pre-wrap;">Text</span>'
const dataTransfer = new DataTransfer()
dataTransfer.setData('text/html', html)
const event = new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
document.dispatchEvent(event)
})
await comfyPage.nextFrame()
// Node count should remain the same — stale node metadata should NOT
// be deserialized when a media node is selected.
const finalCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(finalCount).toBe(initialCount)
})
}
)

View File

@@ -25,15 +25,13 @@
class: {
'p-3 rounded-lg': true,
'pointer-events-none':
bottomPanelStore.bottomPanelTabs.length === 1
},
style: {
color: 'var(--fg-color)',
backgroundColor:
bottomPanelStore.bottomPanelTabs.length === 1,
'bg-secondary-background text-secondary-foreground':
x.context.active &&
bottomPanelStore.bottomPanelTabs.length > 1,
'text-muted-foreground':
!x.context.active ||
bottomPanelStore.bottomPanelTabs.length === 1
? ''
: 'var(--bg-color)'
bottomPanelStore.bottomPanelTabs.length <= 1
}
})
"
@@ -127,4 +125,8 @@ const closeBottomPanel = () => {
:deep(.p-tablist-active-bar) {
display: none;
}
:deep(.p-tab-active) {
color: inherit;
}
</style>

View File

@@ -59,7 +59,15 @@
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<div class="truncate" :title="slotProps.data.id">
<div
class="flex items-center gap-1.5 truncate"
:title="slotProps.data.id"
>
<i
v-if="slotProps.data.keybinding?.combo.isBrowserReserved"
v-tooltip="$t('g.browserReservedKeybindingTooltip')"
class="icon-[lucide--triangle-alert] shrink-0 text-warning-background"
/>
{{ slotProps.data.label }}
</div>
</template>
@@ -93,44 +101,6 @@
</Column>
</DataTable>
<Dialog
v-model:visible="editDialogVisible"
class="min-w-96"
modal
:header="currentEditingCommand?.label"
@hide="cancelEdit"
>
<div>
<InputText
ref="keybindingInput"
class="mb-2 text-center"
:model-value="newBindingKeyCombo?.toString() ?? ''"
:placeholder="$t('g.pressKeysForNewBinding')"
autocomplete="off"
fluid
@keydown.stop.prevent="captureKeybinding"
/>
<Message v-if="existingKeybindingOnCombo" severity="warn">
{{ $t('g.keybindingAlreadyExists') }}
<Tag
severity="secondary"
:value="existingKeybindingOnCombo.commandId"
/>
</Message>
</div>
<template #footer>
<Button
:variant="existingKeybindingOnCombo ? 'destructive' : 'primary'"
autofocus
@click="saveKeybinding"
>
<i
:class="existingKeybindingOnCombo ? 'pi pi-pencil' : 'pi pi-check'"
/>
{{ existingKeybindingOnCombo ? $t('g.overwrite') : $t('g.save') }}
</Button>
</template>
</Dialog>
<Button
v-tooltip="$t('g.resetAllKeybindingsTooltip')"
class="mt-4 w-full"
@@ -147,18 +117,14 @@
import { FilterMatchMode } from '@primevue/core/api'
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import Tag from 'primevue/tag'
import { useToast } from 'primevue/usetoast'
import { computed, ref, watchEffect } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { useEditKeybindingDialog } from '@/composables/useEditKeybindingDialog'
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useCommandStore } from '@/stores/commandStore'
@@ -195,50 +161,16 @@ const commandsData = computed<ICommandData[]>(() => {
})
const selectedCommandData = ref<ICommandData | null>(null)
const editDialogVisible = ref(false)
const newBindingKeyCombo = ref<KeyComboImpl | null>(null)
const currentEditingCommand = ref<ICommandData | null>(null)
const keybindingInput = ref<InstanceType<typeof InputText> | null>(null)
const existingKeybindingOnCombo = computed<KeybindingImpl | null>(() => {
if (!currentEditingCommand.value) {
return null
}
// If the new keybinding is the same as the current editing command, then don't show the error
if (
currentEditingCommand.value.keybinding?.combo?.equals(
newBindingKeyCombo.value
)
) {
return null
}
if (!newBindingKeyCombo.value) {
return null
}
return keybindingStore.getKeybinding(newBindingKeyCombo.value)
})
const editKeybindingDialog = useEditKeybindingDialog()
function editKeybinding(commandData: ICommandData) {
currentEditingCommand.value = commandData
newBindingKeyCombo.value = commandData.keybinding
? commandData.keybinding.combo
: null
editDialogVisible.value = true
editKeybindingDialog.show({
commandId: commandData.id,
commandLabel: commandData.label,
currentCombo: commandData.keybinding?.combo ?? null
})
}
watchEffect(() => {
if (editDialogVisible.value) {
// nextTick doesn't work here, so we use a timeout instead
setTimeout(() => {
// @ts-expect-error - $el is an internal property of the InputText component
keybindingInput.value?.$el?.focus()
}, 300)
}
})
async function removeKeybinding(commandData: ICommandData) {
if (commandData.keybinding) {
keybindingStore.unsetKeybinding(commandData.keybinding)
@@ -246,40 +178,6 @@ async function removeKeybinding(commandData: ICommandData) {
}
}
async function captureKeybinding(event: KeyboardEvent) {
// Allow the use of keyboard shortcuts when adding keyboard shortcuts
if (!event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey) {
switch (event.key) {
case 'Escape':
cancelEdit()
return
case 'Enter':
await saveKeybinding()
return
}
}
const keyCombo = KeyComboImpl.fromEvent(event)
newBindingKeyCombo.value = keyCombo
}
function cancelEdit() {
editDialogVisible.value = false
currentEditingCommand.value = null
newBindingKeyCombo.value = null
}
async function saveKeybinding() {
const commandId = currentEditingCommand.value?.id
const combo = newBindingKeyCombo.value
cancelEdit()
if (!combo || commandId == undefined) return
const updated = keybindingStore.updateKeybindingOnCommand(
new KeybindingImpl({ commandId, combo })
)
if (updated) await keybindingService.persistUserKeybindings()
}
async function resetKeybinding(commandData: ICommandData) {
if (keybindingStore.resetKeybindingForCommand(commandData.id)) {
await keybindingService.persistUserKeybindings()

View File

@@ -0,0 +1,56 @@
<template>
<div class="flex w-96 flex-col border-t border-border-default px-4">
<p class="mb-4 text-sm text-muted-foreground">
{{ $t('g.setAKeybindingForTheFollowing') }}
</p>
<div class="mb-4 text-sm text-base-foreground">
{{ commandLabel }}
</div>
<input
class="text-foreground mb-4 w-full rounded-sm border border-border-default bg-secondary-background px-3 py-2 text-center shadow-none focus:outline-none"
:value="dialogState.newCombo?.toString() ?? ''"
:placeholder="$t('g.enterYourKeybind')"
:aria-label="$t('g.enterYourKeybind')"
autocomplete="off"
autofocus
@keydown.stop.prevent="captureKeybinding"
/>
<div class="min-h-12">
<p
v-if="dialogState.newCombo?.isBrowserReserved"
class="m-0 text-sm text-destructive-background"
>
{{ $t('g.browserReservedKeybinding') }}
</p>
<p
v-else-if="existingKeybindingOnCombo"
class="m-0 text-sm text-destructive-background"
>
{{ $t('g.keybindingAlreadyExists') }}
{{ existingKeybindingOnCombo.commandId }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
import type { EditKeybindingDialogState } from '@/composables/useEditKeybindingDialog'
const { dialogState, onUpdateCombo, existingKeybindingOnCombo } = defineProps<{
dialogState: EditKeybindingDialogState
commandLabel: string
onUpdateCombo: (combo: KeyComboImpl) => void
existingKeybindingOnCombo: KeybindingImpl | null
}>()
function captureKeybinding(event: KeyboardEvent) {
if (!event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey) {
if (event.key === 'Escape') return
}
onUpdateCombo(KeyComboImpl.fromEvent(event))
}
</script>

View File

@@ -0,0 +1,72 @@
<template>
<div class="flex w-full justify-end gap-2 px-4 py-2">
<Button
variant="textonly"
size="md"
class="text-muted-foreground"
@click="handleCancel"
>
{{ $t('g.cancel') }}
</Button>
<Button
:variant="
existingKeybindingOnCombo
? 'destructive'
: dialogState.newCombo?.isBrowserReserved
? 'secondary'
: 'primary'
"
size="md"
:disabled="!dialogState.newCombo"
class="px-4 py-2"
@click="handleSave"
>
{{
existingKeybindingOnCombo
? $t('g.overwrite')
: dialogState.newCombo?.isBrowserReserved
? $t('g.saveAnyway')
: $t('g.save')
}}
</Button>
</div>
</template>
<script setup lang="ts">
import type { Reactive } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useDialogStore } from '@/stores/dialogStore'
import type { EditKeybindingDialogState } from '@/composables/useEditKeybindingDialog'
import { DIALOG_KEY } from '@/composables/useEditKeybindingDialog'
const { dialogState, existingKeybindingOnCombo } = defineProps<{
dialogState: Reactive<EditKeybindingDialogState>
existingKeybindingOnCombo: KeybindingImpl | null
}>()
const keybindingStore = useKeybindingStore()
const keybindingService = useKeybindingService()
const dialogStore = useDialogStore()
function handleCancel() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
async function handleSave() {
const combo = dialogState.newCombo
const commandId = dialogState.commandId
if (!combo || !commandId) return
dialogStore.closeDialog({ key: DIALOG_KEY })
const updated = keybindingStore.updateKeybindingOnCommand(
new KeybindingImpl({ commandId, combo })
)
if (updated) await keybindingService.persistUserKeybindings()
}
</script>

View File

@@ -0,0 +1,5 @@
<template>
<div class="flex w-full items-center gap-2 p-4">
<p class="m-0 font-semibold">{{ $t('g.modifyKeybinding') }}</p>
</div>
</template>

View File

@@ -0,0 +1,179 @@
import type { ChartData, ChartOptions, ChartType } from 'chart.js'
import {
BarController,
BarElement,
CategoryScale,
Chart,
Filler,
Legend,
LinearScale,
LineController,
LineElement,
PointElement,
Tooltip
} from 'chart.js'
import { merge } from 'es-toolkit'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { Ref } from 'vue'
Chart.register(
BarController,
BarElement,
CategoryScale,
Filler,
Legend,
LinearScale,
LineController,
LineElement,
PointElement,
Tooltip
)
function getCssVar(name: string): string {
return getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim()
}
function getDefaultOptions(type: ChartType): ChartOptions {
const foreground = getCssVar('--color-base-foreground') || '#ffffff'
const muted = getCssVar('--color-muted-foreground') || '#8a8a8a'
return {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
align: 'start',
labels: {
color: foreground,
usePointStyle: true,
pointStyle: 'circle',
boxWidth: 8,
boxHeight: 8,
padding: 16,
font: { family: 'Inter', size: 11 },
generateLabels(chart) {
const datasets = chart.data.datasets
return datasets.map((dataset, i) => {
const color =
(dataset as { borderColor?: string }).borderColor ??
(dataset as { backgroundColor?: string }).backgroundColor ??
'#888'
return {
text: dataset.label ?? '',
fillStyle: color as string,
strokeStyle: color as string,
lineWidth: 0,
pointStyle: 'circle' as const,
hidden: !chart.isDatasetVisible(i),
datasetIndex: i
}
})
}
}
},
tooltip: {
enabled: true
}
},
elements: {
point: {
radius: 0,
hoverRadius: 4
}
},
scales: {
x: {
ticks: {
color: muted,
font: { family: 'Inter', size: 11 },
padding: 8
},
grid: {
display: true,
color: muted + '33',
drawTicks: false
},
border: { display: true, color: muted }
},
y: {
ticks: {
color: muted,
font: { family: 'Inter', size: 11 },
padding: 4
},
grid: {
display: false,
drawTicks: false
},
border: { display: true, color: muted }
}
},
...(type === 'bar' && {
datasets: {
bar: {
borderRadius: { topLeft: 4, topRight: 4 },
borderSkipped: false,
barPercentage: 0.6,
categoryPercentage: 0.8
}
}
})
}
}
export function useChart(
canvasRef: Ref<HTMLCanvasElement | null>,
type: Ref<ChartType>,
data: Ref<ChartData>,
options?: Ref<ChartOptions | undefined>
) {
const chartInstance = ref<Chart | null>(null)
function createChart() {
if (!canvasRef.value) return
chartInstance.value?.destroy()
const defaults = getDefaultOptions(type.value)
const merged = options?.value
? merge(defaults, options.value)
: defaults
chartInstance.value = new Chart(canvasRef.value, {
type: type.value,
data: data.value,
options: merged
})
}
onMounted(createChart)
watch(
[type, data, options ?? ref(undefined)],
([nextType], [previousType]) => {
if (!chartInstance.value) return
if (nextType !== previousType) {
createChart()
return
}
chartInstance.value.data = data.value
chartInstance.value.options = options?.value
? merge(getDefaultOptions(type.value), options.value)
: getDefaultOptions(type.value)
chartInstance.value.update()
},
{ deep: true }
)
onBeforeUnmount(() => {
chartInstance.value?.destroy()
chartInstance.value = null
})
return { chartInstance }
}

View File

@@ -0,0 +1,60 @@
import { computed, reactive } from 'vue'
import EditKeybindingContent from '@/components/dialog/content/setting/keybinding/EditKeybindingContent.vue'
import EditKeybindingFooter from '@/components/dialog/content/setting/keybinding/EditKeybindingFooter.vue'
import EditKeybindingHeader from '@/components/dialog/content/setting/keybinding/EditKeybindingHeader.vue'
import type { KeyComboImpl } from '@/platform/keybindings/keyCombo'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useDialogService } from '@/services/dialogService'
export const DIALOG_KEY = 'edit-keybinding'
export interface EditKeybindingDialogState {
commandId: string
newCombo: KeyComboImpl | null
currentCombo: KeyComboImpl | null
}
export function useEditKeybindingDialog() {
const { showSmallLayoutDialog } = useDialogService()
const keybindingStore = useKeybindingStore()
function show(options: {
commandId: string
commandLabel: string
currentCombo: KeyComboImpl | null
}) {
const dialogState = reactive<EditKeybindingDialogState>({
commandId: options.commandId,
newCombo: options.currentCombo,
currentCombo: options.currentCombo
})
const existingKeybindingOnCombo = computed(() => {
if (!dialogState.newCombo) return null
if (dialogState.currentCombo?.equals(dialogState.newCombo)) return null
return keybindingStore.getKeybinding(dialogState.newCombo)
})
function onUpdateCombo(combo: KeyComboImpl) {
dialogState.newCombo = combo
}
showSmallLayoutDialog({
key: DIALOG_KEY,
headerComponent: EditKeybindingHeader,
footerComponent: EditKeybindingFooter,
component: EditKeybindingContent,
props: {
dialogState,
onUpdateCombo,
commandLabel: options.commandLabel,
existingKeybindingOnCombo
},
headerProps: {},
footerProps: { dialogState, existingKeybindingOnCombo }
})
}
return { show }
}

View File

@@ -595,6 +595,34 @@ describe('usePaste', () => {
)
})
})
it('should skip node metadata paste when a media node is selected', async () => {
const mockNode = createMockLGraphNode({
is_selected: true,
pasteFile: vi.fn(),
pasteFiles: vi.fn()
})
mockCanvas.current_node = mockNode
vi.mocked(isImageNode).mockReturnValue(true)
usePaste()
const nodeData = { nodes: [{ type: 'KSampler' }] }
const encoded = btoa(JSON.stringify(nodeData))
const html = `<div data-metadata="${encoded}"></div>`
const dataTransfer = new DataTransfer()
dataTransfer.setData('text/html', html)
dataTransfer.setData('text/plain', 'some text')
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
await vi.waitFor(() => {
expect(mockCanvas._deserializeItems).not.toHaveBeenCalled()
expect(mockCanvas.pasteFromClipboard).toHaveBeenCalled()
})
})
})
describe('cloneDataTransfer', () => {

View File

@@ -229,7 +229,10 @@ export const usePaste = () => {
return
}
}
if (pasteClipboardItems(data)) return
const isMediaNodeSelected =
isImageNodeSelected || isVideoNodeSelected || isAudioNodeSelected
if (!isMediaNodeSelected && pasteClipboardItems(data)) return
// No image found. Look for node data
data = data.getData('text/plain')

View File

@@ -110,6 +110,7 @@
"delete": "Delete",
"rename": "Rename",
"save": "Save",
"saveAnyway": "Save Anyway",
"saving": "Saving",
"no": "No",
"cancel": "Cancel",
@@ -120,7 +121,6 @@
"showRightPanel": "Show right panel",
"hideRightPanel": "Hide right panel",
"or": "or",
"pressKeysForNewBinding": "Press keys for new binding",
"defaultBanner": "default banner",
"enableOrDisablePack": "Enable or disable pack",
"openManager": "Open Manager",
@@ -265,6 +265,11 @@
"multiSelectDropdown": "Multi-select dropdown",
"singleSelectDropdown": "Single-select dropdown",
"progressCountOf": "of",
"modifyKeybinding": "Modify keybinding",
"setAKeybindingForTheFollowing": "Set a keybinding for the following:",
"enterYourKeybind": "Enter your keybind",
"browserReservedKeybinding": "This shortcut is reserved by some browsers and may have unexpected results.",
"browserReservedKeybindingTooltip": "This shortcut conflicts with browser-reserved shortcuts",
"keybindingAlreadyExists": "Keybinding already exists on",
"commandProhibited": "Command {command} is prohibited. Contact an administrator for more information.",
"startRecording": "Start Recording",

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { KeyComboImpl } from './keyCombo'
describe('KeyComboImpl', () => {
describe('isBrowserReserved', () => {
it.each([
{ key: 't', ctrl: true, label: 'Ctrl + t' },
{ key: 'w', ctrl: true, label: 'Ctrl + w' },
{ key: 'F12', label: 'F12' },
{ key: 'n', ctrl: true, shift: true, label: 'Ctrl + Shift + n' },
{ key: 'r', ctrl: true, label: 'Ctrl + r' },
{ key: 'F5', label: 'F5' }
])('returns true for $label', ({ key, ctrl, shift }) => {
const combo = new KeyComboImpl({
key,
ctrl: ctrl ?? false,
alt: false,
shift: shift ?? false
})
expect(combo.isBrowserReserved).toBe(true)
})
it.each([
{ key: 'k', ctrl: true, label: 'Ctrl + k' },
{ key: 's', alt: true, label: 'Alt + s' },
{ key: 'z', ctrl: true, label: 'Ctrl + z' },
{ key: 'F6', label: 'F6' }
])('returns false for $label', ({ key, ctrl, alt }) => {
const combo = new KeyComboImpl({
key,
ctrl: ctrl ?? false,
alt: alt ?? false,
shift: false
})
expect(combo.isBrowserReserved).toBe(false)
})
})
})

View File

@@ -1,6 +1,6 @@
import { toRaw } from 'vue'
import { RESERVED_BY_TEXT_INPUT } from './reserved'
import { RESERVED_BY_BROWSER, RESERVED_BY_TEXT_INPUT } from './reserved'
import type { KeyCombo } from './types'
export class KeyComboImpl implements KeyCombo {
@@ -61,11 +61,15 @@ export class KeyComboImpl implements KeyCombo {
return this.shift && this.modifierCount === 1
}
get isBrowserReserved(): boolean {
return RESERVED_BY_BROWSER.has(toNormalizedString(this))
}
get isReservedByTextInput(): boolean {
return (
!this.hasModifier ||
this.isShiftOnly ||
RESERVED_BY_TEXT_INPUT.has(this.toString())
RESERVED_BY_TEXT_INPUT.has(toNormalizedString(this))
)
}
@@ -84,3 +88,12 @@ export class KeyComboImpl implements KeyCombo {
return sequences
}
}
function toNormalizedString(combo: KeyComboImpl): string {
const sequences: string[] = []
if (combo.ctrl) sequences.push('Ctrl')
if (combo.alt) sequences.push('Alt')
if (combo.shift) sequences.push('Shift')
sequences.push(combo.key.length === 1 ? combo.key.toLowerCase() : combo.key)
return sequences.join(' + ')
}

View File

@@ -1,3 +1,28 @@
export const RESERVED_BY_BROWSER = new Set([
'Ctrl + t', // New tab (all browsers)
'Ctrl + w', // Close tab (all browsers)
'Ctrl + n', // New window (all browsers)
'Ctrl + Shift + n', // New incognito/private window (all browsers)
'Ctrl + Tab', // Next tab (all browsers)
'Ctrl + Shift + Tab', // Previous tab (all browsers)
'Ctrl + Shift + Delete', // Clear browsing data (Chrome, Edge, Firefox)
'Ctrl + h', // History (all browsers)
'Ctrl + j', // Downloads (Chrome, Edge)
'Ctrl + d', // Bookmark current page (all browsers)
'Ctrl + Shift + b', // Toggle bookmarks bar (Chrome, Edge)
'Ctrl + Shift + o', // Bookmarks manager (Chrome, Edge)
'Ctrl + Shift + i', // DevTools (all browsers)
'Ctrl + Shift + j', // DevTools console (Chrome, Edge)
'F5', // Reload page (all browsers)
'Ctrl + F5', // Hard reload (all browsers)
'Ctrl + r', // Reload page (all browsers)
'Ctrl + Shift + r', // Hard reload (all browsers)
'F7', // Caret browsing (Firefox, Edge)
'F11', // Toggle fullscreen (all browsers)
'F12', // DevTools (all browsers)
'Alt + F4' // Close window (Windows, all browsers)
])
export const RESERVED_BY_TEXT_INPUT = new Set([
'Ctrl + a',
'Ctrl + c',

View File

@@ -151,6 +151,110 @@ describe('nodeOutputStore restoreOutputs', () => {
})
})
describe('nodeOutputStore input preview preservation', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('should preserve input preview when execution sends empty output', () => {
const store = useNodeOutputStore()
const executionId = '3'
const inputPreview = createMockOutputs([
{ filename: 'example.png', subfolder: '', type: 'input' }
])
store.setNodeOutputsByExecutionId(executionId, inputPreview)
expect(store.nodeOutputs[executionId]?.images).toHaveLength(1)
const emptyExecutionOutput = createMockOutputs()
store.setNodeOutputsByExecutionId(executionId, emptyExecutionOutput)
expect(store.nodeOutputs[executionId]?.images).toHaveLength(1)
expect(store.nodeOutputs[executionId]?.images?.[0].filename).toBe(
'example.png'
)
})
it('should preserve input preview when execution sends output with empty images array', () => {
const store = useNodeOutputStore()
const executionId = '3'
const inputPreview = createMockOutputs([
{ filename: 'example.png', subfolder: '', type: 'input' }
])
store.setNodeOutputsByExecutionId(executionId, inputPreview)
const emptyImagesOutput = createMockOutputs([])
store.setNodeOutputsByExecutionId(executionId, emptyImagesOutput)
expect(store.nodeOutputs[executionId]?.images).toHaveLength(1)
expect(store.nodeOutputs[executionId]?.images?.[0].type).toBe('input')
})
it('should allow execution output with images to overwrite input preview', () => {
const store = useNodeOutputStore()
const executionId = '3'
const inputPreview = createMockOutputs([
{ filename: 'example.png', subfolder: '', type: 'input' }
])
store.setNodeOutputsByExecutionId(executionId, inputPreview)
const executionOutput = createMockOutputs([
{ filename: 'output.png', subfolder: '', type: 'output' }
])
store.setNodeOutputsByExecutionId(executionId, executionOutput)
expect(store.nodeOutputs[executionId]?.images).toHaveLength(1)
expect(store.nodeOutputs[executionId]?.images?.[0].filename).toBe(
'output.png'
)
})
it('should not preserve non-input outputs from being overwritten', () => {
const store = useNodeOutputStore()
const executionId = '4'
const tempOutput = createMockOutputs([
{ filename: 'temp.png', subfolder: '', type: 'temp' }
])
store.setNodeOutputsByExecutionId(executionId, tempOutput)
const emptyOutput = createMockOutputs()
store.setNodeOutputsByExecutionId(executionId, emptyOutput)
expect(store.nodeOutputs[executionId]?.images).toBeUndefined()
})
it('should pass through non-image fields while preserving input preview images', () => {
const store = useNodeOutputStore()
const executionId = '5'
const inputPreview = createMockOutputs([
{ filename: 'example.png', subfolder: '', type: 'input' }
])
store.setNodeOutputsByExecutionId(executionId, inputPreview)
const videoOutput: ExecutedWsMessage['output'] = {
video: [{ filename: 'output.mp4', subfolder: '', type: 'output' }]
}
store.setNodeOutputsByExecutionId(executionId, videoOutput)
expect(store.nodeOutputs[executionId]?.images).toHaveLength(1)
expect(store.nodeOutputs[executionId]?.images?.[0].filename).toBe(
'example.png'
)
expect(store.nodeOutputs[executionId]?.video).toHaveLength(1)
expect(store.nodeOutputs[executionId]?.video?.[0].filename).toBe(
'output.mp4'
)
})
})
describe('nodeOutputStore getPreviewParam', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))

View File

@@ -126,6 +126,22 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
})
}
/**
* Check if an output contains input-type preview images (from upload widgets).
* These are synthetic previews set by LoadImage/LoadVideo widgets, not
* execution results from the backend.
*/
function isInputPreviewOutput(
output: ExecutedWsMessage['output'] | ResultItem | undefined
): boolean {
const images = (output as ExecutedWsMessage['output'] | undefined)?.images
return (
Array.isArray(images) &&
images.length > 0 &&
images.every((i) => i?.type === 'input')
)
}
/**
* Internal function to set outputs by NodeLocatorId.
* Handles the merge logic when needed.
@@ -140,6 +156,26 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
// (e.g., two LoadImage nodes selecting the same image)
if (outputs == null) return
// Preserve input preview images (from upload widgets) when execution
// sends outputs with no images. Without this guard, execution results
// overwrite the upload widget's preview, causing LoadImage/LoadVideo
// nodes to lose their preview after execution + tab switch.
// Note: intentional preview clears go through setNodeOutputs (widget
// path), not setNodeOutputsByExecutionId, so this guard does not
// interfere with user-initiated clears.
const incomingImages = (outputs as ExecutedWsMessage['output']).images
const hasIncomingImages =
Array.isArray(incomingImages) && incomingImages.length > 0
if (
!hasIncomingImages &&
isInputPreviewOutput(app.nodeOutputs[nodeLocatorId])
) {
outputs = {
...outputs,
images: app.nodeOutputs[nodeLocatorId].images
}
}
if (options.merge) {
const existingOutput = app.nodeOutputs[nodeLocatorId]
if (existingOutput && outputs) {