fix(media): stabilize native ordered preview controls
This commit is contained in:
Vendored
+461
-80
@@ -561,15 +561,19 @@ var NativeNodePreview = class {
|
||||
publishListeners = /* @__PURE__ */ new Set();
|
||||
/** Publish media as an execution-shaped output for both node renderers. */
|
||||
publish(output) {
|
||||
const nodeId = this.nodeId();
|
||||
if (!nodeId) return;
|
||||
const localNodeId = this.localNodeId();
|
||||
if (!localNodeId) return;
|
||||
const outputLocator = this.outputLocator(localNodeId);
|
||||
this.app.nodeOutputs ??= {};
|
||||
this.app.nodeOutputs[nodeId] = output;
|
||||
this.api.dispatchEvent(
|
||||
new CustomEvent("executed", {
|
||||
detail: { node: nodeId, display_node: nodeId, output }
|
||||
})
|
||||
);
|
||||
this.app.nodeOutputs[localNodeId] = output;
|
||||
this.app.nodeOutputs[outputLocator] = output;
|
||||
for (const executionId of this.executionIds(localNodeId)) {
|
||||
this.api.dispatchEvent(
|
||||
new CustomEvent("executed", {
|
||||
detail: { node: executionId, display_node: executionId, output }
|
||||
})
|
||||
);
|
||||
}
|
||||
for (const listener of this.publishListeners) listener();
|
||||
}
|
||||
/** Notify renderer adapters after native output changes. */
|
||||
@@ -581,11 +585,51 @@ var NativeNodePreview = class {
|
||||
clear() {
|
||||
this.publish({ images: [], animated: [] });
|
||||
}
|
||||
nodeId() {
|
||||
localNodeId() {
|
||||
if (this.node.id === void 0) return void 0;
|
||||
return String(this.node.id);
|
||||
}
|
||||
/** Match the output key selected by Comfy's Nodes 2.0 subgraph renderer. */
|
||||
outputLocator(localNodeId) {
|
||||
const graphId = this.node.graph?.id;
|
||||
const isSubgraph = this.node.graph && this.node.graph !== this.app.rootGraph;
|
||||
return isSubgraph && graphId !== void 0 ? `${String(graphId)}:${localNodeId}` : localNodeId;
|
||||
}
|
||||
/** Resolve instance paths because Comfy events consume execution IDs, not graph locators. */
|
||||
executionIds(localNodeId) {
|
||||
const graph = this.node.graph;
|
||||
const rootGraph = this.app.rootGraph;
|
||||
if (!graph || !rootGraph || graph === rootGraph || graph.isRootGraph) {
|
||||
return [localNodeId];
|
||||
}
|
||||
const parentPaths = findGraphInstancePaths(rootGraph, graph);
|
||||
return parentPaths.length > 0 ? parentPaths.map((path) => `${path}:${localNodeId}`) : [];
|
||||
}
|
||||
};
|
||||
function findGraphInstancePaths(root, target, visited = /* @__PURE__ */ new Set()) {
|
||||
if (visited.has(root)) return [];
|
||||
const nextVisited = new Set(visited);
|
||||
nextVisited.add(root);
|
||||
const paths = [];
|
||||
for (const node of root.nodes ?? []) {
|
||||
const nodeId = graphNodeId(node);
|
||||
if (!nodeId || !node.subgraph) continue;
|
||||
if (node.subgraph === target) paths.push(nodeId);
|
||||
for (const nestedPath of findGraphInstancePaths(
|
||||
node.subgraph,
|
||||
target,
|
||||
nextVisited
|
||||
)) {
|
||||
paths.push(`${nodeId}:${nestedPath}`);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
function graphNodeId(node) {
|
||||
if (node.id === void 0) return void 0;
|
||||
const identity = String(node.id);
|
||||
return identity.length > 0 ? identity : void 0;
|
||||
}
|
||||
|
||||
// web/src/orderedMediaPreview.ts
|
||||
var OrderedMediaPreviewController = class {
|
||||
@@ -663,7 +707,7 @@ function ensureObserver() {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["src"]
|
||||
attributeFilter: ["aria-current", "src"]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -678,15 +722,19 @@ var OrderedMediaPreviewAffordances = class {
|
||||
window.addEventListener("resize", this.requestRefresh, true);
|
||||
document.addEventListener("click", this.requestRefresh, true);
|
||||
document.addEventListener("keydown", this.requestRefresh, true);
|
||||
document.addEventListener("pointerdown", this.suspendViewportControls, true);
|
||||
document.addEventListener("pointermove", this.requestRefresh, true);
|
||||
document.addEventListener("pointerup", this.requestRefresh, true);
|
||||
document.addEventListener("pointerup", this.resumeViewportControls, true);
|
||||
document.addEventListener("pointercancel", this.resumeViewportControls, true);
|
||||
document.addEventListener("wheel", this.requestRefresh, true);
|
||||
this.refresh();
|
||||
}
|
||||
options;
|
||||
elements = [];
|
||||
positionedContainers = /* @__PURE__ */ new Map();
|
||||
animationFrame = null;
|
||||
remainingSyncFrames = 0;
|
||||
viewportControlsSuspended = false;
|
||||
/** Recheck native layout across several frames after Comfy rerenders output. */
|
||||
refresh() {
|
||||
this.remainingSyncFrames = Math.max(this.remainingSyncFrames, 4);
|
||||
@@ -697,8 +745,10 @@ var OrderedMediaPreviewAffordances = class {
|
||||
window.removeEventListener("resize", this.requestRefresh, true);
|
||||
document.removeEventListener("click", this.requestRefresh, true);
|
||||
document.removeEventListener("keydown", this.requestRefresh, true);
|
||||
document.removeEventListener("pointerdown", this.suspendViewportControls, true);
|
||||
document.removeEventListener("pointermove", this.requestRefresh, true);
|
||||
document.removeEventListener("pointerup", this.requestRefresh, true);
|
||||
document.removeEventListener("pointerup", this.resumeViewportControls, true);
|
||||
document.removeEventListener("pointercancel", this.resumeViewportControls, true);
|
||||
document.removeEventListener("wheel", this.requestRefresh, true);
|
||||
if (this.animationFrame !== null) {
|
||||
cancelAnimationFrame(this.animationFrame);
|
||||
@@ -706,11 +756,24 @@ var OrderedMediaPreviewAffordances = class {
|
||||
}
|
||||
for (const element of this.elements) element.root.remove();
|
||||
this.elements = [];
|
||||
this.releasePositionedContainers(/* @__PURE__ */ new Set());
|
||||
}
|
||||
requestRefresh = () => {
|
||||
this.remainingSyncFrames = Math.max(this.remainingSyncFrames, 2);
|
||||
this.scheduleSync();
|
||||
};
|
||||
suspendViewportControls = (event) => {
|
||||
if (!(event.target instanceof HTMLCanvasElement)) return;
|
||||
this.viewportControlsSuspended = true;
|
||||
for (const elements of this.elements) {
|
||||
if (elements.root.parentElement === document.body) elements.root.hidden = true;
|
||||
}
|
||||
};
|
||||
resumeViewportControls = () => {
|
||||
if (!this.viewportControlsSuspended) return;
|
||||
this.viewportControlsSuspended = false;
|
||||
this.refresh();
|
||||
};
|
||||
scheduleSync() {
|
||||
if (this.animationFrame !== null) return;
|
||||
this.animationFrame = requestAnimationFrame(() => {
|
||||
@@ -722,43 +785,88 @@ var OrderedMediaPreviewAffordances = class {
|
||||
}
|
||||
sync() {
|
||||
const slots = this.options.getSlots();
|
||||
const activeContainers = /* @__PURE__ */ new Set();
|
||||
this.resizeElements(slots.length);
|
||||
for (const [index, elements] of this.elements.entries()) {
|
||||
const slot = slots[index];
|
||||
if (!slot || slot.width <= 0 || slot.height <= 0) {
|
||||
const actionSlot = slots[index];
|
||||
const slot = actionSlot?.bounds;
|
||||
if (!actionSlot || !slot || slot.width <= 0 || slot.height <= 0 || !actionSlot.container && this.viewportControlsSuspended) {
|
||||
elements.root.hidden = true;
|
||||
continue;
|
||||
}
|
||||
const itemIndex = actionSlot.itemIndex;
|
||||
elements.root.hidden = false;
|
||||
const stripHeight = Math.max(18, Math.min(26, slot.height * 0.16));
|
||||
const position = this.mount(elements.root, actionSlot, activeContainers);
|
||||
const stripHeight = Math.max(18, Math.min(26, position.height * 0.16));
|
||||
Object.assign(elements.root.style, {
|
||||
left: `${String(slot.left)}px`,
|
||||
top: `${String(slot.top)}px`,
|
||||
width: `${String(slot.width)}px`,
|
||||
left: `${String(position.left)}px`,
|
||||
top: `${String(position.top)}px`,
|
||||
width: `${String(position.width)}px`,
|
||||
height: `${String(stripHeight)}px`
|
||||
});
|
||||
elements.root.dataset.ssMediaIndex = String(index);
|
||||
elements.earlier.dataset.ssMediaIndex = String(index);
|
||||
elements.later.dataset.ssMediaIndex = String(index);
|
||||
elements.remove.dataset.ssMediaIndex = String(index);
|
||||
setActionAvailability(elements.earlier, index > 0);
|
||||
elements.root.dataset.ssMediaIndex = String(itemIndex);
|
||||
elements.earlier.dataset.ssMediaIndex = String(itemIndex);
|
||||
elements.later.dataset.ssMediaIndex = String(itemIndex);
|
||||
elements.remove.dataset.ssMediaIndex = String(itemIndex);
|
||||
setActionAvailability(elements.earlier, itemIndex > 0);
|
||||
setActionAvailability(
|
||||
elements.later,
|
||||
index < this.elements.length - 1
|
||||
itemIndex < this.options.getItemCount() - 1
|
||||
);
|
||||
elements.earlier.setAttribute(
|
||||
"aria-label",
|
||||
`Move ${this.options.itemLabel} ${String(index + 1)} earlier`
|
||||
`Move ${this.options.itemLabel} ${String(itemIndex + 1)} earlier`
|
||||
);
|
||||
elements.later.setAttribute(
|
||||
"aria-label",
|
||||
`Move ${this.options.itemLabel} ${String(index + 1)} later`
|
||||
`Move ${this.options.itemLabel} ${String(itemIndex + 1)} later`
|
||||
);
|
||||
elements.remove.setAttribute(
|
||||
"aria-label",
|
||||
`Remove ${this.options.itemLabel} ${String(index + 1)}`
|
||||
`Remove ${this.options.itemLabel} ${String(itemIndex + 1)}`
|
||||
);
|
||||
}
|
||||
this.releasePositionedContainers(activeContainers);
|
||||
}
|
||||
/** Mount one control strip in the preview surface that owns its geometry. */
|
||||
mount(root, actionSlot, activeContainers) {
|
||||
const container = actionSlot.container;
|
||||
if (!container) {
|
||||
if (root.parentElement !== document.body) document.body.append(root);
|
||||
root.style.position = "fixed";
|
||||
root.style.zIndex = "2";
|
||||
return actionSlot.bounds;
|
||||
}
|
||||
activeContainers.add(container);
|
||||
this.positionContainer(container);
|
||||
if (root.parentElement !== container) container.append(root);
|
||||
root.style.position = "absolute";
|
||||
root.style.zIndex = "1";
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const scaleX = containerScale(containerRect.width, container.offsetWidth);
|
||||
const scaleY = containerScale(containerRect.height, container.offsetHeight);
|
||||
return {
|
||||
left: (actionSlot.bounds.left - containerRect.left) / scaleX - container.clientLeft + container.scrollLeft,
|
||||
top: (actionSlot.bounds.top - containerRect.top) / scaleY - container.clientTop + container.scrollTop,
|
||||
width: actionSlot.bounds.width / scaleX,
|
||||
height: actionSlot.bounds.height / scaleY
|
||||
};
|
||||
}
|
||||
/** Establish a local containing block without overriding authored positioning. */
|
||||
positionContainer(container) {
|
||||
if (this.positionedContainers.has(container)) return;
|
||||
const position = getComputedStyle(container).position;
|
||||
if (position !== "" && position !== "static") return;
|
||||
this.positionedContainers.set(container, container.style.position);
|
||||
container.style.position = "relative";
|
||||
}
|
||||
/** Restore preview surfaces that no longer contain loader controls. */
|
||||
releasePositionedContainers(activeContainers) {
|
||||
for (const [container, originalPosition] of this.positionedContainers) {
|
||||
if (activeContainers.has(container)) continue;
|
||||
container.style.position = originalPosition;
|
||||
this.positionedContainers.delete(container);
|
||||
}
|
||||
}
|
||||
resizeElements(count) {
|
||||
while (this.elements.length > count) this.elements.pop()?.root.remove();
|
||||
@@ -771,14 +879,12 @@ var OrderedMediaPreviewAffordances = class {
|
||||
root.className = "ss-native-preview-affordance";
|
||||
Object.assign(root.style, {
|
||||
position: "fixed",
|
||||
zIndex: "9990",
|
||||
zIndex: "2",
|
||||
display: "flex",
|
||||
alignItems: "stretch",
|
||||
pointerEvents: "none",
|
||||
overflow: "hidden",
|
||||
borderRadius: "4px 4px 0 0",
|
||||
background: "rgba(20, 20, 20, 0.72)",
|
||||
boxShadow: "inset 0 -1px 0 rgba(255, 255, 255, 0.16)"
|
||||
background: "rgba(20, 20, 20, 0.72)"
|
||||
});
|
||||
const earlier = controlButton("pi-arrow-left", "Move earlier");
|
||||
earlier.setAttribute(MEDIA_MOVE_EARLIER_ATTRIBUTE, "true");
|
||||
@@ -847,6 +953,11 @@ function stopControlPointerEvent(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
function containerScale(renderedSize, localSize) {
|
||||
if (renderedSize <= 0 || localSize <= 0) return 1;
|
||||
const scale = renderedSize / localSize;
|
||||
return Number.isFinite(scale) && scale > 0 ? scale : 1;
|
||||
}
|
||||
|
||||
// web/src/orderedMediaPreviewTransaction.ts
|
||||
var OrderedMediaPreviewTransaction = class {
|
||||
@@ -951,6 +1062,9 @@ function loadedImagesMatchReferences(images, references) {
|
||||
}
|
||||
|
||||
// web/src/orderedMediaPreviewActions.ts
|
||||
var CUBE_FACE_PROJECTION_SYMBOL = /* @__PURE__ */ Symbol.for(
|
||||
"sugarcubes.cube-face-projection.v1"
|
||||
);
|
||||
var OrderedMediaPreviewActions = class {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
@@ -961,25 +1075,39 @@ var OrderedMediaPreviewActions = class {
|
||||
this.affordances.refresh();
|
||||
}
|
||||
});
|
||||
const moveEarlier = (index) => {
|
||||
const destination = index - 1;
|
||||
this.transaction.move(index, destination);
|
||||
this.followMovedDetail(index, destination);
|
||||
options.moveEarlier(index);
|
||||
};
|
||||
const moveLater = (index) => {
|
||||
const destination = index + 1;
|
||||
this.transaction.move(index, destination);
|
||||
this.followMovedDetail(index, destination);
|
||||
options.moveLater(index);
|
||||
};
|
||||
const remove = (index) => {
|
||||
const removedDetail = this.selectedItemIndex() === index;
|
||||
this.transaction.remove(index);
|
||||
options.remove(index);
|
||||
this.followRemovedDetail(index, removedDetail);
|
||||
};
|
||||
const actionOptions = {
|
||||
getItemCount: () => this.itemCount(),
|
||||
moveEarlier,
|
||||
moveLater,
|
||||
remove
|
||||
};
|
||||
this.affordances = new OrderedMediaPreviewAffordances({
|
||||
itemLabel: options.itemLabel,
|
||||
getSlots: () => this.nativeSlots(),
|
||||
moveEarlier: (index) => {
|
||||
this.transaction.move(index, index - 1);
|
||||
options.moveEarlier(index);
|
||||
},
|
||||
moveLater: (index) => {
|
||||
this.transaction.move(index, index + 1);
|
||||
options.moveLater(index);
|
||||
},
|
||||
remove: (index) => {
|
||||
this.transaction.remove(index);
|
||||
options.remove(index);
|
||||
}
|
||||
getSlots: () => this.nativeActionSlots(),
|
||||
...actionOptions
|
||||
});
|
||||
this.unsubscribePreview = options.preview.subscribe(() => {
|
||||
this.affordances.refresh();
|
||||
this.transaction.authoritativePublished();
|
||||
this.restorePendingDetail();
|
||||
});
|
||||
this.unsubscribeLifecycle = subscribeNativePreviewLifecycle(() => {
|
||||
this.affordances.refresh();
|
||||
@@ -990,12 +1118,19 @@ var OrderedMediaPreviewActions = class {
|
||||
transaction;
|
||||
unsubscribePreview;
|
||||
unsubscribeLifecycle;
|
||||
lastCanvasPreviewRect = null;
|
||||
pendingDetailIndex = null;
|
||||
detailRestoreFrame = null;
|
||||
/** Remove layout listeners and every loader-owned overlay control. */
|
||||
dispose() {
|
||||
this.unsubscribePreview();
|
||||
this.unsubscribeLifecycle();
|
||||
this.transaction.dispose();
|
||||
this.affordances.dispose();
|
||||
if (this.detailRestoreFrame !== null) {
|
||||
cancelAnimationFrame(this.detailRestoreFrame);
|
||||
this.detailRestoreFrame = null;
|
||||
}
|
||||
}
|
||||
itemCount() {
|
||||
const files = this.options.getFiles();
|
||||
@@ -1031,36 +1166,35 @@ var OrderedMediaPreviewActions = class {
|
||||
) ?? null;
|
||||
}
|
||||
canvasSlots() {
|
||||
const canvasApi = this.options.app.canvas;
|
||||
const nodePosition = this.options.node.pos;
|
||||
const imageRects = this.options.node.imageRects;
|
||||
if (!canvasApi || !nodePosition || !imageRects) return [];
|
||||
const canvasRect = canvasApi.canvas.getBoundingClientRect();
|
||||
return imageRects.map(([x, y, width, height]) => {
|
||||
const topLeft = canvasApi.convertOffsetToCanvas([
|
||||
nodePosition[0] + x,
|
||||
nodePosition[1] + y
|
||||
]);
|
||||
const bottomRight = canvasApi.convertOffsetToCanvas([
|
||||
nodePosition[0] + x + width,
|
||||
nodePosition[1] + y + height
|
||||
]);
|
||||
return {
|
||||
left: canvasRect.left + topLeft[0],
|
||||
top: canvasRect.top + topLeft[1],
|
||||
width: bottomRight[0] - topLeft[0],
|
||||
height: bottomRight[1] - topLeft[1]
|
||||
};
|
||||
});
|
||||
if (!imageRects) return [];
|
||||
if (imageRects.length > 0) {
|
||||
this.lastCanvasPreviewRect = unionImageRects(imageRects);
|
||||
}
|
||||
const slots = [];
|
||||
for (const rect of imageRects) {
|
||||
const slot = this.canvasLocalSlot(rect);
|
||||
if (slot) slots.push(slot);
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
nativeSlots() {
|
||||
if (this.options.node.flags?.collapsed || this.options.node.imageIndex != null) {
|
||||
return [];
|
||||
nativeActionSlots() {
|
||||
if (!this.belongsToActiveWorkflow()) return [];
|
||||
if (this.options.node.flags?.collapsed) return [];
|
||||
const selectedIndex = this.selectedItemIndex();
|
||||
if (selectedIndex !== null) {
|
||||
return this.detailActionSlots(selectedIndex);
|
||||
}
|
||||
const activeSlots = this.transaction.activeSlots();
|
||||
if (activeSlots) return activeSlots;
|
||||
const domImages = this.domImages();
|
||||
if (activeSlots) {
|
||||
return indexedSlots(
|
||||
activeSlots,
|
||||
this.domPreviewContainer(domImages) ?? this.cubeFaceProjection()?.container ?? null
|
||||
);
|
||||
}
|
||||
if (!this.isGridVisible()) return [];
|
||||
const domSlots = this.domImages().map((image) => {
|
||||
const domSlots = domImages.map((image) => {
|
||||
const rect = image.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left,
|
||||
@@ -1069,7 +1203,146 @@ var OrderedMediaPreviewActions = class {
|
||||
height: rect.height
|
||||
};
|
||||
});
|
||||
return domSlots.length === this.itemCount() ? domSlots : this.canvasSlots();
|
||||
if (domSlots.length === this.itemCount()) {
|
||||
return indexedSlots(domSlots, this.domPreviewContainer(domImages));
|
||||
}
|
||||
return indexedSlots(
|
||||
this.canvasSlots(),
|
||||
this.cubeFaceProjection()?.container ?? null
|
||||
);
|
||||
}
|
||||
/** Prevent inactive workflow tabs with reused node IDs from claiming active Nodes 2 DOM. */
|
||||
belongsToActiveWorkflow() {
|
||||
const nodeGraph = this.options.node.graph;
|
||||
const nodeRoot = nodeGraph?._rootGraph ?? nodeGraph;
|
||||
const activeRoot = this.options.app.rootGraph ?? this.options.app.canvas?.graph;
|
||||
return !nodeRoot || !activeRoot || nodeRoot === activeRoot;
|
||||
}
|
||||
/** Expose one selected-item control strip in native detail mode. */
|
||||
detailActionSlots(selectedIndex) {
|
||||
const domSlot = this.domDetailSlot(selectedIndex);
|
||||
if (domSlot) return [domSlot];
|
||||
const canvasSlot = this.canvasDetailSlot();
|
||||
if (!canvasSlot) return [];
|
||||
const container = this.cubeFaceProjection()?.container;
|
||||
return [
|
||||
container ? { itemIndex: selectedIndex, bounds: canvasSlot, container } : { itemIndex: selectedIndex, bounds: canvasSlot }
|
||||
];
|
||||
}
|
||||
/** Locate the selected Nodes 2.0 preview without replacing native UI. */
|
||||
domDetailSlot(selectedIndex) {
|
||||
const root = this.domRoot();
|
||||
const reference = this.currentImages()?.[selectedIndex];
|
||||
if (!root || !reference) return null;
|
||||
const expectedKey = comfyImageReferenceKey(reference);
|
||||
const candidates = Array.from(
|
||||
root.querySelectorAll("img")
|
||||
).filter((image2) => {
|
||||
const rect = image2.getBoundingClientRect();
|
||||
return comfyImageSourceKey(image2.src) === expectedKey && rect.width > 0 && rect.height > 0;
|
||||
});
|
||||
const image = candidates.reduce(
|
||||
(largest, candidate) => !largest || imageArea(candidate) > imageArea(largest) ? candidate : largest,
|
||||
null
|
||||
);
|
||||
const previewRegion = root.querySelector(
|
||||
'[role="region"][aria-label^="Image preview"]'
|
||||
);
|
||||
const previewElement = image ?? previewRegion;
|
||||
if (!previewElement) return null;
|
||||
return {
|
||||
itemIndex: selectedIndex,
|
||||
bounds: elementSlot(previewElement),
|
||||
container: previewRegion ?? this.domPreviewContainer(image ? [image] : []) ?? root
|
||||
};
|
||||
}
|
||||
/** Return the closest native preview surface shared by rendered media. */
|
||||
domPreviewContainer(images) {
|
||||
const root = this.domRoot();
|
||||
if (!root || images.length === 0) return null;
|
||||
const previewRegion = root.querySelector(
|
||||
'[role="region"][aria-label^="Image preview"]'
|
||||
);
|
||||
if (previewRegion && images.every((image) => previewRegion.contains(image))) {
|
||||
return previewRegion;
|
||||
}
|
||||
let candidate = images[0]?.parentElement ?? null;
|
||||
while (candidate && candidate !== root) {
|
||||
if (images.every((image) => candidate?.contains(image) === true)) {
|
||||
return candidate;
|
||||
}
|
||||
candidate = candidate.parentElement;
|
||||
}
|
||||
return root;
|
||||
}
|
||||
/** Resolve selected preview geometry in node-local canvas coordinates. */
|
||||
canvasDetailLocalSlot() {
|
||||
const previewWidget = this.options.node.widgets?.find(
|
||||
(widget) => widget.options?.canvasOnly === true && typeof widget.y === "number" && typeof widget.computedHeight === "number" && widget.computedHeight > 0
|
||||
);
|
||||
const nodeWidth = this.options.node.size?.[0];
|
||||
const previewY = previewWidget?.y;
|
||||
const previewHeight = previewWidget?.computedHeight;
|
||||
if (typeof nodeWidth !== "number" || nodeWidth <= 0 || typeof previewY !== "number" || typeof previewHeight !== "number") {
|
||||
if (!this.lastCanvasPreviewRect) return null;
|
||||
const [left, top, width, height] = this.lastCanvasPreviewRect;
|
||||
return { left, top, width, height };
|
||||
}
|
||||
return { left: 0, top: previewY, width: nodeWidth, height: previewHeight };
|
||||
}
|
||||
/** Locate the selected Nodes 1.0 preview from its canvas widget geometry. */
|
||||
canvasDetailSlot() {
|
||||
const localSlot = this.canvasDetailLocalSlot();
|
||||
return localSlot ? this.canvasLocalSlot([
|
||||
localSlot.left,
|
||||
localSlot.top,
|
||||
localSlot.width,
|
||||
localSlot.height
|
||||
]) : null;
|
||||
}
|
||||
/** Project one node-local canvas rectangle into viewport coordinates. */
|
||||
canvasLocalSlot(rect) {
|
||||
const projection = this.cubeFaceProjection();
|
||||
if (projection) {
|
||||
try {
|
||||
const slot = projection.projectRect(rect);
|
||||
if (validSlot(slot)) return slot;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const canvasApi = this.options.app.canvas;
|
||||
const nodePosition = this.options.node.pos;
|
||||
if (!canvasApi || !nodePosition) return null;
|
||||
const [x, y, width, height] = rect;
|
||||
const canvasRect = canvasApi.canvas.getBoundingClientRect();
|
||||
const topLeft = canvasApi.convertOffsetToCanvas([
|
||||
nodePosition[0] + x,
|
||||
nodePosition[1] + y
|
||||
]);
|
||||
const bottomRight = canvasApi.convertOffsetToCanvas([
|
||||
nodePosition[0] + x + width,
|
||||
nodePosition[1] + y + height
|
||||
]);
|
||||
return {
|
||||
left: canvasRect.left + topLeft[0],
|
||||
top: canvasRect.top + topLeft[1],
|
||||
width: bottomRight[0] - topLeft[0],
|
||||
height: bottomRight[1] - topLeft[1]
|
||||
};
|
||||
}
|
||||
/** Read SugarCubes' renderer-neutral projection contract when this node is embedded. */
|
||||
cubeFaceProjection() {
|
||||
const value = Reflect.get(
|
||||
this.options.node,
|
||||
CUBE_FACE_PROJECTION_SYMBOL
|
||||
);
|
||||
if (typeof value !== "object" || value === null) return null;
|
||||
const candidate = value;
|
||||
if (!(candidate.container instanceof HTMLElement) || typeof candidate.projectRect !== "function") {
|
||||
return null;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
captureSurface() {
|
||||
const domImages = this.domImages();
|
||||
@@ -1080,7 +1353,9 @@ var OrderedMediaPreviewActions = class {
|
||||
}
|
||||
return {
|
||||
items: previewItems(canvasImages),
|
||||
slots: canvasImages.map((_, index) => () => this.canvasSlots()[index]),
|
||||
slots: canvasImages.map(
|
||||
(_, index) => () => this.canvasSlots()[index]
|
||||
),
|
||||
apply: (items) => {
|
||||
this.options.node.imgs = items.map((item) => item.image);
|
||||
delete this.options.node.imageRects;
|
||||
@@ -1136,6 +1411,84 @@ var OrderedMediaPreviewActions = class {
|
||||
isGridVisible() {
|
||||
return !this.options.node.flags?.collapsed && this.options.node.imageIndex == null && this.itemCount() > 1;
|
||||
}
|
||||
/** Read detail selection from the renderer that currently owns it. */
|
||||
selectedItemIndex() {
|
||||
const index = this.options.node.imageIndex;
|
||||
if (typeof index === "number" && Number.isInteger(index) && index >= 0 && index < this.itemCount()) {
|
||||
return index;
|
||||
}
|
||||
const currentButton = this.domDetailButtons().find(
|
||||
(button) => button.getAttribute("aria-current") === "true"
|
||||
);
|
||||
if (!currentButton) return null;
|
||||
const detailIndex = this.domDetailButtons().indexOf(currentButton);
|
||||
return detailIndex >= 0 && detailIndex < this.itemCount() ? detailIndex : null;
|
||||
}
|
||||
/** Keep the moved item selected across both native renderer state models. */
|
||||
followMovedDetail(index, destination) {
|
||||
if (this.selectedItemIndex() !== index) return;
|
||||
this.pendingDetailIndex = destination;
|
||||
if (this.options.node.imageIndex === index) {
|
||||
this.options.node.imageIndex = destination;
|
||||
} else {
|
||||
this.domDetailButtons()[destination]?.click();
|
||||
}
|
||||
this.affordances.refresh();
|
||||
}
|
||||
/** Select the nearest remaining item after removing an inspected item. */
|
||||
followRemovedDetail(index, removedDetail) {
|
||||
if (!removedDetail) return;
|
||||
const remaining = this.itemCount();
|
||||
const destination = remaining > 0 ? Math.min(index, remaining - 1) : null;
|
||||
this.pendingDetailIndex = destination;
|
||||
if (this.options.node.imageIndex === index) {
|
||||
this.options.node.imageIndex = destination;
|
||||
} else if (destination !== null) {
|
||||
this.domDetailButtons()[destination]?.click();
|
||||
}
|
||||
this.affordances.refresh();
|
||||
}
|
||||
/** Re-enter Nodes 2.0 detail mode after output publication resets its grid. */
|
||||
restorePendingDetail() {
|
||||
if (this.pendingDetailIndex === null || this.detailRestoreFrame !== null) {
|
||||
return;
|
||||
}
|
||||
let stableFrames = 0;
|
||||
let remainingFrames = 12;
|
||||
const restore = () => {
|
||||
this.detailRestoreFrame = null;
|
||||
const destination = this.pendingDetailIndex;
|
||||
if (destination === null) return;
|
||||
const buttons = this.domDetailButtons();
|
||||
const selected = buttons.findIndex(
|
||||
(button) => button.getAttribute("aria-current") === "true"
|
||||
);
|
||||
if (selected === destination) {
|
||||
stableFrames += 1;
|
||||
} else {
|
||||
stableFrames = 0;
|
||||
buttons[destination]?.click();
|
||||
}
|
||||
remainingFrames -= 1;
|
||||
if (stableFrames >= 3 || remainingFrames <= 0) {
|
||||
this.pendingDetailIndex = null;
|
||||
this.affordances.refresh();
|
||||
return;
|
||||
}
|
||||
this.detailRestoreFrame = requestAnimationFrame(restore);
|
||||
};
|
||||
this.detailRestoreFrame = requestAnimationFrame(restore);
|
||||
}
|
||||
/** Return Comfy's ordered detail navigation controls for this node. */
|
||||
domDetailButtons() {
|
||||
const root = this.domRoot();
|
||||
if (!root) return [];
|
||||
return Array.from(
|
||||
root.querySelectorAll(
|
||||
'button[aria-label^="View image "]'
|
||||
)
|
||||
);
|
||||
}
|
||||
nodeId() {
|
||||
const nodeId = this.options.node.id;
|
||||
return nodeId === void 0 ? void 0 : String(nodeId);
|
||||
@@ -1148,16 +1501,36 @@ function previewItems(images) {
|
||||
}));
|
||||
}
|
||||
function imageSlot(image) {
|
||||
return () => {
|
||||
const rect = image.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
};
|
||||
return () => elementSlot(image);
|
||||
}
|
||||
function elementSlot(element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
};
|
||||
}
|
||||
function validSlot(slot) {
|
||||
return Number.isFinite(slot.left) && Number.isFinite(slot.top) && Number.isFinite(slot.width) && Number.isFinite(slot.height) && slot.width >= 0 && slot.height >= 0;
|
||||
}
|
||||
function imageArea(image) {
|
||||
const rect = image.getBoundingClientRect();
|
||||
return rect.width * rect.height;
|
||||
}
|
||||
function indexedSlots(slots, container = null) {
|
||||
return slots.map(
|
||||
(bounds, itemIndex) => container ? { itemIndex, bounds, container } : { itemIndex, bounds }
|
||||
);
|
||||
}
|
||||
function unionImageRects(rects) {
|
||||
const left = Math.min(...rects.map(([x]) => x));
|
||||
const top = Math.min(...rects.map(([, y]) => y));
|
||||
const right = Math.max(...rects.map(([x, , width]) => x + width));
|
||||
const bottom = Math.max(...rects.map(([, y, , height]) => y + height));
|
||||
return [left, top, right - left, bottom - top];
|
||||
}
|
||||
|
||||
// web/src/orderedMediaSelection.ts
|
||||
var OrderedMediaSelection = class {
|
||||
@@ -1331,8 +1704,12 @@ function configureOrderedMediaNode(candidate, app2, api, config, logger = consol
|
||||
const restoreExternalUploads = wrapExternalAppendUploads(candidate, beginAppend);
|
||||
const originalOnGraphConfigured = candidate.onGraphConfigured;
|
||||
candidate.onGraphConfigured = function(...args) {
|
||||
const configuredValue = imageWidget.value;
|
||||
const serializedValue = configuredWidgetValue(candidate, imageWidget);
|
||||
const result = originalOnGraphConfigured?.apply(this, args);
|
||||
const restored = selection.replace(imageWidget.value);
|
||||
const restored = selection.replace(
|
||||
Array.isArray(configuredValue) ? configuredValue : Array.isArray(serializedValue) ? serializedValue : imageWidget.value
|
||||
);
|
||||
if (!Array.isArray(imageWidget.value)) setPersistedFiles(restored);
|
||||
refresh(restored);
|
||||
return result;
|
||||
@@ -1348,6 +1725,10 @@ function configureOrderedMediaNode(candidate, app2, api, config, logger = consol
|
||||
if (!Array.isArray(imageWidget.value)) setPersistedFiles(initial);
|
||||
refresh(initial);
|
||||
}
|
||||
function configuredWidgetValue(node, widget) {
|
||||
const index = node.widgets?.indexOf(widget) ?? -1;
|
||||
return index >= 0 ? node.widgets_values?.[index] : void 0;
|
||||
}
|
||||
function registerOrderedMediaNode(app2, api, extensionName, config, logger = console) {
|
||||
app2.registerExtension({
|
||||
name: extensionName,
|
||||
|
||||
@@ -5,12 +5,15 @@
|
||||
import type {
|
||||
ComfyApi,
|
||||
ComfyApp,
|
||||
ComfyGraph,
|
||||
ComfyGraphNode,
|
||||
ComfyNodeExecutionOutput
|
||||
} from "./types";
|
||||
import type { OrderedMediaPreviewTarget } from "./orderedMediaPreview";
|
||||
|
||||
interface PreviewNode {
|
||||
readonly id?: string | number;
|
||||
readonly graph?: ComfyGraph;
|
||||
}
|
||||
|
||||
/** Feed input-backed media through the same output path as Preview Image. */
|
||||
@@ -25,15 +28,19 @@ export class NativeNodePreview implements OrderedMediaPreviewTarget {
|
||||
|
||||
/** Publish media as an execution-shaped output for both node renderers. */
|
||||
publish(output: ComfyNodeExecutionOutput): void {
|
||||
const nodeId = this.nodeId();
|
||||
if (!nodeId) return;
|
||||
const localNodeId = this.localNodeId();
|
||||
if (!localNodeId) return;
|
||||
const outputLocator = this.outputLocator(localNodeId);
|
||||
this.app.nodeOutputs ??= {};
|
||||
this.app.nodeOutputs[nodeId] = output;
|
||||
this.api.dispatchEvent(
|
||||
new CustomEvent("executed", {
|
||||
detail: { node: nodeId, display_node: nodeId, output }
|
||||
})
|
||||
);
|
||||
this.app.nodeOutputs[localNodeId] = output;
|
||||
this.app.nodeOutputs[outputLocator] = output;
|
||||
for (const executionId of this.executionIds(localNodeId)) {
|
||||
this.api.dispatchEvent(
|
||||
new CustomEvent("executed", {
|
||||
detail: { node: executionId, display_node: executionId, output }
|
||||
})
|
||||
);
|
||||
}
|
||||
for (const listener of this.publishListeners) listener();
|
||||
}
|
||||
|
||||
@@ -48,8 +55,62 @@ export class NativeNodePreview implements OrderedMediaPreviewTarget {
|
||||
this.publish({ images: [], animated: [] });
|
||||
}
|
||||
|
||||
private nodeId(): string | undefined {
|
||||
private localNodeId(): string | undefined {
|
||||
if (this.node.id === undefined) return undefined;
|
||||
return String(this.node.id);
|
||||
}
|
||||
|
||||
/** Match the output key selected by Comfy's Nodes 2.0 subgraph renderer. */
|
||||
private outputLocator(localNodeId: string): string {
|
||||
const graphId = this.node.graph?.id;
|
||||
const isSubgraph = this.node.graph && this.node.graph !== this.app.rootGraph;
|
||||
return isSubgraph && graphId !== undefined
|
||||
? `${String(graphId)}:${localNodeId}`
|
||||
: localNodeId;
|
||||
}
|
||||
|
||||
/** Resolve instance paths because Comfy events consume execution IDs, not graph locators. */
|
||||
private executionIds(localNodeId: string): string[] {
|
||||
const graph = this.node.graph;
|
||||
const rootGraph = this.app.rootGraph;
|
||||
if (!graph || !rootGraph || graph === rootGraph || graph.isRootGraph) {
|
||||
return [localNodeId];
|
||||
}
|
||||
const parentPaths = findGraphInstancePaths(rootGraph, graph);
|
||||
return parentPaths.length > 0
|
||||
? parentPaths.map((path) => `${path}:${localNodeId}`)
|
||||
: [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Find every root-to-instance path that embeds one shared subgraph definition. */
|
||||
function findGraphInstancePaths(
|
||||
root: ComfyGraph,
|
||||
target: ComfyGraph,
|
||||
visited: ReadonlySet<ComfyGraph> = new Set()
|
||||
): string[] {
|
||||
if (visited.has(root)) return [];
|
||||
const nextVisited = new Set(visited);
|
||||
nextVisited.add(root);
|
||||
const paths: string[] = [];
|
||||
for (const node of root.nodes ?? []) {
|
||||
const nodeId = graphNodeId(node);
|
||||
if (!nodeId || !node.subgraph) continue;
|
||||
if (node.subgraph === target) paths.push(nodeId);
|
||||
for (const nestedPath of findGraphInstancePaths(
|
||||
node.subgraph,
|
||||
target,
|
||||
nextVisited
|
||||
)) {
|
||||
paths.push(`${nodeId}:${nestedPath}`);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/** Normalize one graph-node identity without accepting empty execution segments. */
|
||||
function graphNodeId(node: ComfyGraphNode): string | undefined {
|
||||
if (node.id === undefined) return undefined;
|
||||
const identity = String(node.id);
|
||||
return identity.length > 0 ? identity : undefined;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export function subscribeNativePreviewLifecycle(
|
||||
};
|
||||
}
|
||||
|
||||
/** Observe native preview mounting, source replacement, and detail selection. */
|
||||
function ensureObserver(): void {
|
||||
if (observer) return;
|
||||
observer = new MutationObserver(() => {
|
||||
@@ -29,6 +30,6 @@ function ensureObserver(): void {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["src"]
|
||||
attributeFilter: ["aria-current", "src"]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface OrderedMediaNode {
|
||||
constructor: { comfyClass?: string };
|
||||
id?: string | number;
|
||||
widgets?: OrderedMediaWidget[];
|
||||
widgets_values?: unknown[];
|
||||
pasteFiles?: NativeCallback;
|
||||
onDragDrop?: NativeCallback;
|
||||
onRemoved?: NativeCallback;
|
||||
@@ -70,7 +71,10 @@ export interface OrderedMediaNode {
|
||||
previousValue: unknown,
|
||||
widget: OrderedMediaWidget
|
||||
) => void;
|
||||
graph?: { setDirtyCanvas?: (foreground: boolean, background: boolean) => void };
|
||||
graph?: {
|
||||
id?: string | number;
|
||||
setDirtyCanvas?: (foreground: boolean, background: boolean) => void;
|
||||
};
|
||||
addWidget(
|
||||
type: WidgetType,
|
||||
name: string,
|
||||
@@ -238,8 +242,16 @@ export function configureOrderedMediaNode(
|
||||
const restoreExternalUploads = wrapExternalAppendUploads(candidate, beginAppend);
|
||||
const originalOnGraphConfigured = candidate.onGraphConfigured;
|
||||
candidate.onGraphConfigured = function (...args: unknown[]): unknown {
|
||||
const configuredValue = imageWidget.value;
|
||||
const serializedValue = configuredWidgetValue(candidate, imageWidget);
|
||||
const result = originalOnGraphConfigured?.apply(this, args);
|
||||
const restored = selection.replace(imageWidget.value);
|
||||
const restored = selection.replace(
|
||||
Array.isArray(configuredValue)
|
||||
? configuredValue
|
||||
: Array.isArray(serializedValue)
|
||||
? serializedValue
|
||||
: imageWidget.value
|
||||
);
|
||||
if (!Array.isArray(imageWidget.value)) setPersistedFiles(restored);
|
||||
refresh(restored);
|
||||
return result;
|
||||
@@ -258,6 +270,15 @@ export function configureOrderedMediaNode(
|
||||
refresh(initial);
|
||||
}
|
||||
|
||||
/** Read the persisted value aligned with one native widget when Comfy clears its live value. */
|
||||
function configuredWidgetValue(
|
||||
node: OrderedMediaNode,
|
||||
widget: OrderedMediaWidget
|
||||
): unknown {
|
||||
const index = node.widgets?.indexOf(widget) ?? -1;
|
||||
return index >= 0 ? node.widgets_values?.[index] : undefined;
|
||||
}
|
||||
|
||||
/** Register one renderer-neutral ordered-media loader extension. */
|
||||
export function registerOrderedMediaNode(
|
||||
app: ComfyApp,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { NativeNodePreview } from "./nativeNodePreview";
|
||||
import { subscribeNativePreviewLifecycle } from "./nativePreviewLifecycle";
|
||||
import {
|
||||
OrderedMediaPreviewAffordances,
|
||||
type NativePreviewActionSlot,
|
||||
type NativePreviewSlot
|
||||
} from "./orderedMediaPreviewAffordances";
|
||||
import {
|
||||
@@ -20,15 +21,34 @@ import {
|
||||
} from "./comfyImageReference";
|
||||
|
||||
type NativeImageRect = readonly [number, number, number, number];
|
||||
const CUBE_FACE_PROJECTION_SYMBOL = Symbol.for(
|
||||
"sugarcubes.cube-face-projection.v1"
|
||||
);
|
||||
|
||||
interface CubeFaceProjection {
|
||||
readonly container: HTMLElement;
|
||||
readonly projectRect: (rect: NativeImageRect) => NativePreviewSlot;
|
||||
}
|
||||
|
||||
interface ActionPreviewNode {
|
||||
readonly id?: string | number;
|
||||
readonly pos?: readonly [number, number];
|
||||
readonly size?: readonly [number, number];
|
||||
readonly flags?: { readonly collapsed?: boolean };
|
||||
widgets?: ActionPreviewWidget[];
|
||||
imageIndex?: number | null;
|
||||
imageRects?: NativeImageRect[];
|
||||
imgs?: HTMLImageElement[];
|
||||
graph?: { setDirtyCanvas?: (foreground: boolean, background: boolean) => void };
|
||||
graph?: {
|
||||
_rootGraph?: object;
|
||||
setDirtyCanvas?: (foreground: boolean, background: boolean) => void;
|
||||
};
|
||||
}
|
||||
|
||||
interface ActionPreviewWidget {
|
||||
readonly y?: number;
|
||||
readonly computedHeight?: number;
|
||||
readonly options?: { readonly canvasOnly?: boolean };
|
||||
}
|
||||
|
||||
export interface OrderedMediaPreviewActionOptions {
|
||||
@@ -48,6 +68,9 @@ export class OrderedMediaPreviewActions {
|
||||
private readonly transaction: OrderedMediaPreviewTransaction;
|
||||
private readonly unsubscribePreview: () => void;
|
||||
private readonly unsubscribeLifecycle: () => void;
|
||||
private lastCanvasPreviewRect: NativeImageRect | null = null;
|
||||
private pendingDetailIndex: number | null = null;
|
||||
private detailRestoreFrame: number | null = null;
|
||||
|
||||
constructor(private readonly options: OrderedMediaPreviewActionOptions) {
|
||||
this.transaction = new OrderedMediaPreviewTransaction({
|
||||
@@ -57,25 +80,39 @@ export class OrderedMediaPreviewActions {
|
||||
this.affordances.refresh();
|
||||
}
|
||||
});
|
||||
const moveEarlier = (index: number): void => {
|
||||
const destination = index - 1;
|
||||
this.transaction.move(index, destination);
|
||||
this.followMovedDetail(index, destination);
|
||||
options.moveEarlier(index);
|
||||
};
|
||||
const moveLater = (index: number): void => {
|
||||
const destination = index + 1;
|
||||
this.transaction.move(index, destination);
|
||||
this.followMovedDetail(index, destination);
|
||||
options.moveLater(index);
|
||||
};
|
||||
const remove = (index: number): void => {
|
||||
const removedDetail = this.selectedItemIndex() === index;
|
||||
this.transaction.remove(index);
|
||||
options.remove(index);
|
||||
this.followRemovedDetail(index, removedDetail);
|
||||
};
|
||||
const actionOptions = {
|
||||
getItemCount: () => this.itemCount(),
|
||||
moveEarlier,
|
||||
moveLater,
|
||||
remove
|
||||
};
|
||||
this.affordances = new OrderedMediaPreviewAffordances({
|
||||
itemLabel: options.itemLabel,
|
||||
getSlots: () => this.nativeSlots(),
|
||||
moveEarlier: (index) => {
|
||||
this.transaction.move(index, index - 1);
|
||||
options.moveEarlier(index);
|
||||
},
|
||||
moveLater: (index) => {
|
||||
this.transaction.move(index, index + 1);
|
||||
options.moveLater(index);
|
||||
},
|
||||
remove: (index) => {
|
||||
this.transaction.remove(index);
|
||||
options.remove(index);
|
||||
}
|
||||
getSlots: () => this.nativeActionSlots(),
|
||||
...actionOptions
|
||||
});
|
||||
this.unsubscribePreview = options.preview.subscribe(() => {
|
||||
this.affordances.refresh();
|
||||
this.transaction.authoritativePublished();
|
||||
this.restorePendingDetail();
|
||||
});
|
||||
this.unsubscribeLifecycle = subscribeNativePreviewLifecycle(() => {
|
||||
this.affordances.refresh();
|
||||
@@ -88,6 +125,10 @@ export class OrderedMediaPreviewActions {
|
||||
this.unsubscribeLifecycle();
|
||||
this.transaction.dispose();
|
||||
this.affordances.dispose();
|
||||
if (this.detailRestoreFrame !== null) {
|
||||
cancelAnimationFrame(this.detailRestoreFrame);
|
||||
this.detailRestoreFrame = null;
|
||||
}
|
||||
}
|
||||
|
||||
private itemCount(): number {
|
||||
@@ -130,40 +171,38 @@ export class OrderedMediaPreviewActions {
|
||||
}
|
||||
|
||||
private canvasSlots(): NativePreviewSlot[] {
|
||||
const canvasApi = this.options.app.canvas;
|
||||
const nodePosition = this.options.node.pos;
|
||||
const imageRects = this.options.node.imageRects;
|
||||
if (!canvasApi || !nodePosition || !imageRects) return [];
|
||||
const canvasRect = canvasApi.canvas.getBoundingClientRect();
|
||||
return imageRects.map(([x, y, width, height]) => {
|
||||
const topLeft = canvasApi.convertOffsetToCanvas([
|
||||
nodePosition[0] + x,
|
||||
nodePosition[1] + y
|
||||
]);
|
||||
const bottomRight = canvasApi.convertOffsetToCanvas([
|
||||
nodePosition[0] + x + width,
|
||||
nodePosition[1] + y + height
|
||||
]);
|
||||
return {
|
||||
left: canvasRect.left + topLeft[0],
|
||||
top: canvasRect.top + topLeft[1],
|
||||
width: bottomRight[0] - topLeft[0],
|
||||
height: bottomRight[1] - topLeft[1]
|
||||
};
|
||||
});
|
||||
if (!imageRects) return [];
|
||||
if (imageRects.length > 0) {
|
||||
this.lastCanvasPreviewRect = unionImageRects(imageRects);
|
||||
}
|
||||
const slots: NativePreviewSlot[] = [];
|
||||
for (const rect of imageRects) {
|
||||
const slot = this.canvasLocalSlot(rect);
|
||||
if (slot) slots.push(slot);
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
private nativeSlots(): NativePreviewSlot[] {
|
||||
if (
|
||||
this.options.node.flags?.collapsed ||
|
||||
this.options.node.imageIndex != null
|
||||
) {
|
||||
return [];
|
||||
private nativeActionSlots(): NativePreviewActionSlot[] {
|
||||
if (!this.belongsToActiveWorkflow()) return [];
|
||||
if (this.options.node.flags?.collapsed) return [];
|
||||
const selectedIndex = this.selectedItemIndex();
|
||||
if (selectedIndex !== null) {
|
||||
return this.detailActionSlots(selectedIndex);
|
||||
}
|
||||
const activeSlots = this.transaction.activeSlots();
|
||||
if (activeSlots) return activeSlots;
|
||||
const domImages = this.domImages();
|
||||
if (activeSlots) {
|
||||
return indexedSlots(
|
||||
activeSlots,
|
||||
this.domPreviewContainer(domImages) ??
|
||||
this.cubeFaceProjection()?.container ??
|
||||
null
|
||||
);
|
||||
}
|
||||
if (!this.isGridVisible()) return [];
|
||||
const domSlots = this.domImages().map((image) => {
|
||||
const domSlots = domImages.map((image) => {
|
||||
const rect = image.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left,
|
||||
@@ -172,7 +211,180 @@ export class OrderedMediaPreviewActions {
|
||||
height: rect.height
|
||||
};
|
||||
});
|
||||
return domSlots.length === this.itemCount() ? domSlots : this.canvasSlots();
|
||||
if (domSlots.length === this.itemCount()) {
|
||||
return indexedSlots(domSlots, this.domPreviewContainer(domImages));
|
||||
}
|
||||
return indexedSlots(
|
||||
this.canvasSlots(),
|
||||
this.cubeFaceProjection()?.container ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** Prevent inactive workflow tabs with reused node IDs from claiming active Nodes 2 DOM. */
|
||||
private belongsToActiveWorkflow(): boolean {
|
||||
const nodeGraph = this.options.node.graph;
|
||||
const nodeRoot = nodeGraph?._rootGraph ?? nodeGraph;
|
||||
const activeRoot = this.options.app.rootGraph ?? this.options.app.canvas?.graph;
|
||||
return !nodeRoot || !activeRoot || nodeRoot === activeRoot;
|
||||
}
|
||||
|
||||
/** Expose one selected-item control strip in native detail mode. */
|
||||
private detailActionSlots(selectedIndex: number): NativePreviewActionSlot[] {
|
||||
const domSlot = this.domDetailSlot(selectedIndex);
|
||||
if (domSlot) return [domSlot];
|
||||
const canvasSlot = this.canvasDetailSlot();
|
||||
if (!canvasSlot) return [];
|
||||
const container = this.cubeFaceProjection()?.container;
|
||||
return [
|
||||
container
|
||||
? { itemIndex: selectedIndex, bounds: canvasSlot, container }
|
||||
: { itemIndex: selectedIndex, bounds: canvasSlot }
|
||||
];
|
||||
}
|
||||
|
||||
/** Locate the selected Nodes 2.0 preview without replacing native UI. */
|
||||
private domDetailSlot(selectedIndex: number): NativePreviewActionSlot | null {
|
||||
const root = this.domRoot();
|
||||
const reference = this.currentImages()?.[selectedIndex];
|
||||
if (!root || !reference) return null;
|
||||
const expectedKey = comfyImageReferenceKey(reference);
|
||||
const candidates = Array.from(
|
||||
root.querySelectorAll<HTMLImageElement>("img")
|
||||
).filter((image) => {
|
||||
const rect = image.getBoundingClientRect();
|
||||
return (
|
||||
comfyImageSourceKey(image.src) === expectedKey &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0
|
||||
);
|
||||
});
|
||||
const image = candidates.reduce<HTMLImageElement | null>(
|
||||
(largest, candidate) =>
|
||||
!largest || imageArea(candidate) > imageArea(largest)
|
||||
? candidate
|
||||
: largest,
|
||||
null
|
||||
);
|
||||
const previewRegion = root.querySelector<HTMLElement>(
|
||||
'[role="region"][aria-label^="Image preview"]'
|
||||
);
|
||||
const previewElement = image ?? previewRegion;
|
||||
if (!previewElement) return null;
|
||||
return {
|
||||
itemIndex: selectedIndex,
|
||||
bounds: elementSlot(previewElement),
|
||||
container:
|
||||
previewRegion ??
|
||||
this.domPreviewContainer(image ? [image] : []) ??
|
||||
root
|
||||
};
|
||||
}
|
||||
|
||||
/** Return the closest native preview surface shared by rendered media. */
|
||||
private domPreviewContainer(images: HTMLImageElement[]): HTMLElement | null {
|
||||
const root = this.domRoot();
|
||||
if (!root || images.length === 0) return null;
|
||||
const previewRegion = root.querySelector<HTMLElement>(
|
||||
'[role="region"][aria-label^="Image preview"]'
|
||||
);
|
||||
if (previewRegion && images.every((image) => previewRegion.contains(image))) {
|
||||
return previewRegion;
|
||||
}
|
||||
let candidate = images[0]?.parentElement ?? null;
|
||||
while (candidate && candidate !== root) {
|
||||
if (images.every((image) => candidate?.contains(image) === true)) {
|
||||
return candidate;
|
||||
}
|
||||
candidate = candidate.parentElement;
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
/** Resolve selected preview geometry in node-local canvas coordinates. */
|
||||
private canvasDetailLocalSlot(): NativePreviewSlot | null {
|
||||
const previewWidget = this.options.node.widgets?.find(
|
||||
(widget) =>
|
||||
widget.options?.canvasOnly === true &&
|
||||
typeof widget.y === "number" &&
|
||||
typeof widget.computedHeight === "number" &&
|
||||
widget.computedHeight > 0
|
||||
);
|
||||
const nodeWidth = this.options.node.size?.[0];
|
||||
const previewY = previewWidget?.y;
|
||||
const previewHeight = previewWidget?.computedHeight;
|
||||
if (
|
||||
typeof nodeWidth !== "number" ||
|
||||
nodeWidth <= 0 ||
|
||||
typeof previewY !== "number" ||
|
||||
typeof previewHeight !== "number"
|
||||
) {
|
||||
if (!this.lastCanvasPreviewRect) return null;
|
||||
const [left, top, width, height] = this.lastCanvasPreviewRect;
|
||||
return { left, top, width, height };
|
||||
}
|
||||
return { left: 0, top: previewY, width: nodeWidth, height: previewHeight };
|
||||
}
|
||||
|
||||
/** Locate the selected Nodes 1.0 preview from its canvas widget geometry. */
|
||||
private canvasDetailSlot(): NativePreviewSlot | null {
|
||||
const localSlot = this.canvasDetailLocalSlot();
|
||||
return localSlot
|
||||
? this.canvasLocalSlot([
|
||||
localSlot.left,
|
||||
localSlot.top,
|
||||
localSlot.width,
|
||||
localSlot.height
|
||||
])
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Project one node-local canvas rectangle into viewport coordinates. */
|
||||
private canvasLocalSlot(rect: NativeImageRect): NativePreviewSlot | null {
|
||||
const projection = this.cubeFaceProjection();
|
||||
if (projection) {
|
||||
try {
|
||||
const slot = projection.projectRect(rect);
|
||||
if (validSlot(slot)) return slot;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const canvasApi = this.options.app.canvas;
|
||||
const nodePosition = this.options.node.pos;
|
||||
if (!canvasApi || !nodePosition) return null;
|
||||
const [x, y, width, height] = rect;
|
||||
const canvasRect = canvasApi.canvas.getBoundingClientRect();
|
||||
const topLeft = canvasApi.convertOffsetToCanvas([
|
||||
nodePosition[0] + x,
|
||||
nodePosition[1] + y
|
||||
]);
|
||||
const bottomRight = canvasApi.convertOffsetToCanvas([
|
||||
nodePosition[0] + x + width,
|
||||
nodePosition[1] + y + height
|
||||
]);
|
||||
return {
|
||||
left: canvasRect.left + topLeft[0],
|
||||
top: canvasRect.top + topLeft[1],
|
||||
width: bottomRight[0] - topLeft[0],
|
||||
height: bottomRight[1] - topLeft[1]
|
||||
};
|
||||
}
|
||||
|
||||
/** Read SugarCubes' renderer-neutral projection contract when this node is embedded. */
|
||||
private cubeFaceProjection(): CubeFaceProjection | null {
|
||||
const value = Reflect.get(
|
||||
this.options.node,
|
||||
CUBE_FACE_PROJECTION_SYMBOL
|
||||
) as unknown;
|
||||
if (typeof value !== "object" || value === null) return null;
|
||||
const candidate = value as Partial<CubeFaceProjection>;
|
||||
if (
|
||||
!(candidate.container instanceof HTMLElement) ||
|
||||
typeof candidate.projectRect !== "function"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return candidate as CubeFaceProjection;
|
||||
}
|
||||
|
||||
private captureSurface(): OrderedMediaPreviewSurface | null {
|
||||
@@ -184,7 +396,9 @@ export class OrderedMediaPreviewActions {
|
||||
}
|
||||
return {
|
||||
items: previewItems(canvasImages),
|
||||
slots: canvasImages.map((_, index) => () => this.canvasSlots()[index] as NativePreviewSlot),
|
||||
slots: canvasImages.map(
|
||||
(_, index) => () => this.canvasSlots()[index] as NativePreviewSlot
|
||||
),
|
||||
apply: (items) => {
|
||||
this.options.node.imgs = items.map((item) => item.image);
|
||||
delete this.options.node.imageRects;
|
||||
@@ -257,6 +471,96 @@ export class OrderedMediaPreviewActions {
|
||||
);
|
||||
}
|
||||
|
||||
/** Read detail selection from the renderer that currently owns it. */
|
||||
private selectedItemIndex(): number | null {
|
||||
const index = this.options.node.imageIndex;
|
||||
if (
|
||||
typeof index === "number" &&
|
||||
Number.isInteger(index) &&
|
||||
index >= 0 &&
|
||||
index < this.itemCount()
|
||||
) {
|
||||
return index;
|
||||
}
|
||||
const currentButton = this.domDetailButtons().find(
|
||||
(button) => button.getAttribute("aria-current") === "true"
|
||||
);
|
||||
if (!currentButton) return null;
|
||||
const detailIndex = this.domDetailButtons().indexOf(currentButton);
|
||||
return detailIndex >= 0 && detailIndex < this.itemCount()
|
||||
? detailIndex
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Keep the moved item selected across both native renderer state models. */
|
||||
private followMovedDetail(index: number, destination: number): void {
|
||||
if (this.selectedItemIndex() !== index) return;
|
||||
this.pendingDetailIndex = destination;
|
||||
if (this.options.node.imageIndex === index) {
|
||||
this.options.node.imageIndex = destination;
|
||||
} else {
|
||||
this.domDetailButtons()[destination]?.click();
|
||||
}
|
||||
this.affordances.refresh();
|
||||
}
|
||||
|
||||
/** Select the nearest remaining item after removing an inspected item. */
|
||||
private followRemovedDetail(index: number, removedDetail: boolean): void {
|
||||
if (!removedDetail) return;
|
||||
const remaining = this.itemCount();
|
||||
const destination = remaining > 0 ? Math.min(index, remaining - 1) : null;
|
||||
this.pendingDetailIndex = destination;
|
||||
if (this.options.node.imageIndex === index) {
|
||||
this.options.node.imageIndex = destination;
|
||||
} else if (destination !== null) {
|
||||
this.domDetailButtons()[destination]?.click();
|
||||
}
|
||||
this.affordances.refresh();
|
||||
}
|
||||
|
||||
/** Re-enter Nodes 2.0 detail mode after output publication resets its grid. */
|
||||
private restorePendingDetail(): void {
|
||||
if (this.pendingDetailIndex === null || this.detailRestoreFrame !== null) {
|
||||
return;
|
||||
}
|
||||
let stableFrames = 0;
|
||||
let remainingFrames = 12;
|
||||
const restore = (): void => {
|
||||
this.detailRestoreFrame = null;
|
||||
const destination = this.pendingDetailIndex;
|
||||
if (destination === null) return;
|
||||
const buttons = this.domDetailButtons();
|
||||
const selected = buttons.findIndex(
|
||||
(button) => button.getAttribute("aria-current") === "true"
|
||||
);
|
||||
if (selected === destination) {
|
||||
stableFrames += 1;
|
||||
} else {
|
||||
stableFrames = 0;
|
||||
buttons[destination]?.click();
|
||||
}
|
||||
remainingFrames -= 1;
|
||||
if (stableFrames >= 3 || remainingFrames <= 0) {
|
||||
this.pendingDetailIndex = null;
|
||||
this.affordances.refresh();
|
||||
return;
|
||||
}
|
||||
this.detailRestoreFrame = requestAnimationFrame(restore);
|
||||
};
|
||||
this.detailRestoreFrame = requestAnimationFrame(restore);
|
||||
}
|
||||
|
||||
/** Return Comfy's ordered detail navigation controls for this node. */
|
||||
private domDetailButtons(): HTMLButtonElement[] {
|
||||
const root = this.domRoot();
|
||||
if (!root) return [];
|
||||
return Array.from(
|
||||
root.querySelectorAll<HTMLButtonElement>(
|
||||
'button[aria-label^="View image "]'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private nodeId(): string | undefined {
|
||||
const nodeId = this.options.node.id;
|
||||
return nodeId === undefined ? undefined : String(nodeId);
|
||||
@@ -271,13 +575,52 @@ function previewItems(images: HTMLImageElement[]): OrderedMediaPreviewItem[] {
|
||||
}
|
||||
|
||||
function imageSlot(image: HTMLImageElement): () => NativePreviewSlot {
|
||||
return () => {
|
||||
const rect = image.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
};
|
||||
return () => elementSlot(image);
|
||||
}
|
||||
|
||||
function elementSlot(element: Element): NativePreviewSlot {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
};
|
||||
}
|
||||
|
||||
/** Reject malformed cross-extension projection geometry. */
|
||||
function validSlot(slot: NativePreviewSlot): boolean {
|
||||
return (
|
||||
Number.isFinite(slot.left) &&
|
||||
Number.isFinite(slot.top) &&
|
||||
Number.isFinite(slot.width) &&
|
||||
Number.isFinite(slot.height) &&
|
||||
slot.width >= 0 &&
|
||||
slot.height >= 0
|
||||
);
|
||||
}
|
||||
|
||||
/** Measure a rendered image when native detail contains multiple candidates. */
|
||||
function imageArea(image: HTMLImageElement): number {
|
||||
const rect = image.getBoundingClientRect();
|
||||
return rect.width * rect.height;
|
||||
}
|
||||
|
||||
/** Attach authoritative list positions to native preview footprints. */
|
||||
function indexedSlots(
|
||||
slots: NativePreviewSlot[],
|
||||
container: HTMLElement | null = null
|
||||
): NativePreviewActionSlot[] {
|
||||
return slots.map((bounds, itemIndex) =>
|
||||
container ? { itemIndex, bounds, container } : { itemIndex, bounds }
|
||||
);
|
||||
}
|
||||
|
||||
/** Return the smallest node-local rectangle containing every grid cell. */
|
||||
function unionImageRects(rects: NativeImageRect[]): NativeImageRect {
|
||||
const left = Math.min(...rects.map(([x]) => x));
|
||||
const top = Math.min(...rects.map(([, y]) => y));
|
||||
const right = Math.max(...rects.map(([x, , width]) => x + width));
|
||||
const bottom = Math.max(...rects.map(([, y, , height]) => y + height));
|
||||
return [left, top, right - left, bottom - top];
|
||||
}
|
||||
|
||||
@@ -14,9 +14,17 @@ export interface NativePreviewSlot {
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
/** Bind one native preview footprint to its authoritative media position. */
|
||||
export interface NativePreviewActionSlot {
|
||||
readonly itemIndex: number;
|
||||
readonly bounds: NativePreviewSlot;
|
||||
readonly container?: HTMLElement;
|
||||
}
|
||||
|
||||
export interface OrderedMediaPreviewAffordanceOptions {
|
||||
readonly itemLabel: string;
|
||||
readonly getSlots: () => NativePreviewSlot[];
|
||||
readonly getSlots: () => NativePreviewActionSlot[];
|
||||
readonly getItemCount: () => number;
|
||||
readonly moveEarlier: (index: number) => void;
|
||||
readonly moveLater: (index: number) => void;
|
||||
readonly remove: (index: number) => void;
|
||||
@@ -32,15 +40,19 @@ interface AffordanceElements {
|
||||
/** Overlay compact controls without replacing Comfy's native preview surface. */
|
||||
export class OrderedMediaPreviewAffordances {
|
||||
private elements: AffordanceElements[] = [];
|
||||
private readonly positionedContainers = new Map<HTMLElement, string>();
|
||||
private animationFrame: number | null = null;
|
||||
private remainingSyncFrames = 0;
|
||||
private viewportControlsSuspended = false;
|
||||
|
||||
constructor(private readonly options: OrderedMediaPreviewAffordanceOptions) {
|
||||
window.addEventListener("resize", this.requestRefresh, true);
|
||||
document.addEventListener("click", this.requestRefresh, true);
|
||||
document.addEventListener("keydown", this.requestRefresh, true);
|
||||
document.addEventListener("pointerdown", this.suspendViewportControls, true);
|
||||
document.addEventListener("pointermove", this.requestRefresh, true);
|
||||
document.addEventListener("pointerup", this.requestRefresh, true);
|
||||
document.addEventListener("pointerup", this.resumeViewportControls, true);
|
||||
document.addEventListener("pointercancel", this.resumeViewportControls, true);
|
||||
document.addEventListener("wheel", this.requestRefresh, true);
|
||||
this.refresh();
|
||||
}
|
||||
@@ -56,8 +68,10 @@ export class OrderedMediaPreviewAffordances {
|
||||
window.removeEventListener("resize", this.requestRefresh, true);
|
||||
document.removeEventListener("click", this.requestRefresh, true);
|
||||
document.removeEventListener("keydown", this.requestRefresh, true);
|
||||
document.removeEventListener("pointerdown", this.suspendViewportControls, true);
|
||||
document.removeEventListener("pointermove", this.requestRefresh, true);
|
||||
document.removeEventListener("pointerup", this.requestRefresh, true);
|
||||
document.removeEventListener("pointerup", this.resumeViewportControls, true);
|
||||
document.removeEventListener("pointercancel", this.resumeViewportControls, true);
|
||||
document.removeEventListener("wheel", this.requestRefresh, true);
|
||||
if (this.animationFrame !== null) {
|
||||
cancelAnimationFrame(this.animationFrame);
|
||||
@@ -65,6 +79,7 @@ export class OrderedMediaPreviewAffordances {
|
||||
}
|
||||
for (const element of this.elements) element.root.remove();
|
||||
this.elements = [];
|
||||
this.releasePositionedContainers(new Set());
|
||||
}
|
||||
|
||||
private readonly requestRefresh = (): void => {
|
||||
@@ -72,6 +87,20 @@ export class OrderedMediaPreviewAffordances {
|
||||
this.scheduleSync();
|
||||
};
|
||||
|
||||
private readonly suspendViewportControls = (event: Event): void => {
|
||||
if (!(event.target instanceof HTMLCanvasElement)) return;
|
||||
this.viewportControlsSuspended = true;
|
||||
for (const elements of this.elements) {
|
||||
if (elements.root.parentElement === document.body) elements.root.hidden = true;
|
||||
}
|
||||
};
|
||||
|
||||
private readonly resumeViewportControls = (): void => {
|
||||
if (!this.viewportControlsSuspended) return;
|
||||
this.viewportControlsSuspended = false;
|
||||
this.refresh();
|
||||
};
|
||||
|
||||
private scheduleSync(): void {
|
||||
if (this.animationFrame !== null) return;
|
||||
this.animationFrame = requestAnimationFrame(() => {
|
||||
@@ -84,43 +113,107 @@ export class OrderedMediaPreviewAffordances {
|
||||
|
||||
private sync(): void {
|
||||
const slots = this.options.getSlots();
|
||||
const activeContainers = new Set<HTMLElement>();
|
||||
this.resizeElements(slots.length);
|
||||
for (const [index, elements] of this.elements.entries()) {
|
||||
const slot = slots[index];
|
||||
if (!slot || slot.width <= 0 || slot.height <= 0) {
|
||||
const actionSlot = slots[index];
|
||||
const slot = actionSlot?.bounds;
|
||||
if (
|
||||
!actionSlot ||
|
||||
!slot ||
|
||||
slot.width <= 0 ||
|
||||
slot.height <= 0 ||
|
||||
(!actionSlot.container && this.viewportControlsSuspended)
|
||||
) {
|
||||
elements.root.hidden = true;
|
||||
continue;
|
||||
}
|
||||
const itemIndex = actionSlot.itemIndex;
|
||||
elements.root.hidden = false;
|
||||
const stripHeight = Math.max(18, Math.min(26, slot.height * 0.16));
|
||||
const position = this.mount(elements.root, actionSlot, activeContainers);
|
||||
const stripHeight = Math.max(18, Math.min(26, position.height * 0.16));
|
||||
Object.assign(elements.root.style, {
|
||||
left: `${String(slot.left)}px`,
|
||||
top: `${String(slot.top)}px`,
|
||||
width: `${String(slot.width)}px`,
|
||||
left: `${String(position.left)}px`,
|
||||
top: `${String(position.top)}px`,
|
||||
width: `${String(position.width)}px`,
|
||||
height: `${String(stripHeight)}px`
|
||||
});
|
||||
elements.root.dataset.ssMediaIndex = String(index);
|
||||
elements.earlier.dataset.ssMediaIndex = String(index);
|
||||
elements.later.dataset.ssMediaIndex = String(index);
|
||||
elements.remove.dataset.ssMediaIndex = String(index);
|
||||
setActionAvailability(elements.earlier, index > 0);
|
||||
elements.root.dataset.ssMediaIndex = String(itemIndex);
|
||||
elements.earlier.dataset.ssMediaIndex = String(itemIndex);
|
||||
elements.later.dataset.ssMediaIndex = String(itemIndex);
|
||||
elements.remove.dataset.ssMediaIndex = String(itemIndex);
|
||||
setActionAvailability(elements.earlier, itemIndex > 0);
|
||||
setActionAvailability(
|
||||
elements.later,
|
||||
index < this.elements.length - 1
|
||||
itemIndex < this.options.getItemCount() - 1
|
||||
);
|
||||
elements.earlier.setAttribute(
|
||||
"aria-label",
|
||||
`Move ${this.options.itemLabel} ${String(index + 1)} earlier`
|
||||
`Move ${this.options.itemLabel} ${String(itemIndex + 1)} earlier`
|
||||
);
|
||||
elements.later.setAttribute(
|
||||
"aria-label",
|
||||
`Move ${this.options.itemLabel} ${String(index + 1)} later`
|
||||
`Move ${this.options.itemLabel} ${String(itemIndex + 1)} later`
|
||||
);
|
||||
elements.remove.setAttribute(
|
||||
"aria-label",
|
||||
`Remove ${this.options.itemLabel} ${String(index + 1)}`
|
||||
`Remove ${this.options.itemLabel} ${String(itemIndex + 1)}`
|
||||
);
|
||||
}
|
||||
this.releasePositionedContainers(activeContainers);
|
||||
}
|
||||
|
||||
/** Mount one control strip in the preview surface that owns its geometry. */
|
||||
private mount(
|
||||
root: HTMLElement,
|
||||
actionSlot: NativePreviewActionSlot,
|
||||
activeContainers: Set<HTMLElement>
|
||||
): NativePreviewSlot {
|
||||
const container = actionSlot.container;
|
||||
if (!container) {
|
||||
if (root.parentElement !== document.body) document.body.append(root);
|
||||
root.style.position = "fixed";
|
||||
root.style.zIndex = "2";
|
||||
return actionSlot.bounds;
|
||||
}
|
||||
activeContainers.add(container);
|
||||
this.positionContainer(container);
|
||||
if (root.parentElement !== container) container.append(root);
|
||||
root.style.position = "absolute";
|
||||
root.style.zIndex = "1";
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const scaleX = containerScale(containerRect.width, container.offsetWidth);
|
||||
const scaleY = containerScale(containerRect.height, container.offsetHeight);
|
||||
return {
|
||||
left:
|
||||
(actionSlot.bounds.left - containerRect.left) / scaleX -
|
||||
container.clientLeft +
|
||||
container.scrollLeft,
|
||||
top:
|
||||
(actionSlot.bounds.top - containerRect.top) / scaleY -
|
||||
container.clientTop +
|
||||
container.scrollTop,
|
||||
width: actionSlot.bounds.width / scaleX,
|
||||
height: actionSlot.bounds.height / scaleY
|
||||
};
|
||||
}
|
||||
|
||||
/** Establish a local containing block without overriding authored positioning. */
|
||||
private positionContainer(container: HTMLElement): void {
|
||||
if (this.positionedContainers.has(container)) return;
|
||||
const position = getComputedStyle(container).position;
|
||||
if (position !== "" && position !== "static") return;
|
||||
this.positionedContainers.set(container, container.style.position);
|
||||
container.style.position = "relative";
|
||||
}
|
||||
|
||||
/** Restore preview surfaces that no longer contain loader controls. */
|
||||
private releasePositionedContainers(activeContainers: Set<HTMLElement>): void {
|
||||
for (const [container, originalPosition] of this.positionedContainers) {
|
||||
if (activeContainers.has(container)) continue;
|
||||
container.style.position = originalPosition;
|
||||
this.positionedContainers.delete(container);
|
||||
}
|
||||
}
|
||||
|
||||
private resizeElements(count: number): void {
|
||||
@@ -135,14 +228,12 @@ export class OrderedMediaPreviewAffordances {
|
||||
root.className = "ss-native-preview-affordance";
|
||||
Object.assign(root.style, {
|
||||
position: "fixed",
|
||||
zIndex: "9990",
|
||||
zIndex: "2",
|
||||
display: "flex",
|
||||
alignItems: "stretch",
|
||||
pointerEvents: "none",
|
||||
overflow: "hidden",
|
||||
borderRadius: "4px 4px 0 0",
|
||||
background: "rgba(20, 20, 20, 0.72)",
|
||||
boxShadow: "inset 0 -1px 0 rgba(255, 255, 255, 0.16)"
|
||||
background: "rgba(20, 20, 20, 0.72)"
|
||||
});
|
||||
const earlier = controlButton("pi-arrow-left", "Move earlier");
|
||||
earlier.setAttribute(MEDIA_MOVE_EARLIER_ATTRIBUTE, "true");
|
||||
@@ -223,3 +314,10 @@ function stopControlPointerEvent(event: PointerEvent): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
/** Return a finite rendered-to-local scale for one preview axis. */
|
||||
function containerScale(renderedSize: number, localSize: number): number {
|
||||
if (renderedSize <= 0 || localSize <= 0) return 1;
|
||||
const scale = renderedSize / localSize;
|
||||
return Number.isFinite(scale) && scale > 0 ? scale : 1;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,10 @@ interface NativePreviewNode {
|
||||
hidden?: boolean;
|
||||
options?: { hidden?: boolean };
|
||||
}>;
|
||||
graph?: { setDirtyCanvas?: (foreground: boolean, background: boolean) => void };
|
||||
graph?: {
|
||||
id?: string | number;
|
||||
setDirtyCanvas?: (foreground: boolean, background: boolean) => void;
|
||||
};
|
||||
}
|
||||
|
||||
type SegPreviewExecutionOutput = ComfyNodeExecutionOutput &
|
||||
|
||||
@@ -26,8 +26,20 @@ export interface ComfySettingsApi {
|
||||
): ComfySetting<TValue>;
|
||||
}
|
||||
|
||||
export interface ComfyGraphNode {
|
||||
readonly id?: string | number;
|
||||
readonly subgraph?: ComfyGraph;
|
||||
}
|
||||
|
||||
export interface ComfyGraph {
|
||||
readonly id?: string | number;
|
||||
readonly isRootGraph?: boolean;
|
||||
readonly nodes?: readonly ComfyGraphNode[];
|
||||
}
|
||||
|
||||
export interface ComfyApp {
|
||||
nodeOutputs?: Record<string, ComfyNodeExecutionOutput>;
|
||||
rootGraph?: ComfyGraph;
|
||||
canvas?: ComfyCanvasApi;
|
||||
ui: {
|
||||
settings: ComfySettingsApi;
|
||||
@@ -39,6 +51,7 @@ export interface ComfyApp {
|
||||
/** Stable coordinate operations exposed by Comfy's graph canvas. */
|
||||
export interface ComfyCanvasApi {
|
||||
canvas: HTMLCanvasElement;
|
||||
graph?: ComfyGraph;
|
||||
graph_mouse?: readonly [number, number];
|
||||
convertEventToCanvasOffset(event: MouseEvent): [number, number];
|
||||
convertOffsetToCanvas(
|
||||
|
||||
@@ -5,7 +5,14 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { NativeNodePreview } from "../src/nativeNodePreview";
|
||||
import type { ComfyApi, ComfyApp } from "../src/types";
|
||||
import type {
|
||||
ComfyApi,
|
||||
ComfyApp,
|
||||
ComfyGraph,
|
||||
ComfySetting,
|
||||
ComfySettingDefinition,
|
||||
SettingValue
|
||||
} from "../src/types";
|
||||
|
||||
describe("NativeNodePreview", () => {
|
||||
it("publishes execution-shaped output through Comfy's native event path", () => {
|
||||
@@ -31,6 +38,78 @@ describe("NativeNodePreview", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes subgraph previews under the locator consumed by Nodes 2.0", () => {
|
||||
const subgraph = { id: "cube-definition" };
|
||||
const rootGraph = {
|
||||
id: "root",
|
||||
isRootGraph: true,
|
||||
nodes: [{ id: "cube-instance", subgraph }]
|
||||
};
|
||||
const app = previewApp(rootGraph);
|
||||
const events = new EventTarget();
|
||||
const api = events as ComfyApi;
|
||||
const listener = vi.fn();
|
||||
events.addEventListener("executed", (event: Event) => {
|
||||
listener((event as CustomEvent).detail);
|
||||
});
|
||||
const preview = new NativeNodePreview(app, api, {
|
||||
id: "mask-loader",
|
||||
graph: subgraph
|
||||
});
|
||||
const output = {
|
||||
images: [{ filename: "mask.png", subfolder: "", type: "temp" as const }]
|
||||
};
|
||||
|
||||
preview.publish(output);
|
||||
|
||||
expect(app.nodeOutputs?.["mask-loader"]).toBe(output);
|
||||
expect(app.nodeOutputs?.["cube-definition:mask-loader"]).toBe(output);
|
||||
expect(listener).toHaveBeenCalledWith({
|
||||
node: "cube-instance:mask-loader",
|
||||
display_node: "cube-instance:mask-loader",
|
||||
output
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes shared subgraph previews for every instance path", () => {
|
||||
const subgraph = { id: "cube-definition", nodes: [] };
|
||||
const nestedGraph = {
|
||||
id: "nested-definition",
|
||||
nodes: [{ id: "nested-cube", subgraph }]
|
||||
};
|
||||
const rootGraph = {
|
||||
id: "root",
|
||||
isRootGraph: true,
|
||||
nodes: [
|
||||
{ id: "cube-a", subgraph },
|
||||
{ id: "cube-b", subgraph },
|
||||
{ id: "container", subgraph: nestedGraph }
|
||||
]
|
||||
};
|
||||
const app = previewApp(rootGraph);
|
||||
const events = new EventTarget();
|
||||
const api = events as ComfyApi;
|
||||
const executionIds: string[] = [];
|
||||
const listener = vi.fn((executionId: string) => {
|
||||
executionIds.push(executionId);
|
||||
});
|
||||
events.addEventListener("executed", (event: Event) => {
|
||||
listener(executedNodeId(event));
|
||||
});
|
||||
const preview = new NativeNodePreview(app, api, {
|
||||
id: "mask-loader",
|
||||
graph: subgraph
|
||||
});
|
||||
|
||||
preview.clear();
|
||||
|
||||
expect(executionIds).toEqual([
|
||||
"cube-a:mask-loader",
|
||||
"cube-b:mask-loader",
|
||||
"container:nested-cube:mask-loader"
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears the same native output surface", () => {
|
||||
const app = { nodeOutputs: {} } as ComfyApp;
|
||||
const api = new EventTarget() as ComfyApi;
|
||||
@@ -56,3 +135,33 @@ describe("NativeNodePreview", () => {
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
function executedNodeId(event: Event): string {
|
||||
const detail: unknown = (event as CustomEvent<unknown>).detail;
|
||||
if (
|
||||
typeof detail !== "object" ||
|
||||
detail === null ||
|
||||
!("node" in detail) ||
|
||||
typeof detail.node !== "string"
|
||||
) {
|
||||
throw new TypeError("Executed event did not include a node identity.");
|
||||
}
|
||||
return detail.node;
|
||||
}
|
||||
|
||||
function previewApp(rootGraph: ComfyGraph): ComfyApp {
|
||||
return {
|
||||
nodeOutputs: {},
|
||||
rootGraph,
|
||||
ui: {
|
||||
settings: {
|
||||
addSetting<TValue extends SettingValue>(
|
||||
definition: ComfySettingDefinition<TValue>
|
||||
): ComfySetting<TValue> {
|
||||
return { value: definition.defaultValue };
|
||||
}
|
||||
}
|
||||
},
|
||||
registerExtension: () => undefined
|
||||
};
|
||||
}
|
||||
|
||||
@@ -400,7 +400,9 @@ describe("ordered-media node integration", () => {
|
||||
it("normalizes scalar workflows and survives Nodes 2.0 reactive assignments", async () => {
|
||||
const fixture = configured("saved.png", true);
|
||||
|
||||
fixture.node.onGraphConfigured?.();
|
||||
const onGraphConfigured = fixture.node.onGraphConfigured;
|
||||
if (!onGraphConfigured) throw new Error("Expected graph configuration callback.");
|
||||
onGraphConfigured();
|
||||
|
||||
expect(fixture.imageWidget.value).toEqual(["saved.png"]);
|
||||
await vi.waitFor(() => {
|
||||
@@ -410,6 +412,53 @@ describe("ordered-media node integration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("retains array-valued media when the legacy graph lifecycle clears the widget", async () => {
|
||||
const fixture = createFixture(["one.png", "two.png"]);
|
||||
fixture.node.onGraphConfigured = () => {
|
||||
fixture.imageWidget.value = undefined;
|
||||
};
|
||||
configureOrderedMediaNode(
|
||||
fixture.node,
|
||||
fixture.app,
|
||||
fixture.api,
|
||||
CONFIG
|
||||
);
|
||||
fixture.node.onGraphConfigured();
|
||||
|
||||
expect(fixture.imageWidget.value).toEqual(["one.png", "two.png"]);
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.app.nodeOutputs?.["7"]?.images).toEqual(
|
||||
references("one.png", "two.png")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("restores persisted widget values when Comfy clears the live media widget", async () => {
|
||||
const fixture = createFixture(undefined);
|
||||
fixture.node.widgets_values = [
|
||||
["one.png", "two.png"],
|
||||
"image",
|
||||
"ordered_media",
|
||||
"ordered_media"
|
||||
];
|
||||
configureOrderedMediaNode(
|
||||
fixture.node,
|
||||
fixture.app,
|
||||
fixture.api,
|
||||
CONFIG
|
||||
);
|
||||
fixture.imageWidget.value = undefined;
|
||||
|
||||
fixture.node.onGraphConfigured?.();
|
||||
|
||||
expect(fixture.imageWidget.value).toEqual(["one.png", "two.png"]);
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.app.nodeOutputs?.["7"]?.images).toEqual(
|
||||
references("one.png", "two.png")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("restores native handlers and clears preview on node removal", () => {
|
||||
const fixture = createFixture(["one.png"]);
|
||||
const paste = fixture.node.pasteFiles;
|
||||
@@ -511,6 +560,7 @@ function createFixture(value: unknown, reactive = false) {
|
||||
constructor: { comfyClass: "SimpleSyrup.LoadImageList" },
|
||||
id: 7,
|
||||
widgets,
|
||||
widgets_values: undefined as unknown[] | undefined,
|
||||
imgs: undefined as HTMLImageElement[] | undefined,
|
||||
graph: { setDirtyCanvas: vi.fn() },
|
||||
pasteFiles: vi.fn(() => true) as (...args: unknown[]) => unknown,
|
||||
|
||||
@@ -16,6 +16,39 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("OrderedMediaPreviewActions", () => {
|
||||
it("keeps Nodes 2.0 controls within the native preview stacking context", async () => {
|
||||
actionFixture(["one.png", "two.png"]);
|
||||
const root = document.createElement("section");
|
||||
root.dataset.nodeId = "7";
|
||||
const previewSurface = document.createElement("div");
|
||||
previewSurface.setAttribute("role", "region");
|
||||
previewSurface.setAttribute(
|
||||
"aria-label",
|
||||
"Image preview - Use arrow keys to navigate between images"
|
||||
);
|
||||
mockRect(previewSurface, 40, 60, 200, 100);
|
||||
const firstImage = previewImage("one.png", 50);
|
||||
const secondImage = previewImage("two.png", 130);
|
||||
mockRect(firstImage, 50, 70, 80, 80);
|
||||
mockRect(secondImage, 130, 70, 80, 80);
|
||||
previewSurface.append(firstImage, secondImage);
|
||||
root.append(previewSurface);
|
||||
document.body.append(root);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
previewSurface.querySelectorAll(".ss-native-preview-affordance")
|
||||
).toHaveLength(2);
|
||||
});
|
||||
const first = previewSurface.querySelector<HTMLElement>(
|
||||
".ss-native-preview-affordance"
|
||||
);
|
||||
expect(first?.style.position).toBe("absolute");
|
||||
expect(first?.style.left).toBe("10px");
|
||||
expect(first?.style.top).toBe("10px");
|
||||
expect(first?.style.zIndex).toBe("1");
|
||||
});
|
||||
|
||||
it("discovers Nodes 2.0 thumbnails by output reference", async () => {
|
||||
const fixture = actionFixture(["one.png", "two.png"]);
|
||||
const root = document.createElement("section");
|
||||
@@ -43,6 +76,23 @@ describe("OrderedMediaPreviewActions", () => {
|
||||
expect(fixture.remove).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("does not claim Nodes 2.0 DOM owned by another open workflow", async () => {
|
||||
const inactiveRoot = {};
|
||||
const fixture = actionFixture(["one.png", "two.png"], {
|
||||
graph: { _rootGraph: inactiveRoot }
|
||||
});
|
||||
fixture.app.rootGraph = {};
|
||||
const root = document.createElement("section");
|
||||
root.dataset.nodeId = "7";
|
||||
root.append(previewImage("one.png", 0), previewImage("two.png", 100));
|
||||
document.body.append(root);
|
||||
document.dispatchEvent(new MouseEvent("click"));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("positions controls from Nodes 1.0 imageRects", async () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
mockRect(canvas, 10, 20, 500, 500);
|
||||
@@ -75,27 +125,176 @@ describe("OrderedMediaPreviewActions", () => {
|
||||
expect(first?.style.width).toBe("80px");
|
||||
});
|
||||
|
||||
it("restores controls when native detail view returns to the grid", async () => {
|
||||
const node = { id: 7, imageIndex: null as number | null };
|
||||
it("anchors embedded Nodes 1.0 controls to SugarCubes' clipped face projection", async () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const container = document.createElement("div");
|
||||
mockRect(canvas, 0, 0, 800, 600);
|
||||
mockRect(container, 200, 300, 320, 240);
|
||||
const projectRect = vi.fn(
|
||||
([left, top, width, height]: readonly [number, number, number, number]) => ({
|
||||
left: 200 + left,
|
||||
top: 300 + top,
|
||||
width,
|
||||
height
|
||||
})
|
||||
);
|
||||
const projectionSymbol = Symbol.for("sugarcubes.cube-face-projection.v1");
|
||||
const fixture = actionFixture(
|
||||
["one.png", "two.png"],
|
||||
{
|
||||
pos: [0, 0],
|
||||
imageIndex: null,
|
||||
imageRects: [
|
||||
[10, 30, 80, 80],
|
||||
[90, 30, 80, 80]
|
||||
],
|
||||
[projectionSymbol]: { container, projectRect }
|
||||
}
|
||||
);
|
||||
const nativeProjection = vi.fn(() => {
|
||||
throw new Error("Root graph projection must not run for an embedded node.");
|
||||
});
|
||||
fixture.app.canvas = {
|
||||
canvas,
|
||||
convertEventToCanvasOffset: (event) => [event.clientX, event.clientY],
|
||||
convertOffsetToCanvas: nativeProjection
|
||||
};
|
||||
document.body.append(canvas, container);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(container.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(2);
|
||||
});
|
||||
const first = container.querySelector<HTMLElement>(
|
||||
".ss-native-preview-affordance"
|
||||
);
|
||||
expect(projectRect).toHaveBeenCalledWith([10, 30, 80, 80]);
|
||||
expect(nativeProjection).not.toHaveBeenCalled();
|
||||
expect(first?.style.position).toBe("absolute");
|
||||
expect(first?.style.left).toBe("10px");
|
||||
expect(first?.style.top).toBe("30px");
|
||||
expect(first?.style.width).toBe("80px");
|
||||
});
|
||||
|
||||
it("positions selected-item controls in Nodes 1.0 detail view", async () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
mockRect(canvas, 10, 20, 500, 500);
|
||||
const fixture = actionFixture(["one.png", "two.png"], {
|
||||
pos: [100, 200],
|
||||
size: [160, 260],
|
||||
imageIndex: 1,
|
||||
widgets: [
|
||||
{
|
||||
y: 30,
|
||||
computedHeight: 200,
|
||||
options: { canvasOnly: true }
|
||||
}
|
||||
]
|
||||
});
|
||||
fixture.app.canvas = {
|
||||
canvas,
|
||||
convertEventToCanvasOffset: (event) => [
|
||||
event.clientX - 10,
|
||||
event.clientY - 20
|
||||
],
|
||||
convertOffsetToCanvas: (position) => [...position]
|
||||
};
|
||||
document.body.append(canvas);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(1);
|
||||
});
|
||||
expect(
|
||||
document.querySelector('[aria-label="Move image 2 earlier"]')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
document.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Move image 2 later"]'
|
||||
)?.disabled
|
||||
).toBe(true);
|
||||
expect(
|
||||
document.querySelector<HTMLElement>(".ss-native-preview-affordance")
|
||||
?.style.width
|
||||
).toBe("160px");
|
||||
});
|
||||
|
||||
it("reuses the Nodes 1.0 grid footprint after detail clears imageRects", async () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
mockRect(canvas, 10, 20, 500, 500);
|
||||
const node = {
|
||||
id: 7,
|
||||
pos: [100, 200] as const,
|
||||
imageIndex: null as number | null,
|
||||
imageRects: [
|
||||
[0, 30, 80, 80],
|
||||
[80, 30, 80, 80]
|
||||
] as Array<readonly [number, number, number, number]>
|
||||
};
|
||||
const fixture = actionFixture(["one.png", "two.png"], node);
|
||||
const root = document.createElement("section");
|
||||
root.dataset.nodeId = "7";
|
||||
root.append(previewImage("one.png", 0), previewImage("two.png", 100));
|
||||
fixture.app.canvas = {
|
||||
canvas,
|
||||
convertEventToCanvasOffset: (event) => [
|
||||
event.clientX - 10,
|
||||
event.clientY - 20
|
||||
],
|
||||
convertOffsetToCanvas: (position) => [...position]
|
||||
};
|
||||
document.body.append(canvas);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(2);
|
||||
});
|
||||
|
||||
fixture.node.imageIndex = 1;
|
||||
delete (fixture.node as { imageRects?: unknown }).imageRects;
|
||||
document.dispatchEvent(new MouseEvent("click"));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(1);
|
||||
});
|
||||
expect(
|
||||
document.querySelector('[aria-label="Move image 2 earlier"]')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
document.querySelector<HTMLElement>(".ss-native-preview-affordance")
|
||||
?.style.width
|
||||
).toBe("160px");
|
||||
});
|
||||
|
||||
it("keeps selected-item controls while native detail view is open", async () => {
|
||||
const node = { id: 7 };
|
||||
const fixture = actionFixture(["one.png", "two.png"], node);
|
||||
const { root, select } = nativeDetailPreview();
|
||||
document.body.append(root);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(2);
|
||||
});
|
||||
select(0);
|
||||
|
||||
fixture.node.imageIndex = 0;
|
||||
document.dispatchEvent(new MouseEvent("click"));
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(0);
|
||||
expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(1);
|
||||
});
|
||||
|
||||
fixture.node.imageIndex = null;
|
||||
document.dispatchEvent(new MouseEvent("click"));
|
||||
document
|
||||
.querySelector<HTMLButtonElement>('[aria-label="Move image 1 later"]')
|
||||
?.click();
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(2);
|
||||
expect(
|
||||
document.querySelector('[aria-label="Remove image 2"]')
|
||||
).not.toBeNull();
|
||||
});
|
||||
expect(fixture.moveLater).toHaveBeenCalledWith(0);
|
||||
expect(
|
||||
root.querySelector('[aria-label="View image 2 of 2"]')?.getAttribute(
|
||||
"aria-current"
|
||||
)
|
||||
).toBe("true");
|
||||
|
||||
select(-1);
|
||||
fixture.preview.publish({
|
||||
images: references("two.png", "one.png"),
|
||||
animated: [false, false]
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
root.querySelector('[aria-label="View image 2 of 2"]')?.getAttribute(
|
||||
"aria-current"
|
||||
)
|
||||
).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,6 +324,12 @@ function actionFixture(
|
||||
const node = { id: 7, ...nodeOverrides } as {
|
||||
id: number;
|
||||
imageIndex?: number | null;
|
||||
imageRects?: Array<readonly [number, number, number, number]>;
|
||||
widgets?: Array<{
|
||||
y?: number;
|
||||
computedHeight?: number;
|
||||
options?: { canvasOnly?: boolean };
|
||||
}>;
|
||||
};
|
||||
const preview = new NativeNodePreview(app, api, node);
|
||||
const moveEarlier = vi.fn();
|
||||
@@ -141,7 +346,7 @@ function actionFixture(
|
||||
remove
|
||||
});
|
||||
adapters.push(adapter);
|
||||
return { adapter, app, moveEarlier, moveLater, node, remove };
|
||||
return { adapter, app, moveEarlier, moveLater, node, preview, remove };
|
||||
}
|
||||
|
||||
function previewImage(filename: string, left: number): HTMLImageElement {
|
||||
@@ -151,6 +356,42 @@ function previewImage(filename: string, left: number): HTMLImageElement {
|
||||
return image;
|
||||
}
|
||||
|
||||
function nativeDetailPreview(): {
|
||||
root: HTMLElement;
|
||||
select: (index: number) => void;
|
||||
} {
|
||||
const root = document.createElement("section");
|
||||
root.dataset.nodeId = "7";
|
||||
const region = document.createElement("div");
|
||||
region.setAttribute("role", "region");
|
||||
region.setAttribute(
|
||||
"aria-label",
|
||||
"Image preview - Use arrow keys to navigate between images"
|
||||
);
|
||||
mockRect(region, 20, 30, 160, 200);
|
||||
const navigation = document.createElement("div");
|
||||
const select = (index: number): void => {
|
||||
for (const [buttonIndex, button] of buttons.entries()) {
|
||||
if (buttonIndex === index) button.setAttribute("aria-current", "true");
|
||||
else button.removeAttribute("aria-current");
|
||||
}
|
||||
};
|
||||
const buttons = [0, 1].map((index) => {
|
||||
const button = document.createElement("button");
|
||||
button.setAttribute(
|
||||
"aria-label",
|
||||
`View image ${String(index + 1)} of 2`
|
||||
);
|
||||
button.addEventListener("click", () => {
|
||||
select(index);
|
||||
});
|
||||
return button;
|
||||
});
|
||||
navigation.append(...buttons);
|
||||
root.append(region, navigation);
|
||||
return { root, select };
|
||||
}
|
||||
|
||||
function mockRect(
|
||||
element: Element,
|
||||
left: number,
|
||||
|
||||
@@ -15,6 +15,80 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("OrderedMediaPreviewAffordances", () => {
|
||||
it("mounts controls inside their native preview surface", async () => {
|
||||
const previewSurface = document.createElement("div");
|
||||
previewSurface.getBoundingClientRect = () => new DOMRect(100, 200, 240, 180);
|
||||
document.body.append(previewSurface);
|
||||
affordances = new OrderedMediaPreviewAffordances({
|
||||
itemLabel: "mask",
|
||||
getSlots: () => [
|
||||
{
|
||||
itemIndex: 0,
|
||||
bounds: { left: 110, top: 220, width: 80, height: 100 },
|
||||
container: previewSurface
|
||||
}
|
||||
],
|
||||
getItemCount: () => 1,
|
||||
moveEarlier: vi.fn(),
|
||||
moveLater: vi.fn(),
|
||||
remove: vi.fn()
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(previewSurface.querySelector(".ss-native-preview-affordance")).not.toBeNull();
|
||||
});
|
||||
const root = previewSurface.querySelector<HTMLElement>(
|
||||
".ss-native-preview-affordance"
|
||||
);
|
||||
expect(previewSurface.style.position).toBe("relative");
|
||||
expect(root?.style.position).toBe("absolute");
|
||||
expect(root?.style.zIndex).toBe("1");
|
||||
expect(root?.style.left).toBe("10px");
|
||||
expect(root?.style.top).toBe("20px");
|
||||
expect(root?.style.borderRadius).toBe("");
|
||||
expect(root?.style.boxShadow).toBe("");
|
||||
|
||||
affordances.dispose();
|
||||
affordances = undefined;
|
||||
expect(previewSurface.style.position).toBe("");
|
||||
expect(previewSurface.querySelector(".ss-native-preview-affordance")).toBeNull();
|
||||
});
|
||||
|
||||
it("converts viewport geometry into a transformed preview surface", async () => {
|
||||
const previewSurface = document.createElement("div");
|
||||
Object.defineProperties(previewSurface, {
|
||||
offsetWidth: { value: 100 },
|
||||
offsetHeight: { value: 80 }
|
||||
});
|
||||
previewSurface.getBoundingClientRect = () => new DOMRect(100, 200, 200, 160);
|
||||
document.body.append(previewSurface);
|
||||
affordances = new OrderedMediaPreviewAffordances({
|
||||
itemLabel: "mask",
|
||||
getSlots: () => [
|
||||
{
|
||||
itemIndex: 0,
|
||||
bounds: { left: 120, top: 220, width: 80, height: 100 },
|
||||
container: previewSurface
|
||||
}
|
||||
],
|
||||
getItemCount: () => 1,
|
||||
moveEarlier: vi.fn(),
|
||||
moveLater: vi.fn(),
|
||||
remove: vi.fn()
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(previewSurface.querySelector(".ss-native-preview-affordance")).not.toBeNull();
|
||||
});
|
||||
const root = previewSurface.querySelector<HTMLElement>(
|
||||
".ss-native-preview-affordance"
|
||||
);
|
||||
expect(root?.style.left).toBe("10px");
|
||||
expect(root?.style.top).toBe("10px");
|
||||
expect(root?.style.width).toBe("40px");
|
||||
expect(root?.style.height).toBe("18px");
|
||||
});
|
||||
|
||||
it("positions native-icon list actions over every native slot", async () => {
|
||||
const moveEarlier = vi.fn();
|
||||
const moveLater = vi.fn();
|
||||
@@ -22,9 +96,16 @@ describe("OrderedMediaPreviewAffordances", () => {
|
||||
affordances = new OrderedMediaPreviewAffordances({
|
||||
itemLabel: "mask",
|
||||
getSlots: () => [
|
||||
{ left: 10, top: 20, width: 100, height: 120 },
|
||||
{ left: 120, top: 20, width: 80, height: 120 }
|
||||
{
|
||||
itemIndex: 0,
|
||||
bounds: { left: 10, top: 20, width: 100, height: 120 }
|
||||
},
|
||||
{
|
||||
itemIndex: 1,
|
||||
bounds: { left: 120, top: 20, width: 80, height: 120 }
|
||||
}
|
||||
],
|
||||
getItemCount: () => 2,
|
||||
moveEarlier,
|
||||
moveLater,
|
||||
remove
|
||||
@@ -72,11 +153,15 @@ describe("OrderedMediaPreviewAffordances", () => {
|
||||
itemLabel: "image",
|
||||
getSlots: () =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
left: index * 100,
|
||||
top: 0,
|
||||
width: 90,
|
||||
height: 90
|
||||
itemIndex: index,
|
||||
bounds: {
|
||||
left: index * 100,
|
||||
top: 0,
|
||||
width: 90,
|
||||
height: 90
|
||||
}
|
||||
})),
|
||||
getItemCount: () => count,
|
||||
moveEarlier: vi.fn(),
|
||||
moveLater: vi.fn(),
|
||||
remove: vi.fn()
|
||||
@@ -93,12 +178,20 @@ describe("OrderedMediaPreviewAffordances", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("disappears in detail view and returns after native grid navigation", async () => {
|
||||
it("disappears without a native slot and returns after navigation", async () => {
|
||||
let visible = true;
|
||||
affordances = new OrderedMediaPreviewAffordances({
|
||||
itemLabel: "image",
|
||||
getSlots: () =>
|
||||
visible ? [{ left: 10, top: 20, width: 100, height: 120 }] : [],
|
||||
visible
|
||||
? [
|
||||
{
|
||||
itemIndex: 0,
|
||||
bounds: { left: 10, top: 20, width: 100, height: 120 }
|
||||
}
|
||||
]
|
||||
: [],
|
||||
getItemCount: () => 1,
|
||||
moveEarlier: vi.fn(),
|
||||
moveLater: vi.fn(),
|
||||
remove: vi.fn()
|
||||
@@ -119,4 +212,42 @@ describe("OrderedMediaPreviewAffordances", () => {
|
||||
expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("hides viewport controls while the canvas is being manipulated", async () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
document.body.append(canvas);
|
||||
affordances = new OrderedMediaPreviewAffordances({
|
||||
itemLabel: "mask",
|
||||
getSlots: () => [
|
||||
{
|
||||
itemIndex: 0,
|
||||
bounds: { left: 10, top: 20, width: 100, height: 120 }
|
||||
}
|
||||
],
|
||||
getItemCount: () => 1,
|
||||
moveEarlier: vi.fn(),
|
||||
moveLater: vi.fn(),
|
||||
remove: vi.fn()
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
document.querySelector<HTMLElement>(".ss-native-preview-affordance")
|
||||
?.hidden
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
canvas.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
|
||||
expect(
|
||||
document.querySelector<HTMLElement>(".ss-native-preview-affordance")
|
||||
?.hidden
|
||||
).toBe(true);
|
||||
|
||||
document.dispatchEvent(new MouseEvent("pointerup", { bubbles: true }));
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
document.querySelector<HTMLElement>(".ss-native-preview-affordance")
|
||||
?.hidden
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user