mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
Merge branch 'main' into deepme987/feat/missing-node-telemetry
This commit is contained in:
@@ -24,6 +24,20 @@ test.describe(
|
||||
)
|
||||
})
|
||||
|
||||
test('@mobile graph canvas toolbar visible', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const minimapButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.toggleMinimapButton
|
||||
)
|
||||
await expect(minimapButton).toBeVisible()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'mobile-graph-canvas-toolbar.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('@mobile settings dialog', async ({ comfyPage }) => {
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
28
src/components/LiteGraphCanvasSplitterOverlay.test.ts
Normal file
28
src/components/LiteGraphCanvasSplitterOverlay.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Regression test: the graph-canvas-panel SplitterPanel must not clip
|
||||
* absolutely-positioned children (like GraphCanvasMenu).
|
||||
*
|
||||
* PrimeVue applies `overflow: hidden` to all SplitterPanels by default.
|
||||
* Without an explicit `overflow-visible` override, the bottom-right canvas
|
||||
* toolbar becomes invisible on mobile viewports where the panel's bounding
|
||||
* box is smaller than the full canvas area.
|
||||
*
|
||||
* @see https://www.notion.so/Bug-Graph-canvas-toolbar-not-visible-on-mobile-3246d73d36508144ae00f10065c42fac
|
||||
*/
|
||||
describe('LiteGraphCanvasSplitterOverlay', () => {
|
||||
it('graph-canvas-panel has overflow-visible to prevent clipping toolbar on mobile', () => {
|
||||
const filePath = resolve(__dirname, 'LiteGraphCanvasSplitterOverlay.vue')
|
||||
const source = readFileSync(filePath, 'utf-8')
|
||||
|
||||
// The SplitterPanel wrapping graph-canvas-panel must include overflow-visible
|
||||
// to override PrimeVue's default overflow:hidden on .p-splitterpanel.
|
||||
// Without this, GraphCanvasMenu (absolute right-0 bottom-0) gets clipped on mobile.
|
||||
expect(source).toMatch(
|
||||
/class="[^"]*graph-canvas-panel[^"]*overflow-visible/
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -72,7 +72,7 @@
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
>
|
||||
<SplitterPanel class="graph-canvas-panel relative">
|
||||
<SplitterPanel class="graph-canvas-panel relative overflow-visible">
|
||||
<slot name="graph-canvas-panel" />
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
|
||||
@@ -164,9 +164,11 @@ import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables
|
||||
import { useWorkflowPersistenceV2 as useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistenceV2'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
|
||||
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
|
||||
import { requestSlotLayoutSyncForAllNodes } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
|
||||
import { UnauthorizedError } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
@@ -207,6 +209,7 @@ const workspaceStore = useWorkspaceStore()
|
||||
const { isBuilderMode } = useAppMode()
|
||||
const canvasStore = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { linearMode } = storeToRefs(canvasStore)
|
||||
const executionStore = useExecutionStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const toastStore = useToastStore()
|
||||
@@ -279,6 +282,22 @@ watch(
|
||||
const allNodes = computed((): VueNodeData[] =>
|
||||
Array.from(vueNodeLifecycle.nodeManager.value?.vueNodeData?.values() ?? [])
|
||||
)
|
||||
watch(
|
||||
() => linearMode.value,
|
||||
(isLinearMode) => {
|
||||
if (!shouldRenderVueNodes.value) return
|
||||
|
||||
if (isLinearMode) {
|
||||
layoutStore.clearAllSlotLayouts()
|
||||
} else {
|
||||
// App mode hides the graph canvas with `display: none`, so slot connectors
|
||||
// need a fresh DOM measurement pass before links can render correctly.
|
||||
requestSlotLayoutSyncForAllNodes()
|
||||
}
|
||||
|
||||
layoutStore.setPendingSlotSync(true)
|
||||
}
|
||||
)
|
||||
|
||||
function onLinkOverlayReady(el: HTMLCanvasElement) {
|
||||
if (!canvasStore.canvas) return
|
||||
|
||||
@@ -236,6 +236,7 @@ watch(
|
||||
() => isActiveSubscription.value,
|
||||
(isActive) => {
|
||||
if (isActive && showCustomPricingTable.value) {
|
||||
telemetry?.trackMonthlySubscriptionSucceeded()
|
||||
emit('close', true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,14 @@ describe('GtmTelemetryProvider', () => {
|
||||
expect(lastDataLayerEntry()).toMatchObject({ event: 'select_promotion' })
|
||||
})
|
||||
|
||||
it('pushes subscription_success for subscription activation', () => {
|
||||
const provider = createInitializedProvider()
|
||||
provider.trackMonthlySubscriptionSucceeded()
|
||||
expect(lastDataLayerEntry()).toMatchObject({
|
||||
event: 'subscription_success'
|
||||
})
|
||||
})
|
||||
|
||||
it('pushes run_workflow with trigger_source', () => {
|
||||
const provider = createInitializedProvider()
|
||||
provider.trackRunButton({ trigger_source: 'button' })
|
||||
|
||||
@@ -165,6 +165,10 @@ export class GtmTelemetryProvider implements TelemetryProvider {
|
||||
this.pushEvent('signup_opened')
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionSucceeded(): void {
|
||||
this.pushEvent('subscription_success')
|
||||
}
|
||||
|
||||
trackRunButton(options?: {
|
||||
subscribe_to_run?: boolean
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
|
||||
@@ -47,6 +47,14 @@ vi.mock('@/stores/dialogStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const mockTrackMonthlySubscriptionSucceeded = vi.fn()
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackMonthlySubscriptionSucceeded: mockTrackMonthlySubscriptionSucceeded
|
||||
})
|
||||
}))
|
||||
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { useBillingOperationStore } from './billingOperationStore'
|
||||
@@ -159,6 +167,37 @@ describe('billingOperationStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('fires purchase telemetry on subscription success', async () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'succeeded',
|
||||
started_at: new Date().toISOString(),
|
||||
completed_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
store.startOperation('op-1', 'subscription')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(mockTrackMonthlySubscriptionSucceeded).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not fire purchase telemetry on topup success', async () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'succeeded',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
store.startOperation('op-1', 'topup')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(mockTrackMonthlySubscriptionSucceeded).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows topup success message for topup operations', async () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { t } from '@/i18n'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
@@ -133,6 +134,10 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
|
||||
updateOperationStatus(opId, 'succeeded', null)
|
||||
cleanup(opId)
|
||||
|
||||
if (operation.type === 'subscription') {
|
||||
useTelemetry()?.trackMonthlySubscriptionSucceeded()
|
||||
}
|
||||
|
||||
const billingContext = useBillingContext()
|
||||
await Promise.all([
|
||||
billingContext.fetchStatus(),
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useNodeSlotRegistryStore } from '@/renderer/extensions/vueNodes/stores/
|
||||
import {
|
||||
syncNodeSlotLayoutsFromDOM,
|
||||
flushScheduledSlotLayoutSync,
|
||||
requestSlotLayoutSyncForAllNodes,
|
||||
useSlotElementTracking
|
||||
} from './useSlotElementTracking'
|
||||
|
||||
@@ -55,7 +56,10 @@ function createWrapperComponent(type: 'input' | 'output') {
|
||||
*/
|
||||
async function mountAndRegisterSlot(type: 'input' | 'output') {
|
||||
const wrapper = mount(createWrapperComponent(type))
|
||||
wrapper.vm.el = document.createElement('div')
|
||||
const slotEl = document.createElement('div')
|
||||
slotEl.getBoundingClientRect = vi.fn(() => new DOMRect(100, 200, 16, 16))
|
||||
document.body.append(slotEl)
|
||||
wrapper.vm.el = slotEl
|
||||
await nextTick()
|
||||
flushScheduledSlotLayoutSync()
|
||||
return wrapper
|
||||
@@ -64,6 +68,7 @@ async function mountAndRegisterSlot(type: 'input' | 'output') {
|
||||
describe('useSlotElementTracking', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
document.body.innerHTML = ''
|
||||
layoutStore.initializeFromLiteGraph([])
|
||||
layoutStore.applyOperation({
|
||||
type: 'createNode',
|
||||
@@ -134,9 +139,55 @@ describe('useSlotElementTracking', () => {
|
||||
expect(layoutStore.pendingSlotSync).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps pendingSlotSync when all registered slots are hidden', () => {
|
||||
const slotKey = getSlotKey(NODE_ID, SLOT_INDEX, true)
|
||||
const hiddenSlot = document.createElement('div')
|
||||
|
||||
const registryStore = useNodeSlotRegistryStore()
|
||||
const node = registryStore.ensureNode(NODE_ID)
|
||||
node.slots.set(slotKey, {
|
||||
el: hiddenSlot,
|
||||
index: SLOT_INDEX,
|
||||
type: 'input'
|
||||
})
|
||||
|
||||
layoutStore.setPendingSlotSync(true)
|
||||
requestSlotLayoutSyncForAllNodes()
|
||||
|
||||
expect(layoutStore.pendingSlotSync).toBe(true)
|
||||
expect(layoutStore.getSlotLayout(slotKey)).toBeNull()
|
||||
})
|
||||
|
||||
it('removes stale slot layouts when slot element is hidden', () => {
|
||||
const slotKey = getSlotKey(NODE_ID, SLOT_INDEX, true)
|
||||
const hiddenSlot = document.createElement('div')
|
||||
|
||||
const staleLayout: SlotLayout = {
|
||||
nodeId: NODE_ID,
|
||||
index: SLOT_INDEX,
|
||||
type: 'input',
|
||||
position: { x: 10, y: 20 },
|
||||
bounds: { x: 6, y: 16, width: 8, height: 8 }
|
||||
}
|
||||
layoutStore.batchUpdateSlotLayouts([{ key: slotKey, layout: staleLayout }])
|
||||
|
||||
const registryStore = useNodeSlotRegistryStore()
|
||||
const node = registryStore.ensureNode(NODE_ID)
|
||||
node.slots.set(slotKey, {
|
||||
el: hiddenSlot,
|
||||
index: SLOT_INDEX,
|
||||
type: 'input'
|
||||
})
|
||||
|
||||
syncNodeSlotLayoutsFromDOM(NODE_ID)
|
||||
|
||||
expect(layoutStore.getSlotLayout(slotKey)).toBeNull()
|
||||
})
|
||||
|
||||
it('skips slot layout writeback when measured slot geometry is unchanged', () => {
|
||||
const slotKey = getSlotKey(NODE_ID, SLOT_INDEX, true)
|
||||
const slotEl = document.createElement('div')
|
||||
document.body.append(slotEl)
|
||||
slotEl.getBoundingClientRect = vi.fn(() => new DOMRect(100, 200, 16, 16))
|
||||
|
||||
const registryStore = useNodeSlotRegistryStore()
|
||||
|
||||
@@ -33,6 +33,38 @@ function scheduleSlotLayoutSync(nodeId: string) {
|
||||
raf.schedule()
|
||||
}
|
||||
|
||||
function shouldWaitForSlotLayouts(): boolean {
|
||||
const graph = app.canvas?.graph
|
||||
const hasNodes = Boolean(graph && graph._nodes && graph._nodes.length > 0)
|
||||
return hasNodes && !layoutStore.hasSlotLayouts
|
||||
}
|
||||
|
||||
function completePendingSlotSync(): void {
|
||||
layoutStore.setPendingSlotSync(false)
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function getSlotElementRect(el: HTMLElement): DOMRect | null {
|
||||
if (!el.isConnected) return null
|
||||
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width <= 0 || rect.height <= 0) return null
|
||||
return rect
|
||||
}
|
||||
|
||||
export function requestSlotLayoutSyncForAllNodes(): void {
|
||||
const nodeSlotRegistryStore = useNodeSlotRegistryStore()
|
||||
for (const nodeId of nodeSlotRegistryStore.getNodeIds()) {
|
||||
scheduleSlotLayoutSync(nodeId)
|
||||
}
|
||||
|
||||
// If no slots are currently registered, run the completion check immediately
|
||||
// so pendingSlotSync can be cleared when the graph has no nodes.
|
||||
if (pendingNodes.size === 0) {
|
||||
flushScheduledSlotLayoutSync()
|
||||
}
|
||||
}
|
||||
|
||||
function createSlotLayout(options: {
|
||||
nodeId: string
|
||||
index: number
|
||||
@@ -60,17 +92,14 @@ function createSlotLayout(options: {
|
||||
export function flushScheduledSlotLayoutSync() {
|
||||
if (pendingNodes.size === 0) {
|
||||
// No pending nodes - check if we should wait for Vue components to mount
|
||||
const graph = app.canvas?.graph
|
||||
const hasNodes = graph && graph._nodes && graph._nodes.length > 0
|
||||
if (hasNodes && !layoutStore.hasSlotLayouts) {
|
||||
if (shouldWaitForSlotLayouts()) {
|
||||
// Graph has nodes but no slot layouts yet - Vue hasn't mounted.
|
||||
// Keep flag set so late mounts can re-assert via scheduleSlotLayoutSync()
|
||||
return
|
||||
}
|
||||
// Either no nodes (nothing to wait for) or slot layouts already exist
|
||||
// (undo/redo preserved them). Clear the flag so links can render.
|
||||
layoutStore.setPendingSlotSync(false)
|
||||
app.canvas?.setDirty(true, true)
|
||||
completePendingSlotSync()
|
||||
return
|
||||
}
|
||||
const conv = useSharedCanvasPositionConversion()
|
||||
@@ -78,10 +107,12 @@ export function flushScheduledSlotLayoutSync() {
|
||||
pendingNodes.delete(nodeId)
|
||||
syncNodeSlotLayoutsFromDOM(nodeId, conv)
|
||||
}
|
||||
// Clear the pending sync flag - slots are now synced
|
||||
layoutStore.setPendingSlotSync(false)
|
||||
// Trigger canvas redraw now that links can render with correct positions
|
||||
app.canvas?.setDirty(true, true)
|
||||
|
||||
// Keep pending sync active until at least one measurable slot layout has
|
||||
// been captured for the current graph.
|
||||
if (shouldWaitForSlotLayouts()) return
|
||||
|
||||
completePendingSlotSync()
|
||||
}
|
||||
|
||||
export function syncNodeSlotLayoutsFromDOM(
|
||||
@@ -99,7 +130,14 @@ export function syncNodeSlotLayoutsFromDOM(
|
||||
const positionConv = conv ?? useSharedCanvasPositionConversion()
|
||||
|
||||
for (const [slotKey, entry] of node.slots) {
|
||||
const rect = entry.el.getBoundingClientRect()
|
||||
const rect = getSlotElementRect(entry.el)
|
||||
if (!rect) {
|
||||
// Drop stale layout values while the slot is hidden so we don't render
|
||||
// links with off-screen coordinates from a previous graph/tab state.
|
||||
layoutStore.deleteSlotLayout(slotKey)
|
||||
continue
|
||||
}
|
||||
|
||||
const screenCenter: [number, number] = [
|
||||
rect.left + rect.width / 2,
|
||||
rect.top + rect.height / 2
|
||||
|
||||
@@ -41,10 +41,15 @@ export const useNodeSlotRegistryStore = defineStore('nodeSlotRegistry', () => {
|
||||
registry.clear()
|
||||
}
|
||||
|
||||
function getNodeIds(): string[] {
|
||||
return Array.from(registry.keys())
|
||||
}
|
||||
|
||||
return {
|
||||
getNode,
|
||||
ensureNode,
|
||||
deleteNode,
|
||||
clear
|
||||
clear,
|
||||
getNodeIds
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user