Compare commits

..

5 Commits

Author SHA1 Message Date
GitHub Action
26cef70453 [automated] Apply ESLint and Oxfmt fixes 2026-03-12 15:59:41 +00:00
bymyself
c57d4f5da8 fix: restore setNodeLocatorResolver call dropped during rebase
The call to setNodeLocatorResolver was lost when resolving merge
conflicts during rebase onto main. Re-adds it in the bootstrap
sequence before comfyApp.setup().
2026-03-12 08:55:29 -07:00
GitHub Action
695ec64752 [automated] Apply ESLint and Oxfmt fixes 2026-03-12 08:48:50 -07:00
bymyself
7cd10ccd88 fix: deep-freeze DEFAULT_STATE nested arrays to prevent shared mutation
Amp-Thread-ID: https://ampcode.com/threads/T-019cbd94-a928-7610-b468-2583f4816262
2026-03-12 08:48:35 -07:00
bymyself
6da7c896c9 feat: centralize node image rendering state in NodeImageStore
Introduce useNodeImageStore — a Pinia store keyed by NodeLocatorId that
owns imgs, imageIndex, imageRects, pointerDown, and overIndex state.

LGraphNode properties delegate to the store via Object.defineProperty
getters/setters installed in LGraph.add(), so all existing consumer code
(~18 files) continues to read/write node.imgs, node.imageIndex, etc.
unchanged.

Key design decisions:
- Plain Map (not reactive) avoids Vue proxy overhead in the canvas
  render hot path.
- peekState() + frozen DEFAULT_STATE for read-only access prevents
  unbounded Map growth from getter-only calls.
- Module-scoped setNodeLocatorResolver() breaks circular dependency
  (LGraph → store → workflowStore → app → litegraph).
- imgs getter returns undefined when empty to preserve node.imgs?.length
  optional chaining semantics.

Cleanup is wired into removeNodeOutputs (per-node) and
resetAllOutputsAndPreviews (bulk clear).

Fixes #9242

Amp-Thread-ID: https://ampcode.com/threads/T-019cbd88-ca30-76ec-abfa-38949748ba3d
2026-03-12 08:48:35 -07:00
21 changed files with 654 additions and 705 deletions

View File

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

View File

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

View File

@@ -59,15 +59,7 @@
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<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"
/>
<div class="truncate" :title="slotProps.data.id">
{{ slotProps.data.label }}
</div>
</template>
@@ -101,6 +93,44 @@
</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"
@@ -117,14 +147,18 @@
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 } from 'vue'
import { computed, ref, watchEffect } 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 { useEditKeybindingDialog } from '@/composables/useEditKeybindingDialog'
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useCommandStore } from '@/stores/commandStore'
@@ -161,16 +195,50 @@ const commandsData = computed<ICommandData[]>(() => {
})
const selectedCommandData = ref<ICommandData | null>(null)
const editKeybindingDialog = useEditKeybindingDialog()
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)
})
function editKeybinding(commandData: ICommandData) {
editKeybindingDialog.show({
commandId: commandData.id,
commandLabel: commandData.label,
currentCombo: commandData.keybinding?.combo ?? null
})
currentEditingCommand.value = commandData
newBindingKeyCombo.value = commandData.keybinding
? commandData.keybinding.combo
: null
editDialogVisible.value = true
}
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)
@@ -178,6 +246,40 @@ 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

@@ -1,56 +0,0 @@
<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

@@ -1,72 +0,0 @@
<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

@@ -1,5 +0,0 @@
<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

@@ -177,6 +177,7 @@ import { shouldIgnoreCopyPaste } from '@/workbench/eventHelpers'
import { storeToRefs } from 'pinia'
import { useBootstrapStore } from '@/stores/bootstrapStore'
import { setNodeLocatorResolver } from '@/stores/nodeImageStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
@@ -510,6 +511,8 @@ onMounted(async () => {
)
}
setNodeLocatorResolver(workflowStore.nodeToNodeLocatorId)
// @ts-expect-error fixme ts strict error
await comfyApp.setup(canvasRef.value)
canvasStore.canvas = comfyApp.canvas

View File

