Merge pull request #38 from HappyOnigiri/feat/centralize-portal-roots

feat: centralize portal root management
This commit is contained in:
HappyOnigiri
2026-03-02 03:35:38 +09:00
committed by GitHub
5 changed files with 3901 additions and 3784 deletions
@@ -9,9 +9,10 @@ import {
X,
} from "lucide-react";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import { useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useEscapeToClose } from "../../../hooks/useEscapeToClose";
import { getPortalRoot } from "../../../portals/portalRoots";
import { useGallery } from "../../../store/GalleryContext";
import { useImageActions } from "../../images/hooks/useImageActions";
@@ -26,58 +27,14 @@ export const BulkActionBar: React.FC = () => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [menuAnchorRect, setMenuAnchorRect] = useState<DOMRect | null>(null);
const actionButtonRef = useRef<HTMLButtonElement>(null);
const [portalContainer, setPortalContainer] = useState<HTMLDivElement | null>(
null,
);
// Get or create the portal container to render inside .comfyui-body-bottom
useEffect(() => {
const el = document.getElementById("meld-bulk-bar-portal");
let container = el instanceof HTMLDivElement ? el : null;
if (!container) {
// We must create a portal mount point at runtime because the ComfyUI host HTML cannot be modified by the extension.
container = document.createElement("div");
container.id = "meld-bulk-bar-portal";
container.dataset.mountCount = "0";
// Find .comfyui-body-bottom (same location as LightTable)
const bottomArea = document.querySelector(".comfyui-body-bottom");
if (bottomArea) {
bottomArea.appendChild(container);
} else {
// Fallback
document.body.appendChild(container);
}
}
// Increment usage count
const mountCount = Number.parseInt(container.dataset.mountCount || "0", 10);
container.dataset.mountCount = (mountCount + 1).toString();
setPortalContainer(container);
return () => {
if (container) {
const currentCount = Number.parseInt(
container.dataset.mountCount || "1",
10,
);
const newCount = currentCount - 1;
container.dataset.mountCount = newCount.toString();
if (newCount <= 0) {
container.remove();
}
}
};
}, []);
const portalRoot = getPortalRoot("bulkActionBar");
useEscapeToClose({
onEscape: () => setIsMenuOpen(false),
enabled: isMenuOpen,
});
if (count === 0 || !portalContainer) return null;
if (count === 0) return null;
const isTrashMode = state.viewScope === "trash";
@@ -249,5 +206,5 @@ export const BulkActionBar: React.FC = () => {
</div>
);
return createPortal(bulkBarJSX, portalContainer);
return createPortal(bulkBarJSX, portalRoot);
};
@@ -1,7 +1,8 @@
import { Plus, Trash, X } from "lucide-react";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import { createPortal } from "react-dom";
import { getPortalRoot } from "../../../portals/portalRoots";
import { useGallery } from "../../../store/GalleryContext";
import { useLightTableKeys } from "../hooks/useLightTableKeys";
import { useLightTableStore } from "../store";
@@ -24,37 +25,12 @@ export const LightTable: React.FC = () => {
const setIsOpen = useLightTableStore((s) => s.setIsOpen);
const { state: galleryState } = useGallery();
const [activeTabId, setActiveTabId] = useState(slots[0]?.id || "keep");
const portalContainerRef = useRef<HTMLDivElement | null>(null);
const portalRoot = getPortalRoot("lightTable");
/** Show/hide flag for Clear All confirm modal */
const [showClearAllConfirm, setShowClearAllConfirm] = useState(false);
// Get or create the portal container
useEffect(() => {
let container = document.getElementById(
"meld-light-table-portal",
) as HTMLDivElement | null;
if (!container) {
container = document.createElement("div");
container.id = "meld-light-table-portal";
// Find .comfyui-body-bottom (same location as Image Feed)
const bottomArea = document.querySelector(".comfyui-body-bottom");
if (bottomArea) {
bottomArea.appendChild(container);
} else {
// Fallback: append to document.body
document.body.appendChild(container);
}
}
portalContainerRef.current = container;
return () => {
// Keep the container even if the component is unmounted (reuse on remount)
};
}, []);
if (!isOpen || !portalContainerRef.current) return null;
if (!isOpen) return null;
const handleAddSlot = () => {
const newId = `slot_${Date.now().toString(36)}`;
@@ -186,5 +162,5 @@ export const LightTable: React.FC = () => {
</div>
);
return createPortal(lightTableJSX, portalContainerRef.current);
return createPortal(lightTableJSX, portalRoot);
};
+3
View File
@@ -8,6 +8,7 @@ import { GalleryPanel } from "./features/gallery/components/GalleryPanel";
import * as imagesApi from "./features/images/api/imagesApi";
import * as settingsApi from "./features/settings/api/settingsApi";
import { logger } from "./logger";
import { startPortalRootAutoAttach } from "./portals/portalRoots";
import { GalleryProvider } from "./store/GalleryContext";
import type { ComfyApp } from "./types";
import "./meld_unified_loader_ui";
@@ -69,6 +70,8 @@ app.registerExtension({
},
async setup(app: ComfyApp) {
startPortalRootAutoAttach();
// Initialize logger from server settings
try {
const settings = await settingsApi.fetchSettings();
+142
View File
@@ -0,0 +1,142 @@
export type PortalRootKey = "lightTable" | "bulkActionBar";
type PortalRootDefinition = {
id: string;
preferredParentSelector: string;
};
const portalRootDefinitions: Record<PortalRootKey, PortalRootDefinition> = {
lightTable: {
id: "meld-light-table-portal",
preferredParentSelector: ".comfyui-body-bottom",
},
bulkActionBar: {
id: "meld-bulk-bar-portal",
preferredParentSelector: ".comfyui-body-bottom",
},
};
const portalRootKeys = Object.keys(portalRootDefinitions) as PortalRootKey[];
const portalRoots = new Map<PortalRootKey, HTMLDivElement>();
const PORTAL_ROOT_ATTR = "data-meld-portal-root";
let portalObserver: MutationObserver | null = null;
let scheduledRafId: number | null = null;
/**
* Check if a node list contains any element relevant to portal root attachment:
* - our portal roots (data-meld-portal-root)
* - the preferred parent (.comfyui-body-bottom) or elements containing it
*/
const PREFERRED_PARENT_SELECTOR = ".comfyui-body-bottom";
function nodeListContainsRelevantNode(list: NodeList): boolean {
const portalSelector = `[${PORTAL_ROOT_ATTR}]`;
for (let i = 0; i < list.length; i++) {
const node = list[i];
if (node.nodeType !== Node.ELEMENT_NODE) continue;
const el = node as Element;
if (el.hasAttribute?.(PORTAL_ROOT_ATTR)) return true;
if (el.querySelector?.(portalSelector)) return true;
if (el.matches?.(PREFERRED_PARENT_SELECTOR)) return true;
if (el.querySelector?.(PREFERRED_PARENT_SELECTOR)) return true;
}
return false;
}
function getPreferredParent(selector: string): HTMLElement {
const preferred = document.querySelector(selector);
if (preferred instanceof HTMLElement) {
return preferred;
}
return document.body;
}
function createPortalRoot(key: PortalRootKey): HTMLDivElement {
const definition = portalRootDefinitions[key];
const existing = document.getElementById(definition.id);
if (existing instanceof HTMLDivElement) {
existing.dataset.meldPortalRoot = key;
return existing;
}
const root = document.createElement("div");
root.id = definition.id;
root.dataset.meldPortalRoot = key;
// Requirement: keep global layer ordering in CSS tokens only.
// Do not set root.style.zIndex here.
return root;
}
function ensurePortalRootAttached(key: PortalRootKey): HTMLDivElement {
const definition = portalRootDefinitions[key];
const root = portalRoots.get(key) ?? createPortalRoot(key);
portalRoots.set(key, root);
const parent = getPreferredParent(definition.preferredParentSelector);
if (root.parentElement !== parent || !document.contains(root)) {
parent.appendChild(root);
}
return root;
}
function ensureAllPortalRootsAttached(): void {
for (const key of portalRootKeys) {
ensurePortalRootAttached(key);
}
}
function scheduleEnsureAllPortalRootsAttached(): void {
if (scheduledRafId !== null) {
return;
}
scheduledRafId = requestAnimationFrame(() => {
scheduledRafId = null;
ensureAllPortalRootsAttached();
});
}
export function getPortalRoot(key: PortalRootKey): HTMLDivElement {
return ensurePortalRootAttached(key);
}
export function startPortalRootAutoAttach(): void {
if (portalObserver || typeof document === "undefined" || !document.body) {
return;
}
ensureAllPortalRootsAttached();
portalObserver = new MutationObserver((mutations: MutationRecord[]) => {
for (const m of mutations) {
if (nodeListContainsRelevantNode(m.addedNodes)) {
scheduleEnsureAllPortalRootsAttached();
return;
}
if (nodeListContainsRelevantNode(m.removedNodes)) {
scheduleEnsureAllPortalRootsAttached();
return;
}
}
});
portalObserver.observe(document.body, {
childList: true,
subtree: true,
});
}
export function stopPortalRootAutoAttach(): void {
if (portalObserver) {
portalObserver.disconnect();
portalObserver = null;
}
if (scheduledRafId !== null) {
cancelAnimationFrame(scheduledRafId);
scheduledRafId = null;
}
}
+3746 -3707
View File
File diff suppressed because it is too large Load Diff