refactor: implement user-controlled resizing for dual panel subgraph viewer
This commit is contained in:
@@ -25,3 +25,11 @@
|
||||
- **Event listener cleanup:** Touch focus handlers were previously registered with `{ passive: true }` but removed without matching options. Matching add/remove signatures prevents leaked listeners on Safari/iOS.
|
||||
- **Bug fix regressions:** A regressions introduced while refreshing the host calculation referenced an undefined `canvasElement`, preventing the panel from opening. Restored correct usage of `currentCanvas.parentElement`, eliminating the runtime `ReferenceError`.
|
||||
- **Observed backend 404s:** The Vue frontend logs `api/userdata/...` 404s when optional user assets (templates, CSS) are absent. These are expected in a clean setup and unrelated to the extension; documented here to avoid misattributing them to the panel logic.
|
||||
|
||||
- **Open issue (2025-10-18): Subgraph panel cannot host DOM-based widgets (e.g. CLIP Text Encode multiline prompt).**
|
||||
ComfyUI renders multiline prompts as DOM widgets (`addDOMWidget`) via a separate Vue+Pinia layer tied to the main canvas. Our dual-panel canvas does not participate in that lifecycle, so the widgets are not promoted/visible and user input is impossible without a deeper frontend change. Until the upstream DOM widget layer supports multiple canvases, we treat multiline prompts as read-only inside the panel.
|
||||
- **Open issue (2025-10-18): Node toolbars are missing inside the subgraph panel.**
|
||||
The Vue toolbar overlay relies on `useCanvasStore()` (Pinia) watching the active canvas; our secondary `LGraphCanvas` is not registered with that store, so the overlay never renders. Enabling it would require upstream support for multiple canvases inside the front-end store.
|
||||
|
||||
- **Open issue (2025-10-18): Node toolbars are missing inside the subgraph panel.**
|
||||
The Vue toolbar overlay relies on `useCanvasStore()` (Pinia) watching the active canvas; our secondary LGraphCanvas is not registered with that store, so the overlay never renders. Enabling it would require upstream support for multiple canvases inside the front-end store.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# 2025-10-19 Dual Panel Resize Persistence
|
||||
|
||||
## Context
|
||||
- Requirement `000-dual-panel-subgraph` now needs the subgraph side panel to support user-controlled widths instead of the fixed CSS-based size.
|
||||
- The existing JavaScript extension already injects the panel and manages layout through the `--dual-panel-subgraph-width` CSS variable, making it a natural hook for resizing logic.
|
||||
- We must keep the implementation non-invasive and compatible with the Vue/TypeScript frontend, following the documented `app.registerExtension()` workflow.
|
||||
|
||||
## Decision
|
||||
- Introduced a dedicated resize handle (`.dual-panel-subgraph__resize-handle`) that listens for pointer events and adjusts the panel width on drag.
|
||||
- Clamped panel widths between 320px and `viewport - 360px` to preserve a usable main canvas, updating the CSS variable on every change.
|
||||
- Persisted the most recent width in `localStorage` under the key `dualPanel.subgraph.width.v1`, restoring it whenever the panel reopens.
|
||||
- Added a temporary body class while resizing to give user feedback, suppress unwanted text selection, and disable layout transitions that caused visible gaps.
|
||||
- Throttled canvas resizes with `requestAnimationFrame` during pointer moves to keep drag interactions responsive while still redrawing at the next frame.
|
||||
- When the panel is open, detected the existing PrimeVue canvas toolbar (`.p-buttongroup.p-component.p-buttongroup-vertical`) and the minimap container (`.minimap-main-container`), pinning both to the viewport with fixed positioning so they stay anchored while the panel resizes the main canvas host.
|
||||
|
||||
## Consequences
|
||||
- Users can resize the panel freely, and their preferred width survives reloads in the same browser profile.
|
||||
- The new localStorage key is browser-scoped; different profiles or privacy modes will not share the saved width.
|
||||
- Additional pointer-event logic must be maintained alongside future LiteGraph or Vue updates, but it remains isolated within the extension.
|
||||
- Frame-throttled resizing keeps the interaction smooth at the cost of slightly delayed redraws when dragging extremely quickly, which we accept as a trade-off.
|
||||
- The PrimeVue toolbar and minimap now float independently of the workflow canvas; if future ComfyUI updates rename these classes, we will need to refresh the tracked selectors.
|
||||
+297
-2
@@ -8,8 +8,16 @@ app.registerExtension({
|
||||
console.info("[DualPanel] ★★★ setup() called ★★★");
|
||||
const STYLE_ID = "dual-panel-subgraph-style";
|
||||
const BODY_CLASS = "dual-panel-subgraph-open";
|
||||
const BODY_RESIZING_CLASS = "dual-panel-subgraph-resizing";
|
||||
const HOST_CLASS = "dual-panel-main-host";
|
||||
const PANEL_CLASS = "dual-panel-subgraph";
|
||||
const WIDTH_STORAGE_KEY = "dualPanel.subgraph.width.v1";
|
||||
const MIN_PANEL_WIDTH = 320;
|
||||
const MIN_MAIN_WIDTH = 360;
|
||||
const FLOATING_UI_SELECTORS = [
|
||||
".p-buttongroup.p-component.p-buttongroup-vertical",
|
||||
".minimap-main-container"
|
||||
];
|
||||
|
||||
installStyle();
|
||||
|
||||
@@ -19,6 +27,7 @@ app.registerExtension({
|
||||
graphCanvas: null,
|
||||
disposers: [],
|
||||
mainHosts: [],
|
||||
floatingToolbars: [],
|
||||
};
|
||||
|
||||
function installStyle() {
|
||||
@@ -37,6 +46,11 @@ app.registerExtension({
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.${BODY_RESIZING_CLASS} {
|
||||
cursor: ew-resize;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.${PANEL_CLASS} {
|
||||
position: fixed;
|
||||
top: var(--dual-panel-top-offset, 0px);
|
||||
@@ -50,6 +64,7 @@ app.registerExtension({
|
||||
box-shadow: -24px 0 64px -48px rgba(0, 0, 0, 0.8);
|
||||
z-index: 64;
|
||||
transform: translateZ(0);
|
||||
will-change: width;
|
||||
}
|
||||
|
||||
.dual-panel-subgraph__header {
|
||||
@@ -85,6 +100,16 @@ app.registerExtension({
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.dual-panel-subgraph__resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -8px;
|
||||
width: 16px;
|
||||
height: 100%;
|
||||
cursor: ew-resize;
|
||||
z-index: 65;
|
||||
}
|
||||
|
||||
.dual-panel-subgraph__close:hover {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
border-color: rgba(255, 255, 255, 0.32);
|
||||
@@ -110,10 +135,233 @@ app.registerExtension({
|
||||
max-width: calc(100% - var(--dual-panel-subgraph-width));
|
||||
transition: width 140ms ease, max-width 140ms ease;
|
||||
}
|
||||
|
||||
body.${BODY_RESIZING_CLASS} .${PANEL_CLASS},
|
||||
body.${BODY_RESIZING_CLASS} .${HOST_CLASS} {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.dual-panel-toolbar-fixed {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
body.${BODY_CLASS} .dual-panel-toolbar-fixed {
|
||||
position: fixed !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function clampPanelWidth(width) {
|
||||
const viewportWidth = Math.max(window.innerWidth || document.documentElement.clientWidth || 0, MIN_PANEL_WIDTH);
|
||||
const maxWidth = Math.max(MIN_PANEL_WIDTH, viewportWidth - MIN_MAIN_WIDTH);
|
||||
const safeWidth = Math.min(Math.max(width, MIN_PANEL_WIDTH), Math.max(MIN_PANEL_WIDTH, maxWidth));
|
||||
return Math.round(safeWidth);
|
||||
}
|
||||
|
||||
function loadStoredPanelWidth() {
|
||||
try {
|
||||
const raw = window.localStorage?.getItem(WIDTH_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return clampPanelWidth(parsed);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[DualPanel] Failed to load stored panel width", error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function persistPanelWidth(width) {
|
||||
try {
|
||||
window.localStorage?.setItem(WIDTH_STORAGE_KEY, String(clampPanelWidth(width)));
|
||||
} catch (error) {
|
||||
console.warn("[DualPanel] Failed to persist panel width", error);
|
||||
}
|
||||
}
|
||||
|
||||
function setPanelWidth(width, { panel, canvasHost, canvas, graphCanvas, persist = false } = {}) {
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
const rounded = clampPanelWidth(width);
|
||||
const widthPx = `${rounded}px`;
|
||||
panel.style.width = widthPx;
|
||||
document.documentElement.style.setProperty("--dual-panel-subgraph-width", widthPx);
|
||||
updatePanelOffset(panel, canvasHost);
|
||||
if (canvas && graphCanvas) {
|
||||
handleResize(canvas, graphCanvas);
|
||||
}
|
||||
if (persist) {
|
||||
persistPanelWidth(rounded);
|
||||
}
|
||||
}
|
||||
|
||||
function restoreFloatingToolbars() {
|
||||
if (!state.floatingToolbars.length) {
|
||||
return;
|
||||
}
|
||||
const entries = state.floatingToolbars.splice(0, state.floatingToolbars.length);
|
||||
entries.forEach(({ element, previousInline }) => {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
element.classList.remove("dual-panel-toolbar-fixed");
|
||||
const { position, right, bottom, left, top, zIndex } = previousInline;
|
||||
element.style.position = position;
|
||||
element.style.right = right;
|
||||
element.style.bottom = bottom;
|
||||
element.style.left = left;
|
||||
element.style.top = top;
|
||||
element.style.zIndex = zIndex;
|
||||
});
|
||||
}
|
||||
|
||||
function pinFloatingToolbars() {
|
||||
restoreFloatingToolbars();
|
||||
const candidates = [];
|
||||
const seen = new Set();
|
||||
FLOATING_UI_SELECTORS.forEach((selector) => {
|
||||
const elements = document.querySelectorAll(selector);
|
||||
elements.forEach((element) => {
|
||||
if (!element || seen.has(element)) {
|
||||
return;
|
||||
}
|
||||
seen.add(element);
|
||||
candidates.push(element);
|
||||
});
|
||||
});
|
||||
const pinned = [];
|
||||
candidates.forEach((element) => {
|
||||
if (!element?.isConnected) {
|
||||
return;
|
||||
}
|
||||
if (state.panel && state.panel.contains(element)) {
|
||||
return;
|
||||
}
|
||||
const computed = window.getComputedStyle(element);
|
||||
if (!computed) {
|
||||
return;
|
||||
}
|
||||
if (computed.position !== "absolute" && computed.position !== "fixed") {
|
||||
return;
|
||||
}
|
||||
const verticalAnchor = computed.bottom !== "auto" ? { prop: "bottom", value: computed.bottom } :
|
||||
(computed.top !== "auto" ? { prop: "top", value: computed.top } : null);
|
||||
const horizontalAnchor = computed.right !== "auto" ? { prop: "right", value: computed.right } :
|
||||
(computed.left !== "auto" ? { prop: "left", value: computed.left } : null);
|
||||
if (!verticalAnchor || !horizontalAnchor) {
|
||||
return;
|
||||
}
|
||||
const previousInline = {
|
||||
position: element.style.position,
|
||||
right: element.style.right,
|
||||
bottom: element.style.bottom,
|
||||
left: element.style.left,
|
||||
top: element.style.top,
|
||||
zIndex: element.style.zIndex,
|
||||
};
|
||||
element.classList.add("dual-panel-toolbar-fixed");
|
||||
element.style.position = "fixed";
|
||||
element.style[verticalAnchor.prop] = verticalAnchor.value;
|
||||
element.style[verticalAnchor.prop === "bottom" ? "top" : "bottom"] = "";
|
||||
element.style[horizontalAnchor.prop] = horizontalAnchor.value;
|
||||
element.style[horizontalAnchor.prop === "right" ? "left" : "right"] = "";
|
||||
if (computed.zIndex === "auto") {
|
||||
element.style.zIndex = "70";
|
||||
} else {
|
||||
element.style.zIndex = computed.zIndex;
|
||||
}
|
||||
pinned.push({ element, previousInline });
|
||||
});
|
||||
if (pinned.length) {
|
||||
state.floatingToolbars = pinned;
|
||||
state.disposers.push(restoreFloatingToolbars);
|
||||
}
|
||||
}
|
||||
|
||||
function setupPanelResizer(panel, canvasHost, canvas, graphCanvas, resizeHandle) {
|
||||
if (!panel || !resizeHandle) {
|
||||
return;
|
||||
}
|
||||
|
||||
let resizeRafId = null;
|
||||
|
||||
const scheduleCanvasResize = () => {
|
||||
if (!canvas || !graphCanvas) {
|
||||
return;
|
||||
}
|
||||
if (resizeRafId !== null) {
|
||||
return;
|
||||
}
|
||||
resizeRafId = window.requestAnimationFrame(() => {
|
||||
resizeRafId = null;
|
||||
handleResize(canvas, graphCanvas);
|
||||
});
|
||||
};
|
||||
|
||||
const pointerDown = (event) => {
|
||||
const isPrimaryPointer = event.button === 0 || event.pointerType === "touch" || event.pointerType === "pen";
|
||||
if (!isPrimaryPointer) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
|
||||
const updateFromClientX = (clientX) => {
|
||||
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || panel.getBoundingClientRect().right;
|
||||
const desiredWidth = viewportWidth - clientX;
|
||||
setPanelWidth(desiredWidth, { panel, canvasHost });
|
||||
scheduleCanvasResize();
|
||||
};
|
||||
|
||||
const releasePointer = (releaseEvent) => {
|
||||
if (releaseEvent.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
resizeHandle.releasePointerCapture?.(event.pointerId);
|
||||
resizeHandle.removeEventListener("pointermove", handlePointerMove);
|
||||
resizeHandle.removeEventListener("pointerup", releasePointer);
|
||||
resizeHandle.removeEventListener("pointercancel", releasePointer);
|
||||
document.body.classList.remove(BODY_RESIZING_CLASS);
|
||||
if (resizeRafId !== null) {
|
||||
window.cancelAnimationFrame(resizeRafId);
|
||||
resizeRafId = null;
|
||||
}
|
||||
const finalWidth = panel.getBoundingClientRect().width;
|
||||
setPanelWidth(finalWidth, { panel, canvasHost, canvas, graphCanvas, persist: true });
|
||||
};
|
||||
|
||||
const handlePointerMove = (moveEvent) => {
|
||||
if (moveEvent.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
moveEvent.preventDefault();
|
||||
updateFromClientX(moveEvent.clientX);
|
||||
};
|
||||
|
||||
resizeHandle.setPointerCapture?.(event.pointerId);
|
||||
document.body.classList.add(BODY_RESIZING_CLASS);
|
||||
updateFromClientX(event.clientX);
|
||||
|
||||
resizeHandle.addEventListener("pointermove", handlePointerMove);
|
||||
resizeHandle.addEventListener("pointerup", releasePointer);
|
||||
resizeHandle.addEventListener("pointercancel", releasePointer);
|
||||
};
|
||||
|
||||
resizeHandle.addEventListener("pointerdown", pointerDown);
|
||||
state.disposers.push(() => {
|
||||
document.body.classList.remove(BODY_RESIZING_CLASS);
|
||||
if (resizeRafId !== null) {
|
||||
window.cancelAnimationFrame(resizeRafId);
|
||||
resizeRafId = null;
|
||||
}
|
||||
resizeHandle.removeEventListener("pointerdown", pointerDown);
|
||||
});
|
||||
}
|
||||
|
||||
function getMainCanvasElement() {
|
||||
return app.canvas?.canvas ?? document.querySelector("#graph-canvas canvas");
|
||||
}
|
||||
@@ -169,6 +417,8 @@ app.registerExtension({
|
||||
}
|
||||
|
||||
disposeListeners();
|
||||
restoreFloatingToolbars();
|
||||
document.body.classList.remove(BODY_RESIZING_CLASS);
|
||||
|
||||
if (state.graphCanvas?.graph) {
|
||||
try {
|
||||
@@ -317,10 +567,14 @@ app.registerExtension({
|
||||
canvas.className = "litegraph";
|
||||
canvasHost.appendChild(canvas);
|
||||
|
||||
const resizeHandle = document.createElement("div");
|
||||
resizeHandle.className = "dual-panel-subgraph__resize-handle";
|
||||
|
||||
panel.appendChild(header);
|
||||
panel.appendChild(resizeHandle);
|
||||
panel.appendChild(canvasHost);
|
||||
|
||||
return { panel, canvasHost, canvas };
|
||||
return { panel, canvasHost, canvas, resizeHandle };
|
||||
}
|
||||
|
||||
function updatePanelOffset(panel, canvasHost) {
|
||||
@@ -358,10 +612,28 @@ app.registerExtension({
|
||||
|
||||
ensureMainHosts();
|
||||
|
||||
const { panel, canvasHost, canvas } = createPanelElements(node);
|
||||
const { panel, canvasHost, canvas, resizeHandle } = createPanelElements(node);
|
||||
document.body.appendChild(panel);
|
||||
document.body.classList.add(BODY_CLASS);
|
||||
|
||||
const storedWidth = loadStoredPanelWidth();
|
||||
if (storedWidth) {
|
||||
setPanelWidth(storedWidth, { panel, canvasHost, canvas });
|
||||
}
|
||||
|
||||
const stopEventPropagation = (event) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
const subgraphCanvasEvents = ["pointerdown", "pointerup", "dblclick", "contextmenu"];
|
||||
subgraphCanvasEvents.forEach((evtName) => {
|
||||
canvas.addEventListener(evtName, stopEventPropagation);
|
||||
});
|
||||
state.disposers.push(() => {
|
||||
subgraphCanvasEvents.forEach((evtName) => {
|
||||
canvas.removeEventListener(evtName, stopEventPropagation);
|
||||
});
|
||||
});
|
||||
|
||||
const subgraph = node.subgraph;
|
||||
const graphCanvas = new LiteGraph.LGraphCanvas(canvas, null, {
|
||||
skip_render: true,
|
||||
@@ -374,10 +646,33 @@ app.registerExtension({
|
||||
graphCanvas.subgraph = subgraph;
|
||||
graphCanvas.startRendering?.();
|
||||
|
||||
const suppressedEmitEventSubtypes = new Set([
|
||||
"empty-double-click",
|
||||
"group-double-click",
|
||||
"node-double-click"
|
||||
]);
|
||||
const originalEmitEvent = graphCanvas.emitEvent;
|
||||
graphCanvas.emitEvent = function (event, ...args) {
|
||||
const subtype = event?.subType;
|
||||
if (subtype && suppressedEmitEventSubtypes.has(subtype)) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof originalEmitEvent === "function") {
|
||||
return originalEmitEvent.call(this, event, ...args);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
state.disposers.push(() => {
|
||||
graphCanvas.emitEvent = originalEmitEvent;
|
||||
});
|
||||
|
||||
state.panel = panel;
|
||||
state.node = node;
|
||||
state.graphCanvas = graphCanvas;
|
||||
|
||||
setupPanelResizer(panel, canvasHost, canvas, graphCanvas, resizeHandle);
|
||||
pinFloatingToolbars();
|
||||
|
||||
const panelResizeObserver = new ResizeObserver(() => {
|
||||
syncLayoutFromPanel(panel, canvasHost);
|
||||
handleResize(canvas, graphCanvas);
|
||||
|
||||
Reference in New Issue
Block a user