@@ -1,57 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ChartBar from './ChartBar.vue'
const meta: Meta<typeof ChartBar> = {
title: 'Components/Chart/ChartBar',
component: ChartBar,
tags: ['autodocs'],
parameters: { layout: 'centered' },
decorators: [
(story) => ({
components: { story },
template:
'<div class="w-[413px] bg-neutral-900 p-4 rounded-lg"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
ariaLabel: 'Bar chart example',
data: {
labels: ['A', 'B', 'C', 'D'],
datasets: [
{
label: 'BarName1',
data: [10, 50, 35, 75],
backgroundColor: '#ff8000'
}
]
}
}
}
export const MultipleDatasets: Story = {
args: {
ariaLabel: 'Bar chart with multiple datasets',
data: {
labels: ['A', 'B', 'C', 'D'],
datasets: [
{
label: 'Series 1',
data: [30, 60, 45, 80],
backgroundColor: '#ff8000'
},
{
label: 'Series 2',
data: [50, 40, 70, 20],
backgroundColor: '#4ade80'
}
]
}
}
}

View File

@@ -1,34 +0,0 @@
<template>
<div
:class="
cn('rounded-lg bg-component-node-widget-background p-6', props.class)
"
>
<canvas ref="canvasRef" :aria-label="ariaLabel" role="img" />
</div>
</template>
<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js'
import { computed, ref, toRef } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { useChart } from './useChart'
const props = defineProps<{
data: ChartData<'bar'>
options?: ChartOptions<'bar'>
ariaLabel: string
class?: string
}>()
const canvasRef = ref<HTMLCanvasElement | null>(null)
useChart(
canvasRef,
ref('bar'),
toRef(() => props.data),
computed(() => props.options as ChartOptions | undefined)
)
</script>

View File

@@ -1,76 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ChartLine from './ChartLine.vue'
const meta: Meta<typeof ChartLine> = {
title: 'Components/Chart/ChartLine',
component: ChartLine,
tags: ['autodocs'],
parameters: { layout: 'centered' },
decorators: [
(story) => ({
components: { story },
template:
'<div class="w-[413px] bg-neutral-900 p-4 rounded-lg"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
ariaLabel: 'Line chart example',
data: {
labels: ['A', 'B', 'C', 'D'],
datasets: [
{
label: 'LineName1',
data: [10, 45, 25, 80],
borderColor: '#4ade80',
borderDash: [5, 5],
fill: true,
backgroundColor: '#4ade8033',
tension: 0.4
}
]
}
}
}
export const MultipleLines: Story = {
args: {
ariaLabel: 'Line chart with multiple lines',
data: {
labels: ['A', 'B', 'C', 'D'],
datasets: [
{
label: 'LineName1',
data: [10, 45, 25, 80],
borderColor: '#4ade80',
borderDash: [5, 5],
fill: true,
backgroundColor: '#4ade8033',
tension: 0.4
},
{
label: 'LineName2',
data: [80, 60, 40, 10],
borderColor: '#ff8000',
fill: true,
backgroundColor: '#ff800033',
tension: 0.4
},
{
label: 'LineName3',
data: [60, 70, 35, 40],
borderColor: '#ef4444',
fill: true,
backgroundColor: '#ef444433',
tension: 0.4
}
]
}
}
}

View File

@@ -1,34 +0,0 @@
<template>
<div
:class="
cn('rounded-lg bg-component-node-widget-background p-6', props.class)
"
>
<canvas ref="canvasRef" :aria-label="ariaLabel" role="img" />
</div>
</template>
<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js'
import { computed, ref, toRef } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { useChart } from './useChart'
const props = defineProps<{
data: ChartData<'line'>
options?: ChartOptions<'line'>
ariaLabel: string
class?: string
}>()
const canvasRef = ref<HTMLCanvasElement | null>(null)
useChart(
canvasRef,
ref('line'),
toRef(() => props.data),
computed(() => props.options as ChartOptions | undefined)
)
</script>

View File

@@ -1,196 +0,0 @@
import type { ChartData, ChartOptions, ChartType } from 'chart.js'
import {
BarController,
BarElement,
CategoryScale,
Chart,
Filler,
Legend,
LinearScale,
LineController,
LineElement,
PointElement,
Tooltip
} from 'chart.js'
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
? deepMerge(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)], () => {
if (chartInstance.value) {
chartInstance.value.data = data.value
chartInstance.value.options = options?.value
? deepMerge(getDefaultOptions(type.value), options.value)
: getDefaultOptions(type.value)
chartInstance.value.update()
}
})
onBeforeUnmount(() => {
chartInstance.value?.destroy()
chartInstance.value = null
})
return { chartInstance }
}
function deepMerge<T extends Record<string, unknown>>(
target: T,
source: Record<string, unknown>
): T {
const result = { ...target } as Record<string, unknown>
for (const key of Object.keys(source)) {
const srcVal = source[key]
const tgtVal = result[key]
if (
srcVal &&
typeof srcVal === 'object' &&
!Array.isArray(srcVal) &&
tgtVal &&
typeof tgtVal === 'object' &&
!Array.isArray(tgtVal)
) {
result[key] = deepMerge(
tgtVal as Record<string, unknown>,
srcVal as Record<string, unknown>
)
} else {
result[key] = srcVal
}
}
return result as T
}

View File

@@ -1,60 +0,0 @@
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

@@ -1,4 +1,5 @@
import { toString } from 'es-toolkit/compat'
import { getActivePinia } from 'pinia'
import {
SUBGRAPH_INPUT_ID,
@@ -9,6 +10,7 @@ import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { createUuidv4, zeroUuid } from '@/lib/litegraph/src/utils/uuid'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { useNodeImageStore } from '@/stores/nodeImageStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { forEachNode } from '@/utils/graphTraversalUtil'
@@ -985,6 +987,13 @@ export class LGraph
}
}
// Install property projection so node.imgs, node.imageIndex, etc.
// delegate to the centralized NodeImageStore.
// Guarded because Pinia may not be initialized in unit tests.
if (getActivePinia()) {
useNodeImageStore().installPropertyProjection(node)
}
this._nodes.push(node)
this._nodes_by_id[node.id] = node

