Merge pull request #38 from HappyOnigiri/feat/centralize-portal-roots
feat: centralize portal root management
This commit is contained in:
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+3746
-3707
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user