diff --git a/CHANGELOG.md b/CHANGELOG.md index acc047c..2db5ad7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Sidebar redesign**: Built on ComfyUI's palette tokens, so it follows light and dark themes, with native section buttons, the Inspector panel switch at the top, and Glass Toggle Key under Glass Hotkeys. ### Removed -- The per-frame HTML overlay copies of videos, the Virtual Zoom frame throttle, and the unused sidebar Inspector module. +- The per-frame HTML overlay copies of videos and the unused sidebar Inspector module. ### Known Issues - With the Inspector panel off, "Move Glass Position" (drag-glass mode) is unavailable, as it lives on the panel's floating controls. Follow Cursor (`H`) and the arrow keys still move the glass. diff --git a/css/sidebar.css b/css/sidebar.css index b700cf8..e4034e8 100644 --- a/css/sidebar.css +++ b/css/sidebar.css @@ -82,7 +82,7 @@ /* Body: the panel's one scroller */ .magnify-sidebar-content { - flex: 1 1 0; + flex: 1 1 auto; min-height: 0; overflow-x: hidden; overflow-y: auto; diff --git a/src/magnify-glass/OffscreenRenderer.ts b/src/magnify-glass/OffscreenRenderer.ts index 8b0ab6e..b6cd489 100644 --- a/src/magnify-glass/OffscreenRenderer.ts +++ b/src/magnify-glass/OffscreenRenderer.ts @@ -17,6 +17,8 @@ import type { Box, CaptureRegion } from './mediaLayout'; declare const app: any; const TITLE_HEIGHT = 30; +// Each Virtual Zoom frame is two full graph draws: cap it near 60 fps, with slack for rAF jitter at 60 Hz +const VIRTUAL_ZOOM_MIN_INTERVAL_MS = 14; interface DomMedia { el: HTMLImageElement | HTMLVideoElement; @@ -24,6 +26,12 @@ interface DomMedia { objectFit: string; } +/** LiteGraph's node bounds in graph space: title included, collapsed width when collapsed. */ +function boundingBox(node: any): Box { + const [x, y, w, h] = node.boundingRect; + return { x, y, w, h }; +} + export class OffscreenRenderer { private config: ConfigManager; private state: MagnifierState; @@ -32,6 +40,7 @@ export class OffscreenRenderer { private lastRenderWidth: number = 0; private lastRenderHeight: number = 0; private isCapturing: boolean = false; + private lastVirtualZoomTime: number = 0; constructor(config: ConfigManager, state: MagnifierState) { this.config = config; @@ -73,48 +82,51 @@ export class OffscreenRenderer { // forceDirectCapture (hotkey) trades detail for exact cursor alignment. const vueNodes = !!(window as any).LiteGraph?.vueNodesMode; const currentScale = lgCanvas.ds.scale; - const media = this.collectDomMedia(rect, vueNodes); + const virtualZoom = currentScale < 1.0 && !this.config.forceDirectCapture && !vueNodes; - if (currentScale < 1.0 && !this.config.forceDirectCapture && !vueNodes) { - const domScale = 1 / currentScale; - const scaled = media.map(m => ({ ...m, box: scaleAbout(m.box, pivotX, pivotY, domScale) })); - return this.renderVirtualZoom(lgCanvas, targetCanvas, rect, dpr, pivotX, pivotY, cap, scaled); + if (virtualZoom) { + const now = performance.now(); + if (now - this.lastVirtualZoomTime < VIRTUAL_ZOOM_MIN_INTERVAL_MS) return this.offscreenCanvas; + this.lastVirtualZoomTime = now; + } + + const media = this.collectDomMedia(rect, vueNodes, cap, pivotX, pivotY, virtualZoom ? 1 / currentScale : 1); + if (virtualZoom) { + return this.renderVirtualZoom(lgCanvas, targetCanvas, rect, dpr, pivotX, pivotY, cap, media); } return this.renderDirectCapture(targetCanvas, dpr, cap, media, vueNodes); } /** * Read phase: measure loaded, same-origin DOM images and videos (DOM widgets, or Vue node bodies) - * before any canvas draw. Boxes are CSS pixels relative to the canvas at the real zoom. + * under the glass before any canvas draw. Boxes are CSS pixels relative to the canvas, scaled by + * domScale about the pivot into the capture frame. */ - private collectDomMedia(rect: DOMRect, vueNodes: boolean): DomMedia[] { + private collectDomMedia(rect: DOMRect, vueNodes: boolean, cap: CaptureRegion, pivotX: number, pivotY: number, domScale: number): DomMedia[] { const selector = vueNodes ? '[data-node-id] img, [data-node-id] video' : '.dom-widget img, .dom-widget video'; const media: DomMedia[] = []; for (const el of document.querySelectorAll(selector)) { const loaded = el instanceof HTMLVideoElement ? el.readyState >= 2 : el.complete && el.naturalWidth > 0; - if (!loaded || !isSafeMediaSource(el)) continue; + if (!loaded) continue; const r = el.getBoundingClientRect(); if (r.width === 0 || r.height === 0) continue; - media.push({ - el, - box: { x: r.left - rect.left, y: r.top - rect.top, w: r.width, h: r.height }, - objectFit: getComputedStyle(el).objectFit - }); + const box = scaleAbout({ x: r.left - rect.left, y: r.top - rect.top, w: r.width, h: r.height }, pivotX, pivotY, domScale); + if (box.x + box.w <= cap.left || box.x >= cap.left + cap.size) continue; + if (box.y + box.h <= cap.top || box.y >= cap.top + cap.size) continue; + if (!isSafeMediaSource(el)) continue; + media.push({ el, box, objectFit: getComputedStyle(el).objectFit }); } return media; } /** - * Nodes of the graph on screen (including subgraphs) that overlap the capture region, - * in canvas render order (back to front). + * Nodes of the graph on screen (including subgraphs and collapsed nodes) whose title or body + * overlaps the capture region, in canvas render order (back to front). */ private getVisibleNodes(cap: CaptureRegion, scale: number, offset: [number, number]): any[] { const visibleNodes = []; for (const node of app.canvas.visible_nodes) { - if (!node.pos || !node.size) continue; - if (node.flags?.collapsed) continue; - - const box = graphBoxToCss({ x: 0, y: 0, w: node.size[0], h: node.size[1] }, node.pos, scale, offset); + const box = graphBoxToCss(boundingBox(node), [0, 0], scale, offset); if (box.x + box.w < cap.left || box.x > cap.left + cap.size) continue; if (box.y + box.h < cap.top || box.y > cap.top + cap.size) continue; @@ -315,7 +327,7 @@ export class OffscreenRenderer { const sizeTextMargin = getSettingValue('Comfy.Node.AllowImageSizeDraw', true) ? 15 : 0; for (const [i, node] of visibleNodes.entries()) { - const widget = node.imgs?.length && node.widgets?.find((w: any) => w.name === '$$canvas-image-preview'); + const widget = !node.flags?.collapsed && node.imgs?.length && node.widgets?.find((w: any) => w.name === '$$canvas-image-preview'); if (!widget) continue; for (const { img, box } of previewImageBoxes(node.imgs, widget, node, sizeTextMargin)) { const g = toGlass(graphBoxToCss(box, node.pos, scale, offset), cap); @@ -325,7 +337,7 @@ export class OffscreenRenderer { // Nodes later in render order cover this preview on the canvas: put them back from the capture const nodeBox = toGlass(graphBoxToCss({ x: 0, y: 0, w: node.size[0], h: node.size[1] }, node.pos, scale, offset), cap); for (const front of visibleNodes.slice(i + 1)) { - const f = toGlass(graphBoxToCss({ x: 0, y: -TITLE_HEIGHT, w: front.size[0], h: front.size[1] + TITLE_HEIGHT }, front.pos, scale, offset), cap); + const f = toGlass(graphBoxToCss(boundingBox(front), [0, 0], scale, offset), cap); if (!overlaps(f, nodeBox)) continue; ctx.save(); ctx.beginPath(); @@ -368,7 +380,6 @@ export class OffscreenRenderer { const renderSize = this.config.glassSize; for (const node of visibleNodes) { - if (!node.pos || !node.size) continue; if (node.flags?.collapsed) continue; const title = node.title || node.type || "Node"; @@ -385,8 +396,7 @@ export class OffscreenRenderer { // Draw const canvasX = (nodeCssX - cap.left) * cap.k; const canvasY = (nodeCssY - cap.top) * cap.k; - // Limit width for title background - const canvasWidth = Math.min(nodeCssWidth, nodeCssWidth) * cap.k; + const canvasWidth = nodeCssWidth * cap.k; const canvasHeight = TITLE_HEIGHT * scale * cap.k; if (canvasX + canvasWidth > 0 && canvasX < renderSize && canvasY + canvasHeight > 0 && canvasY < renderSize) { @@ -461,7 +471,7 @@ export class OffscreenRenderer { const PADDING = 15; for (const node of visibleNodes) { - if (!node.widgets || !node.pos || !node.size) continue; + if (!node.widgets) continue; if (node.flags?.collapsed) continue; // Calculate node position in CSS pixels diff --git a/src/sidebar/sidebar.ts b/src/sidebar/sidebar.ts index 4657673..04fb953 100644 --- a/src/sidebar/sidebar.ts +++ b/src/sidebar/sidebar.ts @@ -49,6 +49,8 @@ function renderSidebar(container: HTMLElement): void { // Clear existing content to prevent duplicates container.innerHTML = ''; + // The frontend's custom-tab wrapper has no height of its own; fill the panel so the content scrolls under the header + container.style.height = '100%'; // Create main container (hidden initially to prevent flash of unstyled content) const sidebar = document.createElement('div'); diff --git a/tests/unit/OffscreenRenderer.test.ts b/tests/unit/OffscreenRenderer.test.ts index df8c620..a187e6c 100644 --- a/tests/unit/OffscreenRenderer.test.ts +++ b/tests/unit/OffscreenRenderer.test.ts @@ -34,6 +34,10 @@ import { OffscreenRenderer } from '../../src/magnify-glass/OffscreenRenderer'; import { ConfigManager } from '../../src/magnify-glass/ConfigManager'; import { MagnifierState } from '../../src/magnify-glass/MagnifierState'; +// LiteGraph keeps boundingRect up to date for visible nodes: the body plus the 30px title above it +const lgNode = (pos: number[], size: number[], extra: Record = {}) => + ({ pos, size, boundingRect: [pos[0], pos[1] - 30, size[0], size[1] + 30], ...extra }); + describe('OffscreenRenderer Optimization', () => { let renderer: OffscreenRenderer; let configMock: ConfigManager; @@ -111,21 +115,17 @@ describe('OffscreenRenderer Optimization', () => { // Cursor at 100, 100. Glass size 200, zoom 2. Source size 100. // Capture region: 50 to 150. // Node 1 overlaps. - const visibleNode = { - pos: [50, 50], // Graph coords - size: [100, 100], + const visibleNode = lgNode([50, 50], [100, 100], { widgets: [ { type: 'text', name: 'visible_widget', value: 'Visible Text', computedHeight: 50, last_y: 40 } ] - }; + }); // Node 2: Not Visible (far away) - const invisibleNode = { - pos: [1000, 1000], - size: [100, 100], + const invisibleNode = lgNode([1000, 1000], [100, 100], { widgets: [ { type: 'text', name: 'invisible_widget', value: 'Invisible Text', computedHeight: 50, last_y: 40 } ] - }; + }); appMock.canvas.visible_nodes = [visibleNode, invisibleNode]; @@ -205,13 +205,24 @@ describe('OffscreenRenderer Optimization', () => { const drawsOf = (el: unknown) => ((renderer as any).offscreenCtx.drawImage as ReturnType).mock.calls.filter(c => c[0] === el); - const previewNode = (img: HTMLImageElement) => ({ - pos: [150, 100], - size: [200, 300], + const previewNode = (img: HTMLImageElement, extra: Record = {}) => lgNode([150, 100], [200, 300], { imgs: [img], - widgets: [{ name: PREVIEW_WIDGET, type: 'custom', y: 40, computedHeight: 215 }] + widgets: [{ name: PREVIEW_WIDGET, type: 'custom', y: 40, computedHeight: 215 }], + ...extra }); + const domImage = (left: number, top: number) => { + const wrapper = document.createElement('div'); + wrapper.className = 'dom-widget'; + const img = image('/view?filename=b.png&type=output', 40, 30); + vi.spyOn(img, 'getBoundingClientRect').mockReturnValue({ + x: left, y: top, left, top, width: 40, height: 30, right: left + 40, bottom: top + 30, toJSON: () => {} + } as DOMRect); + wrapper.appendChild(img); + document.body.appendChild(wrapper); + return img; + }; + afterEach(() => { document.body.innerHTML = ''; delete (window as any).LiteGraph; @@ -233,8 +244,8 @@ describe('OffscreenRenderer Optimization', () => { it('puts a node in front of a preview back over it from the capture', () => { const img = image('/view?filename=a.png&type=output', 100, 50); - const front = { pos: [200, 250], size: [100, 100], widgets: [] }; - const behind = { pos: [190, 190], size: [40, 40], widgets: [] }; + const front = lgNode([200, 250], [100, 100], { widgets: [] }); + const behind = lgNode([190, 190], [40, 40], { widgets: [] }); appMock.canvas.visible_nodes = [behind, previewNode(img), front]; stateMock.x = 250; stateMock.y = 250; @@ -249,6 +260,51 @@ describe('OffscreenRenderer Optimization', () => { expect(ctx.rect.mock.calls).toEqual([[0, 40, 200, 260]]); }); + it('puts a collapsed node in front back over a preview, and draws no preview for a collapsed node', () => { + const img = image('/view?filename=a.png&type=output', 100, 50); + const hidden = image('/view?filename=c.png&type=output', 100, 50); + // Collapsed: LiteGraph draws only the title pill, so boundingRect is the collapsed width by the title height + const front = previewNode(hidden, { pos: [220, 260], flags: { collapsed: true }, boundingRect: [220, 230, 60, 30] }); + appMock.canvas.visible_nodes = [previewNode(img), front]; + stateMock.x = 250; + stateMock.y = 250; + const canvas = targetCanvas(0, 0, 1); + + renderer.renderHighResRegion(canvas); + + const ctx = (renderer as any).offscreenCtx; + // Pill (220, 230, 60x30) -> glass (40, 60, 120x60) + expect(ctx.drawImage.mock.calls.map((c: unknown[]) => c[0])).toEqual([canvas, img, canvas]); + expect(ctx.rect.mock.calls).toEqual([[40, 60, 120, 60]]); + }); + + it('puts a node back whose title alone reaches into the glass', () => { + const img = image('/view?filename=a.png&type=output', 100, 50); + // Capture region is y 200-300; this body starts at 310, its title at 280 + const front = lgNode([200, 310], [100, 100], { widgets: [] }); + appMock.canvas.visible_nodes = [previewNode(img), front]; + stateMock.x = 250; + stateMock.y = 250; + + renderer.renderHighResRegion(targetCanvas(0, 0, 1)); + + expect((renderer as any).offscreenCtx.rect.mock.calls).toEqual([[0, 160, 200, 260]]); + }); + + it('only measures DOM media under the glass', () => { + const near = domImage(280, 180); + const far = domImage(900, 700); + const style = vi.spyOn(globalThis, 'getComputedStyle'); + stateMock.x = 300; + stateMock.y = 200; + + renderer.renderHighResRegion(targetCanvas(0, 0, 1)); + + expect(style.mock.calls.map(c => c[0])).toEqual([near]); + expect(drawsOf(near)).toHaveLength(1); + expect(drawsOf(far)).toHaveLength(0); + }); + it('passes client coordinates to setZoom', () => { appMock.canvas.ds = { scale: 0.5, offset: [0, 0] }; appMock.canvas.setZoom = vi.fn((s: number) => { appMock.canvas.ds.scale = s; }); @@ -330,18 +386,27 @@ describe('OffscreenRenderer Optimization', () => { expect(drawsOf(img)).toEqual([[img, 60, 60, 80, 60]]); }); - it('draws the video on every call in Virtual Zoom (no throttle)', () => { + 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) => { appMock.canvas.ds.scale = s; }); - appMock.canvas.draw = vi.fn(); + appMock.canvas.setZoom = vi.fn((s: number) => { + appMock.canvas.ds.scale = s; + appMock.canvas.ds.offset = [0, 0]; + }); + appMock.canvas.draw = vi.fn(() => { appMock.canvas.ds.scale = 0.5; }); const video = domVideo(4); stateMock.x = 300; stateMock.y = 200; const canvas = targetCanvas(0, 0, 1); + const now = vi.spyOn(performance, 'now').mockReturnValue(1000); - renderer.renderHighResRegion(canvas); + const first = renderer.renderHighResRegion(canvas); + now.mockReturnValue(1007); // next frame on a 144 Hz display + const skipped = renderer.renderHighResRegion(canvas); + now.mockReturnValue(1017); // next frame on a 60 Hz display renderer.renderHighResRegion(canvas); + expect(skipped).toBe(first); + expect(appMock.canvas.draw).toHaveBeenCalledTimes(4); // Measured at 0.5, scaled x2 about the pivot (300, 200) -> (260, 160, 80x60) -> glass (20, 20, 160x120) expect(drawsOf(video)).toEqual([[video, 20, 20, 160, 120], [video, 20, 20, 160, 120]]); }); diff --git a/tests/unit/SidebarAbout.test.ts b/tests/unit/SidebarAbout.test.ts index b228655..80c2b9b 100644 --- a/tests/unit/SidebarAbout.test.ts +++ b/tests/unit/SidebarAbout.test.ts @@ -74,4 +74,13 @@ describe('Sidebar About', () => { expect(el.querySelectorAll('.magnify-about')).toHaveLength(1); vi.unstubAllGlobals(); }); + + it('makes the tab wrapper fill the panel so the content does not collapse', () => { + registerSidebar(); + const tab = vi.mocked(app.extensionManager.registerSidebarTab).mock.calls[0][0]; + const el = document.createElement('div'); + tab.render(el); + + expect(el.style.height).toBe('100%'); + }); }); diff --git a/web/magnify-glass/OffscreenRenderer.js b/web/magnify-glass/OffscreenRenderer.js index 9964499..a2439c6 100644 --- a/web/magnify-glass/OffscreenRenderer.js +++ b/web/magnify-glass/OffscreenRenderer.js @@ -4,6 +4,11 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy import { getSettingValue } from "../shared/utils.js"; import { captureRegion, scaleAbout, isSafeMediaSource, graphBoxToCss, previewImageBoxes, toGlass, fitContent } from "./mediaLayout.js"; const TITLE_HEIGHT = 30; +const VIRTUAL_ZOOM_MIN_INTERVAL_MS = 14; +function boundingBox(node) { + const [x, y, w, h] = node.boundingRect; + return { x, y, w, h }; +} class OffscreenRenderer { constructor(config, state) { __publicField(this, "config"); @@ -13,6 +18,7 @@ class OffscreenRenderer { __publicField(this, "lastRenderWidth", 0); __publicField(this, "lastRenderHeight", 0); __publicField(this, "isCapturing", false); + __publicField(this, "lastVirtualZoomTime", 0); this.config = config; this.state = state; this.initOffscreenCanvas(); @@ -41,45 +47,47 @@ class OffscreenRenderer { const cap = captureRegion(pivotX, pivotY, renderSize, this.config.zoomFactor); const vueNodes = !!((_a = window.LiteGraph) == null ? void 0 : _a.vueNodesMode); const currentScale = lgCanvas.ds.scale; - const media = this.collectDomMedia(rect, vueNodes); - if (currentScale < 1 && !this.config.forceDirectCapture && !vueNodes) { - const domScale = 1 / currentScale; - const scaled = media.map((m) => ({ ...m, box: scaleAbout(m.box, pivotX, pivotY, domScale) })); - return this.renderVirtualZoom(lgCanvas, targetCanvas, rect, dpr, pivotX, pivotY, cap, scaled); + const virtualZoom = currentScale < 1 && !this.config.forceDirectCapture && !vueNodes; + if (virtualZoom) { + const now = performance.now(); + if (now - this.lastVirtualZoomTime < VIRTUAL_ZOOM_MIN_INTERVAL_MS) return this.offscreenCanvas; + this.lastVirtualZoomTime = now; + } + const media = this.collectDomMedia(rect, vueNodes, cap, pivotX, pivotY, virtualZoom ? 1 / currentScale : 1); + if (virtualZoom) { + return this.renderVirtualZoom(lgCanvas, targetCanvas, rect, dpr, pivotX, pivotY, cap, media); } return this.renderDirectCapture(targetCanvas, dpr, cap, media, vueNodes); } /** * Read phase: measure loaded, same-origin DOM images and videos (DOM widgets, or Vue node bodies) - * before any canvas draw. Boxes are CSS pixels relative to the canvas at the real zoom. + * under the glass before any canvas draw. Boxes are CSS pixels relative to the canvas, scaled by + * domScale about the pivot into the capture frame. */ - collectDomMedia(rect, vueNodes) { + collectDomMedia(rect, vueNodes, cap, pivotX, pivotY, domScale) { const selector = vueNodes ? "[data-node-id] img, [data-node-id] video" : ".dom-widget img, .dom-widget video"; const media = []; for (const el of document.querySelectorAll(selector)) { const loaded = el instanceof HTMLVideoElement ? el.readyState >= 2 : el.complete && el.naturalWidth > 0; - if (!loaded || !isSafeMediaSource(el)) continue; + if (!loaded) continue; const r = el.getBoundingClientRect(); if (r.width === 0 || r.height === 0) continue; - media.push({ - el, - box: { x: r.left - rect.left, y: r.top - rect.top, w: r.width, h: r.height }, - objectFit: getComputedStyle(el).objectFit - }); + const box = scaleAbout({ x: r.left - rect.left, y: r.top - rect.top, w: r.width, h: r.height }, pivotX, pivotY, domScale); + if (box.x + box.w <= cap.left || box.x >= cap.left + cap.size) continue; + if (box.y + box.h <= cap.top || box.y >= cap.top + cap.size) continue; + if (!isSafeMediaSource(el)) continue; + media.push({ el, box, objectFit: getComputedStyle(el).objectFit }); } return media; } /** - * Nodes of the graph on screen (including subgraphs) that overlap the capture region, - * in canvas render order (back to front). + * Nodes of the graph on screen (including subgraphs and collapsed nodes) whose title or body + * overlaps the capture region, in canvas render order (back to front). */ getVisibleNodes(cap, scale, offset) { - var _a; const visibleNodes = []; for (const node of app.canvas.visible_nodes) { - if (!node.pos || !node.size) continue; - if ((_a = node.flags) == null ? void 0 : _a.collapsed) continue; - const box = graphBoxToCss({ x: 0, y: 0, w: node.size[0], h: node.size[1] }, node.pos, scale, offset); + const box = graphBoxToCss(boundingBox(node), [0, 0], scale, offset); if (box.x + box.w < cap.left || box.x > cap.left + cap.size) continue; if (box.y + box.h < cap.top || box.y > cap.top + cap.size) continue; visibleNodes.push(node); @@ -218,7 +226,7 @@ class OffscreenRenderer { * into the glass. Video is redrawn every tick, so it plays. */ drawMedia(targetCanvas, dpr, visibleNodes, media, cap, scale, offset) { - var _a, _b; + var _a, _b, _c; const ctx = this.offscreenCtx; const renderSize = this.config.glassSize; const onGlass = (b) => b.w > 0 && b.h > 0 && b.x < renderSize && b.y < renderSize && b.x + b.w > 0 && b.y + b.h > 0; @@ -229,7 +237,7 @@ class OffscreenRenderer { ctx.imageSmoothingQuality = "high"; const sizeTextMargin = getSettingValue("Comfy.Node.AllowImageSizeDraw", true) ? 15 : 0; for (const [i, node] of visibleNodes.entries()) { - const widget = ((_a = node.imgs) == null ? void 0 : _a.length) && ((_b = node.widgets) == null ? void 0 : _b.find((w) => w.name === "$$canvas-image-preview")); + const widget = !((_a = node.flags) == null ? void 0 : _a.collapsed) && ((_b = node.imgs) == null ? void 0 : _b.length) && ((_c = node.widgets) == null ? void 0 : _c.find((w) => w.name === "$$canvas-image-preview")); if (!widget) continue; for (const { img, box } of previewImageBoxes(node.imgs, widget, node, sizeTextMargin)) { const g = toGlass(graphBoxToCss(box, node.pos, scale, offset), cap); @@ -237,7 +245,7 @@ class OffscreenRenderer { } const nodeBox = toGlass(graphBoxToCss({ x: 0, y: 0, w: node.size[0], h: node.size[1] }, node.pos, scale, offset), cap); for (const front of visibleNodes.slice(i + 1)) { - const f = toGlass(graphBoxToCss({ x: 0, y: -TITLE_HEIGHT, w: front.size[0], h: front.size[1] + TITLE_HEIGHT }, front.pos, scale, offset), cap); + const f = toGlass(graphBoxToCss(boundingBox(front), [0, 0], scale, offset), cap); if (!overlaps(f, nodeBox)) continue; ctx.save(); ctx.beginPath(); @@ -269,7 +277,6 @@ class OffscreenRenderer { const ctx = this.offscreenCtx; const renderSize = this.config.glassSize; for (const node of visibleNodes) { - if (!node.pos || !node.size) continue; if ((_a = node.flags) == null ? void 0 : _a.collapsed) continue; const title = node.title || node.type || "Node"; const nodeCssX = (node.pos[0] + offset[0]) * scale; @@ -279,7 +286,7 @@ class OffscreenRenderer { if (nodeCssY + TITLE_HEIGHT * 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 = Math.min(nodeCssWidth, nodeCssWidth) * cap.k; + const canvasWidth = nodeCssWidth * cap.k; const canvasHeight = TITLE_HEIGHT * scale * cap.k; if (canvasX + canvasWidth > 0 && canvasX < renderSize && canvasY + canvasHeight > 0 && canvasY < renderSize) { ctx.save(); @@ -328,7 +335,7 @@ class OffscreenRenderer { const WIDGET_MARGIN = 4; const PADDING = 15; for (const node of visibleNodes) { - if (!node.widgets || !node.pos || !node.size) continue; + if (!node.widgets) continue; if ((_a = node.flags) == null ? void 0 : _a.collapsed) continue; const nodeCssX = (node.pos[0] + offset[0]) * scale; const nodeCssY = (node.pos[1] + offset[1]) * scale; diff --git a/web/shared/settings.js b/web/shared/settings.js deleted file mode 100644 index f6ad602..0000000 --- a/web/shared/settings.js +++ /dev/null @@ -1,459 +0,0 @@ -import { app } from "/scripts/app.js"; -import { DEFAULT_ZOOM_FACTOR, DEFAULT_GLASS_SIZE, DEFAULT_BORDER_WIDTH, DEFAULT_BORDER_COLOR, ACTIVATION_KEYS, DEFAULT_OFFSET_STEP, RESET_KEYS, GLASS_POSITIONS, GLASS_SHAPES, TOGGLE_FOLLOW_KEYS, DEFAULT_PANEL_MAX_HEIGHT, DEFAULT_PANEL_OPACITY, DEFAULT_PANEL_WIDTH, PANEL_POSITIONS } from "./constants.js"; -const DEFAULT_GLASS_SETTINGS = { - "🔍MagnifyGlass.ZoomFactor": DEFAULT_ZOOM_FACTOR, - "🔍MagnifyGlass.GlassSize": DEFAULT_GLASS_SIZE, - "🔍MagnifyGlass.BorderColor": DEFAULT_BORDER_COLOR, - "🔍MagnifyGlass.BorderWidth": DEFAULT_BORDER_WIDTH, - "🔍MagnifyGlass.ActivationKey": "x", - "🔍MagnifyGlass.AltRequired": false, - "🔍MagnifyGlass.FollowCursor": false, - "🔍MagnifyGlass.DebugMode": false, - "🔍MagnifyGlass.OffsetStep": DEFAULT_OFFSET_STEP, - "🔍MagnifyGlass.GlassPosition": "Top-Right", - "🔍MagnifyGlass.ResetKey": "o", - "🔍MagnifyGlass.GlassShape": "Rounded Square", - "🔍MagnifyGlass.BorderEnabled": true, - "🔍MagnifyGlass.TextureFiltering": "Linear", - "🔍MagnifyGlass.AlwaysActiveMode": true, - "🔍MagnifyGlass.ToggleFollowCursorKey": "h" -}; -const DEFAULT_PANEL_SETTINGS = { - "🔍MagnifyGlass.InfoPanelEnabled": true, - "🔍MagnifyGlass.InfoPanelPosition": "Bottom", - "🔍MagnifyGlass.InfoPanelWidth": DEFAULT_PANEL_WIDTH, - "🔍MagnifyGlass.InfoPanelOpacity": DEFAULT_PANEL_OPACITY, - "🔍MagnifyGlass.InfoPanelMaxHeight": DEFAULT_PANEL_MAX_HEIGHT, - "🔍MagnifyGlass.InfoPanelAnimations": false, - "🔍MagnifyGlass.ShowInspectorTab": false, - "🔍MagnifyGlass.ToggleHotkey": "i", - "🔍MagnifyGlass.GlassPreviewToggleHotkey": "g", - "🔍MagnifyGlass.PinPanelHotkey": "u", - "🔍MagnifyGlass.ShowHoveringControls": true, - "🔍MagnifyGlass.ControlsPosition": "bottom", - "🔍MagnifyGlass.InfoPanelTextColor": "#6b7280", - "🔍MagnifyGlass.InfoPanelAccentColor": "#3b82f6" -}; -function registerGlassSettings(magnifyGlass) { - const settings = DEFAULT_GLASS_SETTINGS; - app.ui.settings.addSetting({ - id: "🔍MagnifyGlass.ZoomFactor", - name: "🔍 Magnify Glass: Zoom Factor (%)", - type: "slider", - defaultValue: settings["🔍MagnifyGlass.ZoomFactor"], - min: 100, - max: 1e3, - step: 25, - tooltip: "Magnification level as a percentage (e.g., 300 = 3x zoom, 150 = 1.5x zoom).", - onChange: (value) => { - if (magnifyGlass == null ? void 0 : magnifyGlass.config) { - magnifyGlass.config.zoomFactor = typeof value === "number" ? value / 100 : parseFloat(value) / 100; - if (magnifyGlass.state.active) { - magnifyGlass.updateMagnifiedView(); - } - } - } - }); - app.ui.settings.addSetting({ - id: "🔍MagnifyGlass.GlassSize", - name: "🔍 Magnify Glass: Size (px)", - type: "slider", - defaultValue: settings["🔍MagnifyGlass.GlassSize"], - min: 50, - max: 100, - step: 10, - tooltip: "Diameter of the magnifying glass circle in pixels.", - onChange: (value) => { - if (magnifyGlass == null ? void 0 : magnifyGlass.config) { - magnifyGlass.config.glassSize = parseInt(String(value), 10); - magnifyGlass.applyUiChanges(); - if (magnifyGlass.state.active) { - magnifyGlass.updateMagnifiedView(); - } - } - } - }); - app.ui.settings.addSetting({ - id: "🔍MagnifyGlass.BorderWidth", - name: "🔍 Magnify Glass: Border Width (px)", - type: "slider", - defaultValue: settings["🔍MagnifyGlass.BorderWidth"], - min: 0, - max: 10, - step: 0.1, - tooltip: "Width of the border around the magnifying glass.", - onChange: (value) => { - if (magnifyGlass == null ? void 0 : magnifyGlass.config) { - magnifyGlass.config.borderWidth = parseFloat(String(value)); - magnifyGlass.applyUiChanges(); - } - } - }); - app.ui.settings.addSetting({ - id: "🔍MagnifyGlass.BorderColor", - name: "🎨 Magnify Glass: Border Color", - type: "color", - defaultValue: settings["🔍MagnifyGlass.BorderColor"], - tooltip: "Color of the border around the magnifying glass.", - onChange: (value) => { - if (magnifyGlass == null ? void 0 : magnifyGlass.config) { - const strValue = String(value); - const normalizedColor = strValue && !strValue.startsWith("#") ? `#${strValue}` : strValue; - magnifyGlass.config.borderColor = normalizedColor; - if (strValue !== normalizedColor) { - try { - app.ui.settings.setSettingValue("🔍MagnifyGlass.BorderColor", normalizedColor); - } catch (e) { - console.warn("Failed to save normalized border color:", e); - } - } - magnifyGlass.applyUiChanges(); - } - } - }); - app.ui.settings.addSetting({ - id: "🔍MagnifyGlass.ActivationKey", - name: "⌨️ Magnify Glass: Activation Key", - type: "combo", - options: ACTIVATION_KEYS.map((k) => ({ value: k, text: k })), - // Assuming ACTIVATION_KEYS is simply an array of strings - defaultValue: settings["🔍MagnifyGlass.ActivationKey"], - tooltip: "The key to activate the magnifier.", - onChange: (value) => { - if (magnifyGlass == null ? void 0 : magnifyGlass.config) { - magnifyGlass.config.activationKey = String(value).toLowerCase(); - } - } - }); - app.ui.settings.addSetting({ - id: "🔍MagnifyGlass.AltRequired", - name: "⌨️ Magnify Glass: Require Alt/Option Key", - type: "combo", - options: [{ value: true, text: "Yes" }, { value: false, text: "No" }], - defaultValue: settings["🔍MagnifyGlass.AltRequired"], - tooltip: "If Yes, Alt (Windows/Linux) or Option (Mac) must be held for activation.", - onChange: (value) => { - if (magnifyGlass == null ? void 0 : magnifyGlass.config) { - magnifyGlass.config.altRequired = !!value; - } - } - }); - app.ui.settings.addSetting({ - id: "🔍MagnifyGlass.FollowCursor", - name: "🖱️ Magnify Glass: Follow Cursor Position", - type: "combo", - options: [{ value: true, text: "Yes" }, { value: false, text: "No" }], - defaultValue: settings["🔍MagnifyGlass.FollowCursor"], - tooltip: "If Yes, the magnifier window moves with the cursor.", - onChange: (value) => { - if (magnifyGlass == null ? void 0 : magnifyGlass.config) { - magnifyGlass.config.followCursor = !!value; - } - } - }); - app.ui.settings.addSetting({ - id: "🔍MagnifyGlass.OffsetStep", - name: "⌨️ Magnify Glass: Offset Adjust Step", - type: "slider", - defaultValue: settings["🔍MagnifyGlass.OffsetStep"], - min: 1, - max: 50, - step: 1, - tooltip: "How many graph units the view shifts when pressing arrow keys.", - onChange: (value) => { - if (magnifyGlass == null ? void 0 : magnifyGlass.config) { - magnifyGlass.config.offsetStep = parseInt(String(value), 10); - } - } - }); - app.ui.settings.addSetting({ - id: "🔍MagnifyGlass.ResetKey", - name: "⌨️ Magnify Glass: Reset Offset Key", - type: "combo", - options: RESET_KEYS.map((k) => ({ value: k, text: k })), - defaultValue: settings["🔍MagnifyGlass.ResetKey"], - tooltip: "The key to reset the magnify glass offset to default.", - onChange: (value) => { - if (magnifyGlass == null ? void 0 : magnifyGlass.config) { - magnifyGlass.config.resetKey = String(value).toLowerCase(); - } - } - }); - app.ui.settings.addSetting({ - id: "🔍MagnifyGlass.DebugMode", - name: "🐞 Magnify Glass: Debug Mode", - type: "combo", - options: [{ value: true, text: "Enabled" }, { value: false, text: "Disabled" }], - defaultValue: settings["🔍MagnifyGlass.DebugMode"], - tooltip: "Show detailed logging and the debug visualization overlay.", - onChange: (value) => { - if (magnifyGlass == null ? void 0 : magnifyGlass.config) { - magnifyGlass.config.debugMode = !!value; - magnifyGlass.applyUiChanges(); - } - } - }); - app.ui.settings.addSetting({ - id: "🔍MagnifyGlass.GlassPosition", - name: "🖱️ Magnify Glass: Glass Position", - type: "combo", - options: GLASS_POSITIONS.map((p) => ({ value: p, text: p === "Bottom" ? "Bottom (Default)" : p })), - defaultValue: settings["🔍MagnifyGlass.GlassPosition"], - tooltip: "Position of the magnifying glass relative to the cursor.", - onChange: (value) => { - if (magnifyGlass == null ? void 0 : magnifyGlass.config) { - magnifyGlass.config.glassPosition = String(value); - if (magnifyGlass.state.active && !magnifyGlass.config.followCursor) { - const { x, y } = magnifyGlass.lastKnownMousePosition; - magnifyGlass.ui.positionGlass(x, y); - } - } - } - }); - app.ui.settings.addSetting({ - id: "🔍MagnifyGlass.GlassShape", - name: "🖼️ Magnify Glass: Shape", - type: "combo", - options: GLASS_SHAPES.map((s) => ({ value: s, text: s })), - defaultValue: settings["🔍MagnifyGlass.GlassShape"], - tooltip: "Shape of the magnifying glass.", - onChange: (value) => { - if (magnifyGlass == null ? void 0 : magnifyGlass.config) { - magnifyGlass.config.glassShape = String(value); - magnifyGlass.applyUiChanges(); - } - } - }); - app.ui.settings.addSetting({ - id: "🔍MagnifyGlass.BorderEnabled", - name: "🖼️ Magnify Glass: Show Border", - type: "combo", - options: [{ value: true, text: "Yes" }, { value: false, text: "No" }], - defaultValue: settings["🔍MagnifyGlass.BorderEnabled"], - tooltip: "Enable or disable the border around the magnifying glass.", - onChange: (value) => { - if (magnifyGlass == null ? void 0 : magnifyGlass.config) { - magnifyGlass.config.borderEnabled = !!value; - magnifyGlass.applyUiChanges(); - } - } - }); - app.ui.settings.addSetting({ - id: "🔍MagnifyGlass.TextureFiltering", - name: "🖼️ Magnify Glass: Texture Filtering", - type: "combo", - options: [ - { value: "Linear", text: "Linear (Smooth)" }, - { value: "Nearest", text: "Nearest (Pixelated)" } - ], - defaultValue: settings["🔍MagnifyGlass.TextureFiltering"], - tooltip: "Controls how the magnified image is scaled.", - onChange: (value) => { - if ((magnifyGlass == null ? void 0 : magnifyGlass.config) && magnifyGlass.renderer) { - magnifyGlass.config.textureFiltering = String(value); - magnifyGlass.renderer.updateTextureFiltering(value); - if (magnifyGlass.state.active) { - magnifyGlass.updateMagnifiedView(); - } - } - } - }); - app.ui.settings.addSetting({ - id: "🔍MagnifyGlass.AlwaysActiveMode", - name: "🔒 Magnify Glass: Always Active Mode", - type: "combo", - options: [{ value: true, text: "Yes" }, { value: false, text: "No" }], - defaultValue: settings["🔍MagnifyGlass.AlwaysActiveMode"], - tooltip: "If Yes, activating the magnifier keeps it on until activated again.", - onChange: (value) => { - if (magnifyGlass == null ? void 0 : magnifyGlass.config) { - magnifyGlass.config.alwaysActiveMode = !!value; - } - } - }); - app.ui.settings.addSetting({ - id: "🔍MagnifyGlass.ToggleFollowCursorKey", - name: "🔑 Magnify Glass: Toggle Follow Key", - type: "combo", - options: TOGGLE_FOLLOW_KEYS.map((k) => ({ value: k, text: k })), - defaultValue: settings["🔍MagnifyGlass.ToggleFollowCursorKey"], - tooltip: "The key to toggle the 'Follow Cursor' behavior.", - onChange: (value) => { - if (magnifyGlass == null ? void 0 : magnifyGlass.config) { - magnifyGlass.config.toggleFollowCursorKey = String(value).toLowerCase(); - } - } - }); -} -function registerPanelSettings(stateManager, uiManager, positionManager) { - const settings = DEFAULT_PANEL_SETTINGS; - app.ui.settings.addSetting({ - id: "🔍MagnifyGlass.InfoPanelEnabled", - name: "📊 Info Panel: Enable", - type: "combo", - options: [{ value: true, text: "Yes" }, { value: false, text: "No" }], - defaultValue: settings["🔍MagnifyGlass.InfoPanelEnabled"], - tooltip: "Enable or disable the information panel.", - onChange: (value) => { - if (stateManager) { - stateManager.state.settings["🔍MagnifyGlass.InfoPanelEnabled"] = !!value; - if (!value && uiManager) { - uiManager.hide(); - } - } - } - }); - app.ui.settings.addSetting({ - id: "🔍MagnifyGlass.InfoPanelPosition", - name: "📊 Info Panel: Position", - type: "combo", - options: PANEL_POSITIONS.map((p) => ({ value: p, text: p })), - defaultValue: settings["🔍MagnifyGlass.InfoPanelPosition"], - tooltip: "Position of the info panel relative to the magnifying glass.", - onChange: (value) => { - if (stateManager) { - stateManager.state.settings["🔍MagnifyGlass.InfoPanelPosition"] = String(value); - if (positionManager) { - positionManager.positionPanel(); - } - } - } - }); - app.ui.settings.addSetting({ - id: "🔍MagnifyGlass.InfoPanelWidth", - name: "📊 Info Panel: Width (px)", - type: "slider", - defaultValue: settings["🔍MagnifyGlass.InfoPanelWidth"], - min: 200, - max: 600, - step: 20, - tooltip: "Width of the information panel in pixels.", - onChange: (value) => { - if (stateManager) { - stateManager.state.settings["🔍MagnifyGlass.InfoPanelWidth"] = parseInt(String(value), 10); - if (uiManager) { - uiManager.applyStyles(); - } - } - } - }); - app.ui.settings.addSetting({ - id: "🔍MagnifyGlass.InfoPanelOpacity", - name: "📊 Info Panel: Opacity (%)", - type: "slider", - defaultValue: settings["🔍MagnifyGlass.InfoPanelOpacity"], - min: 10, - max: 100, - step: 5, - tooltip: "Opacity of the information panel background.", - onChange: (value) => { - if (stateManager) { - stateManager.state.settings["🔍MagnifyGlass.InfoPanelOpacity"] = parseInt(String(value), 10); - if (uiManager) { - uiManager.applyStyles(); - } - } - } - }); - app.ui.settings.addSetting({ - id: "🔍MagnifyGlass.InfoPanelMaxHeight", - name: "📊 Info Panel: Max Height (px)", - type: "slider", - defaultValue: settings["🔍MagnifyGlass.InfoPanelMaxHeight"], - min: 200, - max: 1500, - step: 50, - tooltip: "Maximum height of the information panel in pixels.", - onChange: (value) => { - if (stateManager) { - stateManager.state.settings["🔍MagnifyGlass.InfoPanelMaxHeight"] = parseInt(String(value), 10); - if (uiManager) { - uiManager.applyStyles(); - } - } - } - }); - app.ui.settings.addSetting({ - id: "🔍MagnifyGlass.InfoPanelAnimations", - name: "📊 Info Panel: Animations", - type: "combo", - options: [{ value: true, text: "Enabled" }, { value: false, text: "Disabled" }], - defaultValue: settings["🔍MagnifyGlass.InfoPanelAnimations"], - tooltip: "Enable or disable animations for the info panel.", - onChange: (value) => { - if (stateManager) { - stateManager.state.settings["🔍MagnifyGlass.InfoPanelAnimations"] = !!value; - } - } - }); - app.ui.settings.addSetting({ - id: "🔍MagnifyGlass.ShowInspectorTab", - name: "📊 Info Panel: Show Inspector Tab", - type: "combo", - options: [{ value: true, text: "Yes" }, { value: false, text: "No" }], - defaultValue: settings["🔍MagnifyGlass.ShowInspectorTab"], - tooltip: "Show or hide the Inspector tab with cursor and canvas information.", - onChange: (value) => { - if (stateManager) { - stateManager.state.settings["🔍MagnifyGlass.ShowInspectorTab"] = !!value; - } - } - }); - app.ui.settings.addSetting({ - id: "🔍MagnifyGlass.ShowHoveringControls", - name: "📊 Info Panel: Show Hover Controls", - type: "combo", - options: [{ value: true, text: "Yes" }, { value: false, text: "No" }], - defaultValue: settings["🔍MagnifyGlass.ShowHoveringControls"], - tooltip: "Show or hide hovering UI controls above the info panel.", - onChange: (value) => { - if (stateManager) { - stateManager.state.settings["🔍MagnifyGlass.ShowHoveringControls"] = !!value; - if (uiManager && uiManager.elements.controls) { - uiManager.elements.controls.style.display = value ? "flex" : "none"; - } - } - } - }); - app.ui.settings.addSetting({ - id: "🔍MagnifyGlass.InfoPanelTextColor", - name: "🎨 Info Panel: Text Color", - type: "color", - defaultValue: settings["🔍MagnifyGlass.InfoPanelTextColor"], - tooltip: "Custom text color for the info panel.", - onChange: (value) => { - if (stateManager) { - const strValue = String(value); - const normalizedColor = strValue && !strValue.startsWith("#") ? `#${strValue}` : strValue; - stateManager.state.settings["🔍MagnifyGlass.InfoPanelTextColor"] = normalizedColor; - if (uiManager) { - uiManager.applyStyles(); - } - } - } - }); - app.ui.settings.addSetting({ - id: "🔍MagnifyGlass.InfoPanelAccentColor", - name: "🎨 Info Panel: Accent Color", - type: "color", - defaultValue: settings["🔍MagnifyGlass.InfoPanelAccentColor"], - tooltip: "Custom accent color for the info panel.", - onChange: (value) => { - if (stateManager) { - const strValue = String(value); - const normalizedColor = strValue && !strValue.startsWith("#") ? `#${strValue}` : strValue; - stateManager.state.settings["🔍MagnifyGlass.InfoPanelAccentColor"] = normalizedColor; - if (uiManager) { - uiManager.applyStyles(); - } - } - } - }); -} -export { - DEFAULT_GLASS_SETTINGS, - DEFAULT_PANEL_SETTINGS, - registerGlassSettings, - registerPanelSettings -}; -//# sourceMappingURL=settings.js.map diff --git a/web/sidebar.css b/web/sidebar.css index b700cf8..e4034e8 100644 --- a/web/sidebar.css +++ b/web/sidebar.css @@ -82,7 +82,7 @@ /* Body: the panel's one scroller */ .magnify-sidebar-content { - flex: 1 1 0; + flex: 1 1 auto; min-height: 0; overflow-x: hidden; overflow-y: auto; diff --git a/web/sidebar/sidebar.js b/web/sidebar/sidebar.js index 4dabf3d..f1886b9 100644 --- a/web/sidebar/sidebar.js +++ b/web/sidebar/sidebar.js @@ -24,6 +24,7 @@ function renderSidebar(container) { return; } container.innerHTML = ""; + container.style.height = "100%"; const sidebar = document.createElement("div"); sidebar.className = "magnify-sidebar"; sidebar.style.visibility = "hidden";