Files
Artificial-Sweetener-Simple…/web/src/nativePreviewNavigator.ts
T

164 lines
5.4 KiB
TypeScript

// SimpleSyrup - workflow-focused ComfyUI extensions for image generation
// Copyright (C) 2026 Artificial Sweetener and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
import type { ComfyApp, ComfyImageResult } from "./types";
import {
comfyImageReferenceKey,
comfyImageSourceKey
} from "./comfyImageReference";
import { subscribeNativePreviewLifecycle } from "./nativePreviewLifecycle";
interface PreviewNavigationNode {
readonly id?: string | number;
imageIndex?: number | null;
imageRects?: readonly unknown[];
imgs?: readonly HTMLImageElement[];
pointerDown?: { index: number | null; pos: [number, number] };
graph?: { setDirtyCanvas?: (foreground: boolean, background: boolean) => void };
}
/** Open one output through Comfy's native image inspection behavior. */
export class NativePreviewNavigator {
private pendingReference: ComfyImageResult | undefined;
private legacyInspectionFrame: number | undefined;
private unsubscribeLifecycle: (() => void) | undefined;
constructor(
private readonly app: ComfyApp,
private readonly node: PreviewNavigationNode
) {}
/** Report whether Comfy currently owns a DOM-backed preview surface. */
usesDomPreview(): boolean {
return this.domRoot() !== null;
}
/** Inspect one zero-based native preview item in either node renderer. */
inspect(index: number): void {
if (!Number.isInteger(index) || index < 0) return;
if (this.domRoot()) {
const images = this.images();
if (!images || index >= images.length) return;
const reference = images[index] as ComfyImageResult;
if (this.clickDomImage(reference)) return;
this.pendingReference = reference;
this.unsubscribeLifecycle ??= subscribeNativePreviewLifecycle(() => {
this.completePendingInspection();
});
return;
}
this.scheduleLegacyImage(index);
}
/** Wait for Comfy to restore canvas images before selecting native detail. */
private scheduleLegacyImage(index: number): void {
this.cancelLegacyInspection();
const inspectWhenReady = (): void => {
const images = this.node.imgs;
if (images !== undefined && images.length > index) {
this.legacyInspectionFrame = undefined;
this.openLegacyImage(index);
return;
}
this.legacyInspectionFrame = requestAnimationFrame(inspectWhenReady);
};
this.legacyInspectionFrame = requestAnimationFrame(inspectWhenReady);
}
/** Open one loaded image through Comfy's canvas detail state. */
private openLegacyImage(index: number): void {
this.node.imageIndex = index;
const position = this.app.canvas?.graph_mouse;
if (position) {
this.node.pointerDown = { index, pos: [position[0], position[1]] };
}
delete this.node.imageRects;
this.node.graph?.setDirtyCanvas?.(true, true);
}
/** Return the native preview to its one authoritative gallery. */
showGrid(): void {
this.cancelLegacyInspection();
this.pendingReference = undefined;
if (this.clickDomGrid()) return;
this.node.imageIndex = null;
delete this.node.imageRects;
this.node.graph?.setDirtyCanvas?.(true, true);
}
/** Cancel pending navigation when native images are hidden. */
hide(): void {
this.cancelLegacyInspection();
this.pendingReference = undefined;
this.node.imageIndex = null;
delete this.node.imageRects;
this.node.graph?.setDirtyCanvas?.(true, true);
}
/** Release native-preview lifecycle observation. */
dispose(): void {
this.cancelLegacyInspection();
this.pendingReference = undefined;
this.unsubscribeLifecycle?.();
this.unsubscribeLifecycle = undefined;
}
private cancelLegacyInspection(): void {
if (this.legacyInspectionFrame === undefined) return;
cancelAnimationFrame(this.legacyInspectionFrame);
this.legacyInspectionFrame = undefined;
}
private completePendingInspection(): void {
const reference = this.pendingReference;
if (!reference || !this.clickDomImage(reference)) return;
this.pendingReference = undefined;
}
private clickDomImage(reference: ComfyImageResult): boolean {
const root = this.domRoot();
if (!root) return false;
const image = Array.from(root.querySelectorAll<HTMLImageElement>("img")).find(
(candidate) =>
comfyImageSourceKey(candidate.src) === comfyImageReferenceKey(reference)
);
const button = image?.closest("button");
if (!button) return false;
button.click();
return true;
}
private clickDomGrid(): boolean {
const root = this.domRoot();
if (!root) return false;
const button = Array.from(root.querySelectorAll<HTMLButtonElement>("button")).find(
(candidate) =>
candidate.getAttribute("aria-label") === "Grid view" ||
candidate.title === "Grid view"
);
if (!button) return false;
button.click();
return true;
}
private domRoot(): HTMLElement | null {
const nodeId = this.nodeId();
if (!nodeId) return null;
return (
Array.from(document.querySelectorAll<HTMLElement>("[data-node-id]")).find(
(element) => element.dataset.nodeId === nodeId
) ?? null
);
}
private images(): ComfyImageResult[] | undefined {
const nodeId = this.nodeId();
return nodeId ? this.app.nodeOutputs?.[nodeId]?.images : undefined;
}
private nodeId(): string | undefined {
return this.node.id === undefined ? undefined : String(this.node.id);
}
}