View File

@@ -110,7 +110,6 @@
"delete": "Delete",
"rename": "Rename",
"save": "Save",
"saveAnyway": "Save Anyway",
"saving": "Saving",
"no": "No",
"cancel": "Cancel",
@@ -121,6 +120,7 @@
"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,11 +265,6 @@
"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

@@ -1,39 +0,0 @@
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_BROWSER, RESERVED_BY_TEXT_INPUT } from './reserved'
import { RESERVED_BY_TEXT_INPUT } from './reserved'
import type { KeyCombo } from './types'
export class KeyComboImpl implements KeyCombo {
@@ -61,15 +61,11 @@ 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(toNormalizedString(this))
RESERVED_BY_TEXT_INPUT.has(this.toString())
)
}
@@ -88,12 +84,3 @@ 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,28 +1,3 @@
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

@@ -0,0 +1,350 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { setNodeLocatorResolver, useNodeImageStore } from './nodeImageStore'
const mockNodeToNodeLocatorId = vi.fn()
function createMockNode(overrides: Record<string, unknown> = {}): LGraphNode {
return {
id: 1,
type: 'TestNode',
...overrides
} as Partial<LGraphNode> as LGraphNode
}
describe(useNodeImageStore, () => {
let store: ReturnType<typeof useNodeImageStore>
const locatorA = '42' as NodeLocatorId
const locatorB = 'abc-123:42' as NodeLocatorId
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
store = useNodeImageStore()
vi.clearAllMocks()
setNodeLocatorResolver(mockNodeToNodeLocatorId)
})
describe('getState', () => {
it('returns default state for new locatorId', () => {
const state = store.getState(locatorA)
expect(state).toEqual({
imgs: [],
imageIndex: null,
imageRects: [],
pointerDown: null,
overIndex: null
})
})
it('returns same state for same locatorId', () => {
const first = store.getState(locatorA)
first.overIndex = 42
const second = store.getState(locatorA)
expect(second.overIndex).toBe(42)
})
it('returns different references for different locatorIds', () => {
const a = store.getState(locatorA)
const b = store.getState(locatorB)
expect(a).not.toBe(b)
})
})
describe('clearState', () => {
it('removes entry for locatorId', () => {
const state = store.getState(locatorA)
state.overIndex = 5
store.clearState(locatorA)
const fresh = store.getState(locatorA)
expect(fresh.overIndex).toBeNull()
})
})
describe('clearAll', () => {
it('removes all entries', () => {
store.getState(locatorA).overIndex = 1
store.getState(locatorB).overIndex = 2
store.clearAll()
expect(store.getState(locatorA).overIndex).toBeNull()
expect(store.getState(locatorB).overIndex).toBeNull()
})
})
describe('installPropertyProjection', () => {
it('projects imageRects reads/writes to store', () => {
const node = createMockNode()
mockNodeToNodeLocatorId.mockReturnValue(locatorA)
store.installPropertyProjection(node)
node.imageRects = [[10, 20, 30, 40]]
expect(store.getState(locatorA).imageRects).toEqual([[10, 20, 30, 40]])
expect(node.imageRects).toEqual([[10, 20, 30, 40]])
})
it('projects pointerDown reads/writes to store', () => {
const node = createMockNode()
mockNodeToNodeLocatorId.mockReturnValue(locatorA)
store.installPropertyProjection(node)
node.pointerDown = { index: 3, pos: [100, 200] }
expect(store.getState(locatorA).pointerDown).toEqual({
index: 3,
pos: [100, 200]
})
expect(node.pointerDown).toEqual({ index: 3, pos: [100, 200] })
})
it('projects overIndex reads/writes to store', () => {
const node = createMockNode()
mockNodeToNodeLocatorId.mockReturnValue(locatorA)
store.installPropertyProjection(node)
node.overIndex = 7
expect(store.getState(locatorA).overIndex).toBe(7)
expect(node.overIndex).toBe(7)
})
it('projects imageIndex reads/writes to store', () => {
const node = createMockNode()
mockNodeToNodeLocatorId.mockReturnValue(locatorA)
store.installPropertyProjection(node)
node.imageIndex = 5
expect(store.getState(locatorA).imageIndex).toBe(5)
expect(node.imageIndex).toBe(5)
})
it('preserves existing values when installing projection', () => {
const node = createMockNode({ overIndex: 3, imageIndex: 2 })
mockNodeToNodeLocatorId.mockReturnValue(locatorA)
store.installPropertyProjection(node)
expect(node.overIndex).toBe(3)
expect(node.imageIndex).toBe(2)
expect(store.getState(locatorA).overIndex).toBe(3)
expect(store.getState(locatorA).imageIndex).toBe(2)
})
it('returns undefined when node has no locatorId', () => {
const node = createMockNode()
mockNodeToNodeLocatorId.mockReturnValue(undefined)
store.installPropertyProjection(node)
expect(node.overIndex).toBeUndefined()
expect(node.imageIndex).toBeUndefined()
})
it('silently drops writes when node has no locatorId', () => {
const node = createMockNode()
mockNodeToNodeLocatorId.mockReturnValue(undefined)
store.installPropertyProjection(node)
node.overIndex = 5
expect(node.overIndex).toBeUndefined()
})
it('is idempotent when called twice on the same node', () => {
const node = createMockNode()
mockNodeToNodeLocatorId.mockReturnValue(locatorA)
store.installPropertyProjection(node)
node.overIndex = 3
node.imageIndex = 7
node.imgs = [new Image()]
store.installPropertyProjection(node)
expect(node.overIndex).toBe(3)
expect(node.imageIndex).toBe(7)
expect(node.imgs).toHaveLength(1)
expect(store.getState(locatorA).overIndex).toBe(3)
})
})
describe('imgs projection', () => {
it('returns undefined when store array is empty', () => {
const node = createMockNode()
mockNodeToNodeLocatorId.mockReturnValue(locatorA)
store.installPropertyProjection(node)
expect(node.imgs).toBeUndefined()
})
it('returns the array when store has images', () => {
const node = createMockNode()
mockNodeToNodeLocatorId.mockReturnValue(locatorA)
store.installPropertyProjection(node)
const img = new Image()
node.imgs = [img]
expect(node.imgs).toEqual([img])
expect(store.getState(locatorA).imgs).toEqual([img])
})
it('converts undefined assignment to empty array in store', () => {
const node = createMockNode()
mockNodeToNodeLocatorId.mockReturnValue(locatorA)
store.installPropertyProjection(node)
node.imgs = [new Image()]
node.imgs = undefined
expect(node.imgs).toBeUndefined()
expect(store.getState(locatorA).imgs).toEqual([])
})
it('preserves existing imgs when installing projection', () => {
const img = new Image()
const node = createMockNode({ imgs: [img] })
mockNodeToNodeLocatorId.mockReturnValue(locatorA)
store.installPropertyProjection(node)
expect(node.imgs).toEqual([img])
expect(store.getState(locatorA).imgs).toEqual([img])
})
it('supports optional chaining pattern (node.imgs?.length)', () => {
const node = createMockNode()
mockNodeToNodeLocatorId.mockReturnValue(locatorA)
store.installPropertyProjection(node)
expect(node.imgs?.length).toBeUndefined()
node.imgs = [new Image(), new Image()]
expect(node.imgs?.length).toBe(2)
})
})
describe('subgraph isolation', () => {
it('isolates image state across subgraph instances', () => {
const locator1 = 'uuid-instance-1:42' as NodeLocatorId
const locator2 = 'uuid-instance-2:42' as NodeLocatorId
const img1 = new Image()
const img2 = new Image()
store.getState(locator1).imgs = [img1]
store.getState(locator2).imgs = [img2]
expect(store.getState(locator1).imgs).toEqual([img1])
expect(store.getState(locator2).imgs).toEqual([img2])
})
it('isolates imageIndex across subgraph instances', () => {
const locator1 = 'uuid-instance-1:42' as NodeLocatorId
const locator2 = 'uuid-instance-2:42' as NodeLocatorId
store.getState(locator1).imageIndex = 0
store.getState(locator2).imageIndex = 3
expect(store.getState(locator1).imageIndex).toBe(0)
expect(store.getState(locator2).imageIndex).toBe(3)
})
it('projects to correct store entry based on locatorId', () => {
const locator1 = 'uuid-instance-1:42' as NodeLocatorId
const locator2 = 'uuid-instance-2:42' as NodeLocatorId
const node1 = createMockNode({ id: 42, _locator: locator1 })
const node2 = createMockNode({ id: 42, _locator: locator2 })
mockNodeToNodeLocatorId.mockImplementation(
(n: Record<string, unknown>) => n._locator
)
store.installPropertyProjection(node1)
store.installPropertyProjection(node2)
node1.overIndex = 1
node2.overIndex = 9
expect(store.getState(locator1).overIndex).toBe(1)
expect(store.getState(locator2).overIndex).toBe(9)
})
})
describe('multiple nodes have independent state', () => {
it('imageIndex is independent per node', () => {
const nodeA = createMockNode({ id: 1, _locator: '1' })
const nodeB = createMockNode({ id: 2, _locator: '2' })
const locA = '1' as NodeLocatorId
const locB = '2' as NodeLocatorId
mockNodeToNodeLocatorId.mockImplementation(
(n: Record<string, unknown>) => n._locator
)
store.installPropertyProjection(nodeA)
store.installPropertyProjection(nodeB)
nodeA.imageIndex = 0
nodeB.imageIndex = 5
expect(nodeA.imageIndex).toBe(0)
expect(nodeB.imageIndex).toBe(5)
expect(store.getState(locA).imageIndex).toBe(0)
expect(store.getState(locB).imageIndex).toBe(5)
})
})
describe('DEFAULT_STATE immutability', () => {
it('default imageRects is frozen and cannot be mutated', () => {
const node = createMockNode()
mockNodeToNodeLocatorId.mockReturnValue(locatorA)
store.installPropertyProjection(node)
// Read default imageRects without triggering state creation
const nodeB = createMockNode()
mockNodeToNodeLocatorId.mockReturnValue(locatorB)
store.installPropertyProjection(nodeB)
// Default arrays should be frozen (no state entry exists yet)
expect(() => {
;(nodeB.imageRects as unknown[]).push([0, 0, 10, 10])
}).toThrow()
})
})
describe('null-to-null transitions', () => {
it('imageIndex null → null works', () => {
const node = createMockNode()
mockNodeToNodeLocatorId.mockReturnValue(locatorA)
store.installPropertyProjection(node)
expect(node.imageIndex).toBeNull()
node.imageIndex = null
expect(node.imageIndex).toBeNull()
})
it('pointerDown null → null works', () => {
const node = createMockNode()
mockNodeToNodeLocatorId.mockReturnValue(locatorA)
store.installPropertyProjection(node)
expect(node.pointerDown).toBeNull()
node.pointerDown = null
expect(node.pointerDown).toBeNull()
})
})
})

