Compare commits

...

14 Commits

Author SHA1 Message Date
Rizumu Ayaka
6a00502045 perf: reduce compositing layers by dynamically sizing TransformPane to contain all nodes 2026-03-13 18:18:18 +08:00
Rizumu Ayaka
7081df4585 fix: update pointer drag handling to ignore right-click and optimize event tracking 2026-03-13 00:41:20 +08:00
github-actions
08b176d429 [automated] Update test expectations 2026-03-12 15:54:47 +00:00
Rizumu Ayaka
e653313141 Merge branch 'main' into rizumu/perf/detect-pointer-drag-in-useTransformSettling-for-pan-optimization
# Conflicts:
#	browser_tests/tests/vueNodes/interactions/canvas/zoom.spec.ts-snapshots/zoomed-in-ctrl-shift-chromium-linux.png
2026-03-12 23:47:43 +08: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
github-actions
a5f6c2e08c [automated] Update test expectations 2026-03-12 12:14:45 +00:00
Rizumu Ayaka
4f1e124e4d ci: fix error 2026-03-12 20:07:47 +08:00
Rizumu Ayaka
7fa78cc458 perf: use 3D transforms for smoother rendering in useTransformState 2026-03-12 19:56:11 +08:00
Rizumu Ayaka
7853cca13f fix: listen for pointerup on window to prevent stuck drag state 2026-03-10 14:16:17 +08:00
Rizumu Ayaka
bf5a5070ea perf: remove unnecessary zoom-level binding in TransformPane 2026-03-09 22:42:18 +08:00
Rizumu Ayaka
46ce7671ab perf: optimize transform pane rendering by using direct DOM mutation 2026-03-09 21:26:14 +08:00
Rizumu Ayaka
67e363fc3c fix: legacy issues with transform-pane--interacting css class 2026-03-09 17:35:48 +08:00
Rizumu Ayaka
d284a32490 perf: restore full JSDoc for useTransformSettling 2026-03-09 17:13:28 +08:00
Rizumu Ayaka
e4240a357d perf: detect pointer drag in useTransformSettling for pan optimization 2026-03-09 16:59:09 +08:00
23 changed files with 535 additions and 246 deletions

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()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -1420,15 +1420,6 @@ audio.comfy-audio.empty-audio-widget {
font-size 0.1s ease;
}
/* Performance optimization during canvas interaction */
.transform-pane--interacting .lg-node * {
transition: none !important;
}
.transform-pane--interacting .lg-node {
will-change: transform;
}
/* ===================== Mask Editor Styles ===================== */
/* To be migrated to Tailwind later */
#maskEditor_brush {

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

