fix(media): stabilize native ordered preview controls

This commit is contained in:
Artificial Sweetener
2026-08-04 21:41:32 -04:00
parent dce4327f9e
commit be348aeeda
12 changed files with 1645 additions and 193 deletions
+461 -80
View File
@@ -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,
+70 -9
View File
@@ -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;
}
+2 -1
View File
@@ -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"]
});
}
+23 -2
View File
@@ -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,
+395 -52
View File
@@ -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];
}
+120 -22
View File
@@ -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;
}
+4 -1
View File
@@ -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 &
+13
View File
@@ -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(
+110 -1
View File
@@ -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
};
}
+51 -1
View File
@@ -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,
+257 -16
View File
@@ -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);
});
});
});