diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cfe403..41a7661 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Max Height now defaults to 600px. ### Fixed +- **Nodes 2.0 (Vue nodes)**: The glass shows the nodes, painted from the page: boxes, borders, text, widget values and prompts (icons are left out). The Inspector finds the node under the cursor, also in pan mode, and the highlight fits collapsed nodes. +- **Node Title Emphasis** sits on the title bar instead of the first slot row, and is not drawn on nodes without a title bar such as Reroute. +- The glass background matches the page past the canvas edge and at canvas zoom of 1.5 and above. - **Subgraphs**: The panel, picker, highlight and editing use the graph on screen, so they work inside subgraphs. Title bars and collapsed nodes are hit. - **Widget edits** run the widget's callback, redraw the canvas and record undo. CLIPLoader's `type` widget shows again. - Dropdowns no longer get orphaned or keep writing after the panel re-renders, and they can be filtered by typing. diff --git a/README.md b/README.md index 068bd70..281bd29 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ Access settings via the **new Sidebar Panel** or the ComfyUI Settings (⚙️) m | **Force Direct Capture**| `Off` | High-accuracy mode for low zoom levels. | | **Show Mini Cursor** | `Off` | Displays a cursor preview inside the glass. | -> Image previews, videos and animated images show in the glass in both capture modes. In Nodes 2.0 (Vue nodes) mode the glass always uses Direct Capture. +> Image previews, videos and animated images show in the glass in both capture modes. In Nodes 2.0 (Vue nodes) mode the glass always uses Direct Capture and paints the nodes from the page, without their icons. diff --git a/src/info-panel/CanvasHighlighter.ts b/src/info-panel/CanvasHighlighter.ts index d06d24a..f421198 100644 --- a/src/info-panel/CanvasHighlighter.ts +++ b/src/info-panel/CanvasHighlighter.ts @@ -113,30 +113,21 @@ export class CanvasHighlighter { const scale = ds.scale; const offset = ds.offset; - const LiteGraph = (window as any).LiteGraph; - const titleHeight = LiteGraph?.NODE_TITLE_HEIGHT ?? 30; const padding = 6; - // Graph coordinates - // Assuming node.pos is top of body, we shift up by titleHeight to cover title - // And use total height = title + body - const graphX = node.pos[0]; - const graphY = node.pos[1] - titleHeight; - const graphW = node.size[0]; - const graphH = titleHeight + node.size[1]; + // Graph coordinates of the title and body. LiteGraph measures a collapsed node as its title pill, + // while a Vue node writes its DOM size, collapsed or not, to node.size below the title + const LiteGraph = (window as any).LiteGraph; + const titleHeight = LiteGraph?.NODE_TITLE_HEIGHT ?? 30; + const [graphX, graphY, graphW, graphH] = LiteGraph?.vueNodesMode + ? [node.pos[0], node.pos[1] - titleHeight, node.size[0], node.size[1] + titleHeight] + : node.getBounding(); - // Canvas-relative screen coordinates (CSS pixels within the canvas element) - // LiteGraph transform is: (Graph + Offset) * Scale - const canvasX = (graphX + offset[0]) * scale; - const canvasY = (graphY + offset[1]) * scale; - const canvasW = graphW * scale; - const canvasH = graphH * scale; - - // Page-absolute coordinates (add canvas position on the page) - const pageX = canvasRect.left + canvasX - padding; - const pageY = canvasRect.top + canvasY - padding; // Removed hardcoded offset - const finalW = canvasW + (padding * 2); - const finalH = canvasH + (padding * 2); + // Page coordinates: LiteGraph draws (graph + offset) * scale from the canvas corner + const pageX = canvasRect.left + (graphX + offset[0]) * scale - padding; + const pageY = canvasRect.top + (graphY + offset[1]) * scale - padding; + const finalW = graphW * scale + (padding * 2); + const finalH = graphH * scale + (padding * 2); // 5. Update DOM, clipped to the canvas element (negative insets keep the glow inside it) const clip = `inset(${canvasRect.top - pageY}px ${pageX + finalW - canvasRect.right}px ${pageY + finalH - canvasRect.bottom}px ${canvasRect.left - pageX}px)`; diff --git a/src/info-panel/InformationGatherer.ts b/src/info-panel/InformationGatherer.ts index 86582e0..2a4bd15 100644 --- a/src/info-panel/InformationGatherer.ts +++ b/src/info-panel/InformationGatherer.ts @@ -51,6 +51,15 @@ export class InformationGatherer { return null; } + // Vue nodes are DOM over the canvas: it gets no pointer moves over them and stacks them differently. + // In pan mode (space or the hand tool) they ignore the pointer, so the canvas hit test below applies + if ((window as any).LiteGraph?.vueNodesMode && !app.canvas.read_only) { + const { x, y } = magnifyGlass.lastKnownMousePosition; + // Slots stick out of the node element, so a hit on one is not a hit on the node element itself + const nodeEl = document.elementsFromPoint(x, y).map(el => el.closest('.lg-node[data-node-id]')).find(Boolean); + return nodeEl ? graph.getNodeById(Number(nodeEl.dataset.nodeId)) ?? null : null; + } + if (app.canvas.node_over) { return app.canvas.node_over; } diff --git a/src/magnify-glass/OffscreenRenderer.ts b/src/magnify-glass/OffscreenRenderer.ts index b6cd489..8a0fa3f 100644 --- a/src/magnify-glass/OffscreenRenderer.ts +++ b/src/magnify-glass/OffscreenRenderer.ts @@ -94,7 +94,7 @@ export class OffscreenRenderer { if (virtualZoom) { return this.renderVirtualZoom(lgCanvas, targetCanvas, rect, dpr, pivotX, pivotY, cap, media); } - return this.renderDirectCapture(targetCanvas, dpr, cap, media, vueNodes); + return this.renderDirectCapture(targetCanvas, rect, dpr, cap, media, vueNodes); } /** @@ -145,6 +145,9 @@ export class OffscreenRenderer { const renderSize = this.config.glassSize; this.offscreenCtx!.save(); this.offscreenCtx!.filter = this.accessibilityFilter(); + // The canvas shows the page behind it past its edge, and through its grid at zoom 1.5 and above + this.offscreenCtx!.fillStyle = getComputedStyle(document.body).backgroundColor; + this.offscreenCtx!.fillRect(0, 0, renderSize, renderSize); this.offscreenCtx!.drawImage( targetCanvas, cap.left * dpr, cap.top * dpr, cap.size * dpr, cap.size * dpr, @@ -157,7 +160,7 @@ export class OffscreenRenderer { * Mode 1: Direct Capture (Nuclear Fix) * Copies pixels directly from screen. Zero drift. */ - private renderDirectCapture(targetCanvas: HTMLCanvasElement, dpr: number, cap: CaptureRegion, media: DomMedia[], vueNodes: boolean): HTMLCanvasElement | null { + private renderDirectCapture(targetCanvas: HTMLCanvasElement, rect: DOMRect, dpr: number, cap: CaptureRegion, media: DomMedia[], vueNodes: boolean): HTMLCanvasElement | null { try { this.isCapturing = true; const renderSize = this.config.glassSize; @@ -168,7 +171,8 @@ export class OffscreenRenderer { const ds = app.canvas.ds; const scale = ds.scale; const offset: [number, number] = [ds.offset[0], ds.offset[1]]; - // Vue nodes draw their own text and titles in the DOM + // Vue nodes are DOM over the canvas, so the capture has none of them + if (vueNodes) this.drawVueNodes(rect, cap, scale); const visibleNodes = vueNodes ? [] : this.getVisibleNodes(cap, scale, offset); this.drawMedia(targetCanvas, dpr, visibleNodes, media, cap, scale, offset); @@ -365,6 +369,122 @@ export class OffscreenRenderer { ctx.restore(); } + /** + * Paint the Vue nodes under the glass from the DOM, back to front: element backgrounds and borders, + * text, and input values. Icons, pseudo-elements and shadows are left out; drawMedia() adds images and videos. + */ + private drawVueNodes(rect: DOMRect, cap: CaptureRegion, scale: number): void { + const ctx = this.offscreenCtx!; + // Node layout CSS pixels to glass pixels: the nodes' transform pane is scaled by the canvas zoom + const k = scale * cap.k; + const glassBox = (r: DOMRect) => toGlass({ x: r.left - rect.left, y: r.top - rect.top, w: r.width, h: r.height }, cap); + const onGlass = (g: Box) => g.w > 0 && g.h > 0 && g.x < this.config.glassSize && g.y < this.config.glassSize && g.x + g.w > 0 && g.y + g.h > 0; + const fontOf = (cs: CSSStyleDeclaration) => `${cs.fontStyle} ${cs.fontWeight} ${parseFloat(cs.fontSize) * k}px ${cs.fontFamily}`; + + const paint = (el: Element, alpha: number) => { + if (el instanceof SVGElement || el instanceof HTMLImageElement || el instanceof HTMLVideoElement || el instanceof HTMLCanvasElement) return; + const cs = getComputedStyle(el); + alpha *= Number(cs.opacity); + if (cs.display === 'none' || cs.visibility === 'hidden' || !alpha) return; + ctx.globalAlpha = alpha; + const g = glassBox(el.getBoundingClientRect()); + + // Icons are a background colour shown through a mask + if (onGlass(g) && !cs.maskImage?.startsWith('url')) { + ctx.beginPath(); + ctx.roundRect(g.x, g.y, g.w, g.h, Math.min(parseFloat(cs.borderTopLeftRadius) * k || 0, g.w / 2, g.h / 2)); + ctx.fillStyle = cs.backgroundColor; + ctx.fill(); + const [top, right, bottom, left] = [cs.borderTopWidth, cs.borderRightWidth, cs.borderBottomWidth, cs.borderLeftWidth].map(w => parseFloat(w) * k || 0); + if (top && top === right && top === bottom && top === left) { + ctx.lineWidth = top; + ctx.strokeStyle = cs.borderTopColor; + ctx.stroke(); + } else { + ctx.fillStyle = cs.borderTopColor; + ctx.fillRect(g.x, g.y, g.w, top); + ctx.fillStyle = cs.borderBottomColor; + ctx.fillRect(g.x, g.y + g.h - bottom, g.w, bottom); + ctx.fillStyle = cs.borderLeftColor; + ctx.fillRect(g.x, g.y, left, g.h); + ctx.fillStyle = cs.borderRightColor; + ctx.fillRect(g.x + g.w - right, g.y, right, g.h); + } + + if ((el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) && el.value) { + ctx.save(); + ctx.clip(); + ctx.font = fontOf(cs); + ctx.fillStyle = cs.color; + const padLeft = parseFloat(cs.paddingLeft) * k || 0; + if (el instanceof HTMLInputElement) { + const center = cs.textAlign === 'center'; + ctx.textBaseline = 'middle'; + ctx.textAlign = center ? 'center' : 'left'; + ctx.fillText(el.value, center ? g.x + g.w / 2 : g.x + padLeft, g.y + g.h / 2); + } else { + // Wrap the prompt at word boundaries like the textarea, from its scroll position + ctx.textBaseline = 'top'; + const width = g.w - padLeft - (parseFloat(cs.paddingRight) * k || 0); + const lineHeight = (parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.2) * k; + let y = g.y + (parseFloat(cs.paddingTop) * k || 0) - el.scrollTop * k; + for (const paragraph of el.value.split('\n')) { + let line = ''; + for (const word of paragraph.split(' ')) { + if (line && ctx.measureText(`${line} ${word}`).width > width) { + ctx.fillText(line, g.x + padLeft, y); + y += lineHeight; + line = word; + } else { + line = line ? `${line} ${word}` : word; + } + } + ctx.fillText(line, g.x + padLeft, y); + y += lineHeight; + if (y > g.y + g.h) break; + } + } + ctx.restore(); + } + } + + for (const child of el.childNodes) { + if (child instanceof Element) { + paint(child, alpha); + ctx.globalAlpha = alpha; + continue; + } + const text = child.nodeType === Node.TEXT_NODE ? child.textContent!.trim() : ''; + if (!text) continue; + const range = document.createRange(); + range.selectNodeContents(child); + const t = glassBox(range.getBoundingClientRect()); + if (!onGlass(t)) continue; + ctx.save(); + // Truncated labels overflow their element: clip them to it + if (cs.overflowX !== 'visible') { + ctx.beginPath(); + ctx.rect(g.x, g.y, g.w, g.h); + ctx.clip(); + } + ctx.font = fontOf(cs); + ctx.fillStyle = cs.color; + ctx.textBaseline = 'middle'; + ctx.textAlign = 'left'; + ctx.fillText(text, t.x, t.y + t.h / 2); + ctx.restore(); + } + }; + + const nodes = [...document.querySelectorAll('.lg-node[data-node-id]')] + .filter(el => onGlass(glassBox(el.getBoundingClientRect()))) + .sort((a, b) => (Number(a.style.zIndex) || 0) - (Number(b.style.zIndex) || 0)); + ctx.save(); + ctx.filter = this.accessibilityFilter(); + for (const node of nodes) paint(node, 1); + ctx.restore(); + } + /** * Draw node titles natively with accessibility styling. */ @@ -384,27 +504,29 @@ export class OffscreenRenderer { const title = node.title || node.type || "Node"; - // Node position in CSS pixels + // Title bar in CSS pixels: LiteGraph draws it above node.pos, and none for NO_TITLE nodes such as Reroute + const titleHeight = node.pos[1] - node.boundingRect[1]; + if (!titleHeight) continue; const nodeCssX = (node.pos[0] + offset[0]) * scale; - const nodeCssY = (node.pos[1] + offset[1]) * scale; + const nodeCssY = (node.pos[1] - titleHeight + offset[1]) * scale; const nodeCssWidth = node.size[0] * scale; // Check visibility if (nodeCssX + nodeCssWidth < cap.left || nodeCssX > cap.left + cap.size) continue; - if (nodeCssY + (TITLE_HEIGHT * scale) < cap.top || nodeCssY > cap.top + cap.size) continue; + if (nodeCssY + (titleHeight * scale) < cap.top || nodeCssY > cap.top + cap.size) continue; // Draw const canvasX = (nodeCssX - cap.left) * cap.k; const canvasY = (nodeCssY - cap.top) * cap.k; const canvasWidth = nodeCssWidth * cap.k; - const canvasHeight = TITLE_HEIGHT * scale * cap.k; + const canvasHeight = titleHeight * scale * cap.k; if (canvasX + canvasWidth > 0 && canvasX < renderSize && canvasY + canvasHeight > 0 && canvasY < renderSize) { ctx.save(); // Enhanced Background ctx.beginPath(); - ctx.roundRect(canvasX, canvasY - (5 * scale), canvasWidth, canvasHeight, 4); + ctx.roundRect(canvasX, canvasY, canvasWidth, canvasHeight, 4); ctx.fillStyle = this.config.highContrastMode ? '#000000' : '#222222'; ctx.fill(); @@ -438,7 +560,7 @@ export class OffscreenRenderer { } const padding = 10 * scale * cap.k; - ctx.fillText(title, canvasX + padding, canvasY + (canvasHeight / 2) - (5 * scale)); + ctx.fillText(title, canvasX + padding, canvasY + canvasHeight / 2); ctx.restore(); } diff --git a/src/types/comfyui.d.ts b/src/types/comfyui.d.ts index 25ed33b..d698864 100644 --- a/src/types/comfyui.d.ts +++ b/src/types/comfyui.d.ts @@ -200,6 +200,8 @@ export interface ComfyCanvas { offset: [number, number]; }; node_over: ComfyNode | null; + /** True in pan mode: space held or the hand tool */ + read_only: boolean; selected_nodes: Record; convertOffsetToCanvasPos?(pos: [number, number]): [number, number]; centerOnNode(node: ComfyNode): void; diff --git a/tests/unit/InfoPanelLifecycle.test.ts b/tests/unit/InfoPanelLifecycle.test.ts index 20f203e..3ae0f5d 100644 --- a/tests/unit/InfoPanelLifecycle.test.ts +++ b/tests/unit/InfoPanelLifecycle.test.ts @@ -1059,7 +1059,8 @@ describe('Info Panel lifecycle', () => { }); it('the highlight follows the cached setting and clips to the canvas', () => { - const node = { id: 3, pos: [0, 0], size: [100, 50] }; + // LiteGraph's bounds include the title bar above pos + const node = { id: 3, getBounding: () => [0, -30, 100, 80] }; const canvas = document.createElement('canvas'); vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({ left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600 } as DOMRect); Object.assign((window as any).app.canvas, { canvas, ds: { scale: 1, offset: [0, 0] }, graph: { getNodeById: () => node } }); @@ -1077,6 +1078,37 @@ describe('Info Panel lifecycle', () => { (window as any).app.canvas.onDrawForeground(null, []); expect(highlight.style.display).toBe('none'); }); + + it('the highlight covers only the title of a collapsed node', () => { + const node = { id: 3, pos: [100, 100], size: [300, 200], getBounding: () => [100, 70, 120, 30] }; + const canvas = document.createElement('canvas'); + vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({ left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600 } as DOMRect); + Object.assign((window as any).app.canvas, { canvas, ds: { scale: 2, offset: [0, 0] }, graph: { getNodeById: () => node } }); + mountInfoPanel(asInstance(mg)); + const highlight = document.getElementById('magnify-glass-node-highlight')!; + + window.infoPanelManager!.canvasHighlighter.setHighlightedNode(3); + (window as any).app.canvas.onDrawForeground(null, []); + + expect([highlight.style.left, highlight.style.top, highlight.style.width, highlight.style.height]).toEqual(['194px', '134px', '252px', '72px']); + }); + + it('the highlight takes a collapsed Vue node from node.size, where Vue writes its DOM size', () => { + (window as any).LiteGraph = { vueNodesMode: true, NODE_TITLE_HEIGHT: 30 }; + // LiteGraph's own pill is narrower than the collapsed Vue node + const node = { id: 3, pos: [100, 100], size: [225, 8], getBounding: () => [100, 70, 120, 30] }; + const canvas = document.createElement('canvas'); + vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({ left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600 } as DOMRect); + Object.assign((window as any).app.canvas, { canvas, ds: { scale: 1, offset: [0, 0] }, graph: { getNodeById: () => node } }); + mountInfoPanel(asInstance(mg)); + const highlight = document.getElementById('magnify-glass-node-highlight')!; + + window.infoPanelManager!.canvasHighlighter.setHighlightedNode(3); + (window as any).app.canvas.onDrawForeground(null, []); + + expect([highlight.style.left, highlight.style.top, highlight.style.width, highlight.style.height]).toEqual(['94px', '64px', '237px', '50px']); + delete (window as any).LiteGraph; + }); }); describe('registerPanelSettings', () => { diff --git a/tests/unit/InformationGatherer.test.ts b/tests/unit/InformationGatherer.test.ts index c0f8824..a74643b 100644 --- a/tests/unit/InformationGatherer.test.ts +++ b/tests/unit/InformationGatherer.test.ts @@ -105,6 +105,50 @@ describe('InformationGatherer', () => { expect(hit).toBe(node); mockApp.canvas.graph = canvasGraph; }); + + it('takes the topmost Vue node element under the pointer in Vue nodes mode', () => { + // LiteGraph's own hit test and node_over disagree with what Vue stacks on top + const front = { id: 7 }; + const nodes: Record = { 3: { id: 3 }, 7: front }; + const canvasGraph = mockApp.canvas.graph; + const getNodeOnPos = vi.fn(() => nodes[3]); + mockApp.canvas.graph = { _nodes: Object.values(nodes), getNodeOnPos, getNodeById: (id: number) => nodes[id] ?? null }; + mockApp.canvas.node_over = nodes[3]; + (window as any).LiteGraph = { vueNodesMode: true }; + // The panel's rows carry data-node-id too, and sit above the nodes + const panelRow = document.createElement('div'); + panelRow.dataset.nodeId = '3'; + // A slot dot sticks out of its node, so the node element itself is not under the pointer + const slot = document.createElement('div'); + const frontEl = document.createElement('div'); + frontEl.className = 'lg-node'; + frontEl.dataset.nodeId = '7'; + frontEl.appendChild(slot); + const backEl = document.createElement('div'); + backEl.className = 'lg-node'; + backEl.dataset.nodeId = '3'; + const elementsFromPoint = vi.fn(() => [panelRow, slot, backEl]); + (document as any).elementsFromPoint = elementsFromPoint; + + expect(gatherer.getNodeUnderCursor()).toBe(front); + expect(elementsFromPoint).toHaveBeenCalledWith(200, 200); + expect(getNodeOnPos).not.toHaveBeenCalled(); + + elementsFromPoint.mockReturnValue([]); + expect(gatherer.getNodeUnderCursor()).toBeNull(); + + // In pan mode Vue nodes are pointer-events: none, so the canvas hit test finds the node + mockApp.canvas.read_only = true; + mockApp.canvas.node_over = null; + expect(gatherer.getNodeUnderCursor()).toBe(nodes[3]); + expect(getNodeOnPos).toHaveBeenCalledWith(200, 200); + + delete mockApp.canvas.read_only; + delete (window as any).LiteGraph; + delete (document as any).elementsFromPoint; + mockApp.canvas.node_over = null; + mockApp.canvas.graph = canvasGraph; + }); }); describe('getNodeModeText', () => { diff --git a/tests/unit/OffscreenRenderer.test.ts b/tests/unit/OffscreenRenderer.test.ts index a187e6c..81d34da 100644 --- a/tests/unit/OffscreenRenderer.test.ts +++ b/tests/unit/OffscreenRenderer.test.ts @@ -96,6 +96,7 @@ describe('OffscreenRenderer Optimization', () => { closePath: vi.fn(), strokeText: vi.fn(), rect: vi.fn(), + fillRect: vi.fn(), }); return canvas; } @@ -164,6 +165,43 @@ describe('OffscreenRenderer Optimization', () => { expect(fillTextSpy).not.toHaveBeenCalledWith(expect.stringContaining('Invisible Text'), expect.any(Number), expect.any(Number)); }); + it('draws the emphasized title over the title bar above node.pos, and none for NO_TITLE nodes', () => { + // A legacy Reroute has no title bar: LiteGraph measures its bounds from pos + const reroute = { pos: [60, 120], size: [75, 26], boundingRect: [60, 120, 75, 26], title: 'Reroute', widgets: [] }; + appMock.canvas.visible_nodes = [lgNode([50, 100], [100, 100], { title: 'KSampler', widgets: [] }), reroute]; + const canvas = document.createElement('canvas'); + canvas.width = 1000; + canvas.height = 800; + + renderer.renderHighResRegion(canvas); + + // Capture region 50-150, k 2: title bar (50, 70, 100x30) -> glass (0, 40, 200x60), not the first slot row below it + const ctx = (renderer as any).offscreenCtx; + expect(ctx.roundRect.mock.calls).toEqual([[0, 40, 200, 60, 4]]); + expect(ctx.fillText).toHaveBeenCalledWith('KSampler', 20, 70); + expect(ctx.fillText).not.toHaveBeenCalledWith('Reroute', expect.any(Number), expect.any(Number)); + }); + + it('fills the glass with the page background that shows through the canvas', () => { + // Past the canvas edge, and through the grid at zoom 1.5 and above, the screen shows the page background + appMock.canvas.clear_background_color = '#222'; + document.body.style.backgroundColor = 'rgb(32, 32, 32)'; + const ctx = (renderer as any).offscreenCtx; + const fillStyles: string[] = []; + ctx.fillRect.mockImplementation(() => fillStyles.push(ctx.fillStyle)); + const canvas = document.createElement('canvas'); + canvas.width = 1000; + canvas.height = 800; + stateMock.y = 790; + + renderer.renderHighResRegion(canvas); + + expect(ctx.fillRect).toHaveBeenCalledWith(0, 0, 200, 200); + expect(fillStyles).toEqual(['rgb(32, 32, 32)']); + expect(ctx.fillRect.mock.invocationCallOrder[0]).toBeLessThan(ctx.drawImage.mock.invocationCallOrder[0]); + document.body.style.backgroundColor = ''; + }); + describe('media in the glass', () => { const PREVIEW_WIDGET = '$$canvas-image-preview'; @@ -300,7 +338,7 @@ describe('OffscreenRenderer Optimization', () => { renderer.renderHighResRegion(targetCanvas(0, 0, 1)); - expect(style.mock.calls.map(c => c[0])).toEqual([near]); + expect(style.mock.calls.map(c => c[0]).filter(el => el !== document.body)).toEqual([near]); expect(drawsOf(near)).toHaveLength(1); expect(drawsOf(far)).toHaveLength(0); }); @@ -386,6 +424,53 @@ describe('OffscreenRenderer Optimization', () => { expect(drawsOf(img)).toEqual([[img, 60, 60, 80, 60]]); }); + it('paints the Vue nodes under the glass from the DOM, back to front', () => { + (window as any).LiteGraph = { vueNodesMode: true }; + appMock.canvas.ds = { scale: 0.5, offset: [0, 0] }; + const box = (el: HTMLElement, left: number, top: number, width: number, height: number) => { + // happy-dom computes no opacity, which browsers always do + el.style.opacity = '1'; + vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({ x: left, y: top, left, top, width, height, right: left + width, bottom: top + height, toJSON: () => {} } as DOMRect); + }; + const vueNode = (id: string, zIndex: string, color: string) => { + const el = document.createElement('div'); + el.className = 'lg-node'; + el.dataset.nodeId = id; + el.style.zIndex = zIndex; + el.style.backgroundColor = color; + document.body.appendChild(el); + return el; + }; + const front = vueNode('2', '9', 'rgb(0, 0, 255)'); + const back = vueNode('1', '1', 'rgb(255, 0, 0)'); + const far = vueNode('3', '1', 'rgb(0, 255, 0)'); + box(front, 290, 190, 40, 40); + box(back, 260, 160, 100, 100); + box(far, 900, 700, 50, 50); + const title = document.createElement('span'); + title.textContent = ' KSampler '; + back.appendChild(title); + box(title, 265, 165, 50, 10); + const steps = document.createElement('input'); + steps.value = '20'; + back.appendChild(steps); + box(steps, 265, 180, 60, 10); + vi.spyOn(Range.prototype, 'getBoundingClientRect').mockReturnValue({ x: 265, y: 165, left: 265, top: 165, width: 40, height: 10, right: 305, bottom: 175, toJSON: () => {} } as DOMRect); + const ctx = (renderer as any).offscreenCtx; + const fills: string[] = []; + ctx.fill.mockImplementation(() => fills.push(ctx.fillStyle)); + stateMock.x = 300; + stateMock.y = 200; + + renderer.renderHighResRegion(targetCanvas(0, 0, 1)); + + // Capture region 250-350 x 150-250, k 2 + expect(fills.filter(f => f.startsWith('rgb('))).toEqual(['rgb(255, 0, 0)', 'rgb(0, 0, 255)']); + expect(ctx.roundRect).toHaveBeenCalledWith(20, 20, 200, 200, 0); + expect(ctx.fillText).toHaveBeenCalledWith('KSampler', 30, 40); + expect(ctx.fillText).toHaveBeenCalledWith('20', 30, 70); + }); + it('caps Virtual Zoom near 60 fps and draws the video on each rendered frame', () => { appMock.canvas.ds = { scale: 0.5, offset: [0, 0] }; appMock.canvas.setZoom = vi.fn((s: number) => { diff --git a/web/info-panel/CanvasHighlighter.js b/web/info-panel/CanvasHighlighter.js index d24273a..2e2d32d 100644 --- a/web/info-panel/CanvasHighlighter.js +++ b/web/info-panel/CanvasHighlighter.js @@ -79,21 +79,14 @@ class CanvasHighlighter { const ds = app.canvas.ds; const scale = ds.scale; const offset = ds.offset; + const padding = 6; const LiteGraph = window.LiteGraph; const titleHeight = (LiteGraph == null ? void 0 : LiteGraph.NODE_TITLE_HEIGHT) ?? 30; - const padding = 6; - const graphX = node.pos[0]; - const graphY = node.pos[1] - titleHeight; - const graphW = node.size[0]; - const graphH = titleHeight + node.size[1]; - const canvasX = (graphX + offset[0]) * scale; - const canvasY = (graphY + offset[1]) * scale; - const canvasW = graphW * scale; - const canvasH = graphH * scale; - const pageX = canvasRect.left + canvasX - padding; - const pageY = canvasRect.top + canvasY - padding; - const finalW = canvasW + padding * 2; - const finalH = canvasH + padding * 2; + const [graphX, graphY, graphW, graphH] = (LiteGraph == null ? void 0 : LiteGraph.vueNodesMode) ? [node.pos[0], node.pos[1] - titleHeight, node.size[0], node.size[1] + titleHeight] : node.getBounding(); + const pageX = canvasRect.left + (graphX + offset[0]) * scale - padding; + const pageY = canvasRect.top + (graphY + offset[1]) * scale - padding; + const finalW = graphW * scale + padding * 2; + const finalH = graphH * scale + padding * 2; const clip = `inset(${canvasRect.top - pageY}px ${pageX + finalW - canvasRect.right}px ${pageY + finalH - canvasRect.bottom}px ${canvasRect.left - pageX}px)`; const styleState = `${pageX.toFixed(1)},${pageY.toFixed(1)},${finalW.toFixed(1)},${finalH.toFixed(1)},${clip}`; if (this.lastStyleState !== styleState || this.highlightEl.style.display === "none") { diff --git a/web/info-panel/InformationGatherer.js b/web/info-panel/InformationGatherer.js index 965eb72..ff45883 100644 --- a/web/info-panel/InformationGatherer.js +++ b/web/info-panel/InformationGatherer.js @@ -25,12 +25,17 @@ class InformationGatherer { return info; } getNodeUnderCursor() { - var _a; + var _a, _b; const magnifyGlass = window.comfyUIMagnifyGlass; const graph = (_a = app.canvas) == null ? void 0 : _a.graph; if (!graph || !magnifyGlass) { return null; } + if (((_b = window.LiteGraph) == null ? void 0 : _b.vueNodesMode) && !app.canvas.read_only) { + const { x, y } = magnifyGlass.lastKnownMousePosition; + const nodeEl = document.elementsFromPoint(x, y).map((el) => el.closest(".lg-node[data-node-id]")).find(Boolean); + return nodeEl ? graph.getNodeById(Number(nodeEl.dataset.nodeId)) ?? null : null; + } if (app.canvas.node_over) { return app.canvas.node_over; } diff --git a/web/magnify-glass/OffscreenRenderer.js b/web/magnify-glass/OffscreenRenderer.js index a2439c6..9045c01 100644 --- a/web/magnify-glass/OffscreenRenderer.js +++ b/web/magnify-glass/OffscreenRenderer.js @@ -57,7 +57,7 @@ class OffscreenRenderer { if (virtualZoom) { return this.renderVirtualZoom(lgCanvas, targetCanvas, rect, dpr, pivotX, pivotY, cap, media); } - return this.renderDirectCapture(targetCanvas, dpr, cap, media, vueNodes); + return this.renderDirectCapture(targetCanvas, rect, dpr, cap, media, vueNodes); } /** * Read phase: measure loaded, same-origin DOM images and videos (DOM widgets, or Vue node bodies) @@ -103,6 +103,8 @@ class OffscreenRenderer { const renderSize = this.config.glassSize; this.offscreenCtx.save(); this.offscreenCtx.filter = this.accessibilityFilter(); + this.offscreenCtx.fillStyle = getComputedStyle(document.body).backgroundColor; + this.offscreenCtx.fillRect(0, 0, renderSize, renderSize); this.offscreenCtx.drawImage( targetCanvas, cap.left * dpr, @@ -120,7 +122,7 @@ class OffscreenRenderer { * Mode 1: Direct Capture (Nuclear Fix) * Copies pixels directly from screen. Zero drift. */ - renderDirectCapture(targetCanvas, dpr, cap, media, vueNodes) { + renderDirectCapture(targetCanvas, rect, dpr, cap, media, vueNodes) { try { this.isCapturing = true; const renderSize = this.config.glassSize; @@ -129,6 +131,7 @@ class OffscreenRenderer { const ds = app.canvas.ds; const scale = ds.scale; const offset = [ds.offset[0], ds.offset[1]]; + if (vueNodes) this.drawVueNodes(rect, cap, scale); const visibleNodes = vueNodes ? [] : this.getVisibleNodes(cap, scale, offset); this.drawMedia(targetCanvas, dpr, visibleNodes, media, cap, scale, offset); this.drawWidgetTextNatively(visibleNodes, cap, scale, offset); @@ -268,6 +271,111 @@ class OffscreenRenderer { } ctx.restore(); } + /** + * Paint the Vue nodes under the glass from the DOM, back to front: element backgrounds and borders, + * text, and input values. Icons, pseudo-elements and shadows are left out; drawMedia() adds images and videos. + */ + drawVueNodes(rect, cap, scale) { + const ctx = this.offscreenCtx; + const k = scale * cap.k; + const glassBox = (r) => toGlass({ x: r.left - rect.left, y: r.top - rect.top, w: r.width, h: r.height }, cap); + const onGlass = (g) => g.w > 0 && g.h > 0 && g.x < this.config.glassSize && g.y < this.config.glassSize && g.x + g.w > 0 && g.y + g.h > 0; + const fontOf = (cs) => `${cs.fontStyle} ${cs.fontWeight} ${parseFloat(cs.fontSize) * k}px ${cs.fontFamily}`; + const paint = (el, alpha) => { + var _a; + if (el instanceof SVGElement || el instanceof HTMLImageElement || el instanceof HTMLVideoElement || el instanceof HTMLCanvasElement) return; + const cs = getComputedStyle(el); + alpha *= Number(cs.opacity); + if (cs.display === "none" || cs.visibility === "hidden" || !alpha) return; + ctx.globalAlpha = alpha; + const g = glassBox(el.getBoundingClientRect()); + if (onGlass(g) && !((_a = cs.maskImage) == null ? void 0 : _a.startsWith("url"))) { + ctx.beginPath(); + ctx.roundRect(g.x, g.y, g.w, g.h, Math.min(parseFloat(cs.borderTopLeftRadius) * k || 0, g.w / 2, g.h / 2)); + ctx.fillStyle = cs.backgroundColor; + ctx.fill(); + const [top, right, bottom, left] = [cs.borderTopWidth, cs.borderRightWidth, cs.borderBottomWidth, cs.borderLeftWidth].map((w) => parseFloat(w) * k || 0); + if (top && top === right && top === bottom && top === left) { + ctx.lineWidth = top; + ctx.strokeStyle = cs.borderTopColor; + ctx.stroke(); + } else { + ctx.fillStyle = cs.borderTopColor; + ctx.fillRect(g.x, g.y, g.w, top); + ctx.fillStyle = cs.borderBottomColor; + ctx.fillRect(g.x, g.y + g.h - bottom, g.w, bottom); + ctx.fillStyle = cs.borderLeftColor; + ctx.fillRect(g.x, g.y, left, g.h); + ctx.fillStyle = cs.borderRightColor; + ctx.fillRect(g.x + g.w - right, g.y, right, g.h); + } + if ((el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) && el.value) { + ctx.save(); + ctx.clip(); + ctx.font = fontOf(cs); + ctx.fillStyle = cs.color; + const padLeft = parseFloat(cs.paddingLeft) * k || 0; + if (el instanceof HTMLInputElement) { + const center = cs.textAlign === "center"; + ctx.textBaseline = "middle"; + ctx.textAlign = center ? "center" : "left"; + ctx.fillText(el.value, center ? g.x + g.w / 2 : g.x + padLeft, g.y + g.h / 2); + } else { + ctx.textBaseline = "top"; + const width = g.w - padLeft - (parseFloat(cs.paddingRight) * k || 0); + const lineHeight = (parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.2) * k; + let y = g.y + (parseFloat(cs.paddingTop) * k || 0) - el.scrollTop * k; + for (const paragraph of el.value.split("\n")) { + let line = ""; + for (const word of paragraph.split(" ")) { + if (line && ctx.measureText(`${line} ${word}`).width > width) { + ctx.fillText(line, g.x + padLeft, y); + y += lineHeight; + line = word; + } else { + line = line ? `${line} ${word}` : word; + } + } + ctx.fillText(line, g.x + padLeft, y); + y += lineHeight; + if (y > g.y + g.h) break; + } + } + ctx.restore(); + } + } + for (const child of el.childNodes) { + if (child instanceof Element) { + paint(child, alpha); + ctx.globalAlpha = alpha; + continue; + } + const text = child.nodeType === Node.TEXT_NODE ? child.textContent.trim() : ""; + if (!text) continue; + const range = document.createRange(); + range.selectNodeContents(child); + const t = glassBox(range.getBoundingClientRect()); + if (!onGlass(t)) continue; + ctx.save(); + if (cs.overflowX !== "visible") { + ctx.beginPath(); + ctx.rect(g.x, g.y, g.w, g.h); + ctx.clip(); + } + ctx.font = fontOf(cs); + ctx.fillStyle = cs.color; + ctx.textBaseline = "middle"; + ctx.textAlign = "left"; + ctx.fillText(text, t.x, t.y + t.h / 2); + ctx.restore(); + } + }; + const nodes = [...document.querySelectorAll(".lg-node[data-node-id]")].filter((el) => onGlass(glassBox(el.getBoundingClientRect()))).sort((a, b) => (Number(a.style.zIndex) || 0) - (Number(b.style.zIndex) || 0)); + ctx.save(); + ctx.filter = this.accessibilityFilter(); + for (const node of nodes) paint(node, 1); + ctx.restore(); + } /** * Draw node titles natively with accessibility styling. */ @@ -279,19 +387,21 @@ class OffscreenRenderer { for (const node of visibleNodes) { if ((_a = node.flags) == null ? void 0 : _a.collapsed) continue; const title = node.title || node.type || "Node"; + const titleHeight = node.pos[1] - node.boundingRect[1]; + if (!titleHeight) continue; const nodeCssX = (node.pos[0] + offset[0]) * scale; - const nodeCssY = (node.pos[1] + offset[1]) * scale; + const nodeCssY = (node.pos[1] - titleHeight + offset[1]) * scale; const nodeCssWidth = node.size[0] * scale; if (nodeCssX + nodeCssWidth < cap.left || nodeCssX > cap.left + cap.size) continue; - if (nodeCssY + TITLE_HEIGHT * scale < cap.top || nodeCssY > cap.top + cap.size) continue; + if (nodeCssY + titleHeight * scale < cap.top || nodeCssY > cap.top + cap.size) continue; const canvasX = (nodeCssX - cap.left) * cap.k; const canvasY = (nodeCssY - cap.top) * cap.k; const canvasWidth = nodeCssWidth * cap.k; - const canvasHeight = TITLE_HEIGHT * scale * cap.k; + const canvasHeight = titleHeight * scale * cap.k; if (canvasX + canvasWidth > 0 && canvasX < renderSize && canvasY + canvasHeight > 0 && canvasY < renderSize) { ctx.save(); ctx.beginPath(); - ctx.roundRect(canvasX, canvasY - 5 * scale, canvasWidth, canvasHeight, 4); + ctx.roundRect(canvasX, canvasY, canvasWidth, canvasHeight, 4); ctx.fillStyle = this.config.highContrastMode ? "#000000" : "#222222"; ctx.fill(); ctx.strokeStyle = this.config.highContrastMode ? "#ffffff" : "#444444"; @@ -312,7 +422,7 @@ class OffscreenRenderer { ctx.shadowColor = this.config.textGlowColor; } const padding = 10 * scale * cap.k; - ctx.fillText(title, canvasX + padding, canvasY + canvasHeight / 2 - 5 * scale); + ctx.fillText(title, canvasX + padding, canvasY + canvasHeight / 2); ctx.restore(); } }