@@ -23,6 +23,7 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import { usePaneBounds } from '@/renderer/core/layout/transform/usePaneBounds'
import { app } from '@/scripts/app'
import { DOMWidgetImpl } from '@/scripts/domWidget'
import { promptRenameWidget } from '@/utils/widgetUtil'
@@ -40,6 +41,7 @@ const canvasStore = useCanvasStore()
const settingStore = useSettingStore()
const workflowStore = useWorkflowStore()
const { t } = useI18n()
const { offset: paneBoundsOffset } = usePaneBounds()
const canvas: LGraphCanvas = canvasStore.getCanvas()
const { isSelectMode, isSelectInputsMode, isSelectOutputsMode, isArrangeMode } =
@@ -116,8 +118,8 @@ function getBounding(nodeId: NodeId, widgetName?: string) {
return {
width: `${node.size[0]}px`,
height: `${node.size[1] + titleOffset}px`,
left: `${node.pos[0]}px`,
top: `${node.pos[1] - titleOffset}px`
left: `${node.pos[0] + paneBoundsOffset.x}px`,
top: `${node.pos[1] - titleOffset + paneBoundsOffset.y}px`
}
if (!widget) return
@@ -130,8 +132,8 @@ function getBounding(nodeId: NodeId, widgetName?: string) {
return {
width: `${node.size[0] - marginX * 2}px`,
height: `${height}px`,
left: `${node.pos[0] + marginX}px`,
top: `${node.pos[1] + widget.y + (margin ?? 0)}px`
left: `${node.pos[0] + marginX + paneBoundsOffset.x}px`,
top: `${node.pos[1] + widget.y + (margin ?? 0) + paneBoundsOffset.y}px`
}
}

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

@@ -81,7 +81,6 @@
? 'Execution error'
: null
"
:zoom-level="canvasStore.canvas?.ds?.scale || 1"
:data-node-id="nodeData.id"
/>
</TransformPane>

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

@@ -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

@@ -133,21 +133,6 @@ describe('TransformPane', () => {
expect.any(Function),
expect.any(Object)
)
expect(mockCanvas.canvas.addEventListener).not.toHaveBeenCalledWith(
'pointerdown',
expect.any(Function),
expect.any(Object)
)
expect(mockCanvas.canvas.addEventListener).not.toHaveBeenCalledWith(
'pointerup',
expect.any(Function),
expect.any(Object)
)
expect(mockCanvas.canvas.addEventListener).not.toHaveBeenCalledWith(
'pointercancel',
expect.any(Function),
expect.any(Object)
)
})
it('should remove event listeners on unmount', async () => {
@@ -166,43 +151,10 @@ describe('TransformPane', () => {
expect.any(Function),
expect.any(Object)
)
expect(mockCanvas.canvas.removeEventListener).not.toHaveBeenCalledWith(
'pointerdown',
expect.any(Function),
expect.any(Object)
)
expect(mockCanvas.canvas.removeEventListener).not.toHaveBeenCalledWith(
'pointerup',
expect.any(Function),
expect.any(Object)
)
expect(mockCanvas.canvas.removeEventListener).not.toHaveBeenCalledWith(
'pointercancel',
expect.any(Function),
expect.any(Object)
)
})
})
describe('interaction state management', () => {
it('should apply interacting class during interactions', async () => {
const mockCanvas = createMockLGraphCanvas()
const wrapper = mount(TransformPane, {
props: {
canvas: mockCanvas
}
})
// Simulate interaction start by checking internal state
// Note: This tests the CSS class application logic
const transformPane = wrapper.find('[data-testid="transform-pane"]')
// Initially should not have interacting class
expect(transformPane.classes()).not.toContain(
'transform-pane--interacting'
)
})
it('should handle pointer events for node delegation', async () => {
const mockCanvas = createMockLGraphCanvas()
const wrapper = mount(TransformPane, {

View File

@@ -1,13 +1,9 @@
<template>
<div
ref="transformPaneRef"
data-testid="transform-pane"
:class="
cn(
'pointer-events-none absolute inset-0 size-full',
isInteracting ? 'transform-pane--interacting' : 'will-change-auto'
)
"
:style="transformStyle"
class="pointer-events-none absolute will-change-auto contain-layout contain-size contain-style"
:style="paneStyle"
>
<!-- Vue nodes will be rendered here -->
<slot />
@@ -16,12 +12,12 @@
<script setup lang="ts">
import { useRafFn } from '@vueuse/core'
import { computed } from 'vue'
import { computed, useTemplateRef, watch } from 'vue'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { usePaneBounds } from '@/renderer/core/layout/transform/usePaneBounds'
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
import { cn } from '@/utils/tailwindUtil'
interface TransformPaneProps {
canvas?: LGraphCanvas
@@ -29,26 +25,55 @@ interface TransformPaneProps {
const props = defineProps<TransformPaneProps>()
const { transformStyle, syncWithCanvas } = useTransformState()
const { syncWithCanvas, camera } = useTransformState()
const { offset, size, expandToContain } = usePaneBounds()
const canvasElement = computed(() => props.canvas?.canvas)
const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
settleDelay: 16
settleDelay: 256
})
const transformPaneRef = useTemplateRef('transformPaneRef')
/** Reactive pane dimensions — only changes when bounds grow. */
const paneStyle = computed(() => ({
width: `${size.width}px`,
height: `${size.height}px`
}))
/**
* Apply transform via direct DOM mutation instead of reactive template
* bindings. The transform changes every animation frame during pan/zoom;
* a reactive binding would cause Vue to diff the entire vnode subtree
* (including all child node slots) on every frame.
*/
const adjustedTransform = computed(() => ({
transform: `scale3d(${camera.z}, ${camera.z}, ${camera.z}) translate3d(${camera.x - offset.x}px, ${camera.y - offset.y}px, 0)`,
transformOrigin: '0 0'
}))
watch([adjustedTransform, transformPaneRef], ([newStyle, el]) => {
if (el) {
Object.assign(el.style, newStyle)
}
})
watch([isInteracting, transformPaneRef], ([interacting, el]) => {
if (el) {
el.classList.toggle('will-change-transform', interacting)
el.classList.toggle('will-change-auto', !interacting)
}
})
useRafFn(
() => {
if (!props.canvas) {
return
}
if (!props.canvas) return
syncWithCanvas(props.canvas)
const nodes = props.canvas.graph?.nodes
if (nodes?.length) {
expandToContain(nodes)
}
},
{ immediate: true }
)
</script>
<style scoped>
.transform-pane--interacting {
will-change: transform;
}
</style>

View File

@@ -0,0 +1,75 @@
/**
* Manages the TransformPane's box-model size and coordinate offset so that
* all canvas nodes sit within the div's pre-transform bounds.
*
* Chrome paints composited-layer children into the parent's GPU texture only
* when they are within the parent's pre-transform box. Nodes outside the box
* get promoted to individual compositing layers — causing "layer explosion"
* that destroys pan/zoom frame rates.
*
* This composable dynamically computes the minimum offset and size needed to
* contain every node in positive coordinate space. The offset is added to each
* node's CSS `translate` and subtracted from the camera's `translate3d` so the
* on-screen positions remain identical.
*
* Values only grow (never shrink) to avoid triggering mass re-renders when a
* single node moves.
*/
import { createSharedComposable } from '@vueuse/core'
import { reactive, readonly } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
const PADDING = 2000
const INITIAL_OFFSET = 5000
function usePaneBoundsIndividual() {
const offset = reactive({ x: INITIAL_OFFSET, y: INITIAL_OFFSET })
const size = reactive({
width: INITIAL_OFFSET * 2,
height: INITIAL_OFFSET * 2
})
/**
* Expand the pane to contain all given nodes.
* Only grows offset and size — never shrinks — to avoid triggering
* reactive style updates on every node when a single node moves.
*/
function expandToContain(nodes: LGraphNode[]) {
if (nodes.length === 0) return
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const node of nodes) {
const x = node.pos[0]
const y = node.pos[1]
minX = Math.min(minX, x)
minY = Math.min(minY, y)
maxX = Math.max(maxX, x + node.size[0])
maxY = Math.max(maxY, y + node.size[1])
}
const neededOffsetX = Math.max(Math.ceil(-minX) + PADDING, INITIAL_OFFSET)
const neededOffsetY = Math.max(Math.ceil(-minY) + PADDING, INITIAL_OFFSET)
if (neededOffsetX > offset.x) offset.x = neededOffsetX
if (neededOffsetY > offset.y) offset.y = neededOffsetY
const neededWidth = Math.ceil(maxX) + offset.x + PADDING
const neededHeight = Math.ceil(maxY) + offset.y + PADDING
if (neededWidth > size.width) size.width = neededWidth
if (neededHeight > size.height) size.height = neededHeight
}
return {
offset: readonly(offset),
size: readonly(size),
expandToContain
}
}
export const usePaneBounds = createSharedComposable(usePaneBoundsIndividual)

View File

@@ -69,11 +69,40 @@ describe('useTransformSettling', () => {
expect(isTransforming.value).toBe(false)
})
it('should not track pan events', async () => {
it('should track pointer drag as pan interaction', async () => {
const { isTransforming } = useTransformSettling(element, {
settleDelay: 200
})
element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
await nextTick()
expect(isTransforming.value).toBe(true)
element.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
vi.advanceTimersByTime(200)
expect(isTransforming.value).toBe(false)
})
it('should not treat right-click as pan', async () => {
const { isTransforming } = useTransformSettling(element, {
settleDelay: 200
})
element.dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true, button: 2 })
)
element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
await nextTick()
expect(isTransforming.value).toBe(false)
})
it('should not track pointermove without pointerdown', async () => {
const { isTransforming } = useTransformSettling(element)
// Pointer events should not trigger transform
element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
await nextTick()

View File

@@ -5,7 +5,7 @@ import type { MaybeRefOrGetter } from 'vue'
interface TransformSettlingOptions {
/**
* Delay in ms before transform is considered "settled" after last interaction
* @default 200
* @default 256
*/
settleDelay?: number
/**
@@ -16,10 +16,10 @@ interface TransformSettlingOptions {
}
/**
* Tracks when canvas zoom transforms are actively changing vs settled.
* Tracks when canvas transforms (zoom or pan) are actively changing vs settled.
*
* This composable helps optimize rendering quality during zoom transformations.
* When the user is actively zooming, we can reduce rendering quality
* This composable helps optimize rendering quality during transform interactions.
* When the user is actively zooming or panning, we can reduce rendering quality
* for better performance. Once the transform "settles" (stops changing), we can
* trigger high-quality re-rasterization.
*
@@ -50,35 +50,72 @@ export function useTransformSettling(
const isTransforming = ref(false)
/**
* Mark transform as active
*/
const markTransformActive = () => {
isTransforming.value = true
}
/**
* Mark transform as settled (debounced)
*/
const markTransformSettled = useDebounceFn(() => {
isTransforming.value = false
}, settleDelay)
/**
* Handle zoom transform event - mark active then queue settle
*/
const handleWheel = () => {
markTransformActive()
function markInteracting() {
isTransforming.value = true
void markTransformSettled()
}
// Register wheel event listener with auto-cleanup
useEventListener(target, 'wheel', handleWheel, {
capture: true,
passive
})
const eventOptions = { capture: true, passive }
useEventListener(target, 'wheel', markInteracting, eventOptions)
usePointerDrag(target, markInteracting, eventOptions)
return {
isTransforming
}
}
/**
* Calls `onDrag` on each pointermove while a pointer is held down.
*/
function usePointerDrag(
target: MaybeRefOrGetter<HTMLElement | null | undefined>,
onDrag: () => void,
eventOptions: AddEventListenerOptions
) {
/** Number of active pointers (supports multi-touch correctly). */
const pointerCount = ref(0)
useEventListener(
target,
'pointerdown',
(e: PointerEvent) => {
// Only primary (0) and middle (1) buttons trigger canvas pan.
if (e.button === 0 || e.button === 1) pointerCount.value++
},
eventOptions
)
useEventListener(
target,
'pointermove',
() => {
if (pointerCount.value > 0) onDrag()
},
eventOptions
)
// Listen on window so the release is caught even if the pointer
// leaves the canvas before the button is released.
useEventListener(
window,
'pointerup',
() => {
if (pointerCount.value > 0) pointerCount.value--
},
eventOptions
)
useEventListener(
window,
'pointercancel',
() => {
if (pointerCount.value > 0) pointerCount.value--
},
eventOptions
)
}

View File

@@ -47,7 +47,7 @@ describe('useTransformState', () => {
it('should generate correct initial transform style', () => {
const { transformStyle } = transformState
expect(transformStyle.value).toEqual({
transform: 'scale(1) translate(0px, 0px)',
transform: 'scale3d(1, 1, 1) translate3d(0px, 0px, 0)',
transformOrigin: '0 0'
})
})
@@ -102,7 +102,7 @@ describe('useTransformState', () => {
syncWithCanvas(mockCanvas as LGraphCanvas)
expect(transformStyle.value).toEqual({
transform: 'scale(0.5) translate(150px, 75px)',
transform: 'scale3d(0.5, 0.5, 0.5) translate3d(150px, 75px, 0)',
transformOrigin: '0 0'
})
})

View File

@@ -79,7 +79,9 @@ function useTransformStateIndividual() {
// ctx.scale(scale); ctx.translate(offset)
// CSS applies right-to-left, so "scale() translate()" -> translate first, then scale
// Effective mapping: screen = (canvas + offset) * scale
transform: `scale(${camera.z}) translate(${camera.x}px, ${camera.y}px)`,
// Using the 3D versions of scale and translate can provide a smoother experience
// when dealing with a large number of nodes.
transform: `scale3d(${camera.z}, ${camera.z}, ${camera.z}) translate3d(${camera.x}px, ${camera.y}px, 0)`,
transformOrigin: '0 0'
}))

View File

@@ -21,7 +21,7 @@
"
:style="[
{
transform: `translate(${position.x ?? 0}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
transform: `translate(${(position.x ?? 0) + paneBoundsOffset.x}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT + paneBoundsOffset.y}px)`,
zIndex: zIndex,
opacity: nodeOpacity
}
@@ -265,6 +265,7 @@ import {
LiteGraph,
RenderShape
} from '@/lib/litegraph/src/litegraph'
import { usePaneBounds } from '@/renderer/core/layout/transform/usePaneBounds'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -322,6 +323,7 @@ interface LGraphNodeProps {
const { nodeData, error = null } = defineProps<LGraphNodeProps>()
const { t } = useI18n()
const { offset: paneBoundsOffset } = usePaneBounds()
const { isSelectMode, isSelectOutputsMode } = useAppMode()
const settingStore = useSettingStore()