View File

@@ -0,0 +1,155 @@
import { defineStore } from 'pinia'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { Point, Rect } from '@/lib/litegraph/src/interfaces'
import type { NodeLocatorId } from '@/types/nodeIdentification'
interface PointerDownState {
index: number | null
pos: Point
}
interface NodeImageState {
imgs: HTMLImageElement[]
imageIndex: number | null
imageRects: Rect[]
pointerDown: PointerDownState | null
overIndex: number | null
}
function createDefaultState(): NodeImageState {
return {
imgs: [],
imageIndex: null,
imageRects: [],
pointerDown: null,
overIndex: null
}
}
const DEFAULT_STATE: Readonly<NodeImageState> = Object.freeze({
...createDefaultState(),
imgs: Object.freeze([]) as unknown as HTMLImageElement[],
imageRects: Object.freeze([]) as unknown as Rect[]
})
/**
* Module-scoped resolver for converting nodes to locator IDs.
* Set once during app bootstrap via {@link setNodeLocatorResolver} to
* avoid a circular dependency: LGraph → nodeImageStore → workflowStore → app → litegraph.
*/
let _nodeLocatorResolver:
| ((node: LGraphNode) => NodeLocatorId | undefined)
| undefined
export function setNodeLocatorResolver(
resolver: (node: LGraphNode) => NodeLocatorId | undefined
): void {
_nodeLocatorResolver = resolver
}
function getNodeLocatorId(node: LGraphNode): NodeLocatorId | undefined {
return _nodeLocatorResolver?.(node)
}
export const useNodeImageStore = defineStore('nodeImage', () => {
const state = new Map<NodeLocatorId, NodeImageState>()
function getState(locatorId: NodeLocatorId): NodeImageState {
const existing = state.get(locatorId)
if (existing) return existing
const entry = createDefaultState()
state.set(locatorId, entry)
return entry
}
function peekState(locatorId: NodeLocatorId): NodeImageState | undefined {
return state.get(locatorId)
}
function clearState(locatorId: NodeLocatorId): void {
state.delete(locatorId)
}
function clearAll(): void {
state.clear()
}
function setStateProperty<K extends keyof NodeImageState>(
locatorId: NodeLocatorId,
prop: K,
value: NodeImageState[K]
): void {
getState(locatorId)[prop] = value
}
function installPropertyProjection(node: LGraphNode): void {
const simpleProperties: (keyof NodeImageState)[] = [
'imageRects',
'pointerDown',
'overIndex',
'imageIndex'
]
const nodeRecord = node as unknown as Record<string, unknown>
for (const prop of simpleProperties) {
const existingValue = nodeRecord[prop]
Object.defineProperty(node, prop, {
get() {
const locatorId = getNodeLocatorId(node)
if (!locatorId) return undefined
return (peekState(locatorId) ?? DEFAULT_STATE)[prop]
},
set(value: unknown) {
const locatorId = getNodeLocatorId(node)
if (!locatorId) return
setStateProperty(
locatorId,
prop,
value as NodeImageState[typeof prop]
)
},
configurable: true,
enumerable: true
})
if (existingValue !== undefined) {
nodeRecord[prop] = existingValue
}
}
// imgs needs special handling: return undefined when empty to preserve
// node.imgs?.length optional chaining semantics
const existingImgs = node.imgs
Object.defineProperty(node, 'imgs', {
get() {
const locatorId = getNodeLocatorId(node)
if (!locatorId) return undefined
const s = peekState(locatorId)
return s?.imgs.length ? s.imgs : undefined
},
set(value: HTMLImageElement[] | undefined) {
const locatorId = getNodeLocatorId(node)
if (!locatorId) return
getState(locatorId).imgs = value ?? []
},
configurable: true,
enumerable: true
})
if (existingImgs !== undefined) {
node.imgs = existingImgs
}
}
return {
getState,
clearState,
clearAll,
installPropertyProjection
}
})

View File

@@ -14,6 +14,7 @@ import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { clone } from '@/scripts/utils'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { useNodeImageStore } from '@/stores/nodeImageStore'
import { parseFilePath } from '@/utils/formatUtil'
import { isAnimatedOutput, isVideoNode } from '@/utils/litegraphUtil'
import {
@@ -359,6 +360,8 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
delete nodePreviewImages.value[nodeLocatorId]
}
useNodeImageStore().clearState(nodeLocatorId)
return hadOutputs
}
@@ -407,6 +410,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
app.nodeOutputs = {}
nodeOutputs.value = {}
revokeAllPreviews()
useNodeImageStore().clearAll()
}
/**