From be348aeedae32f09b8769e558aba117c2a37ef4b Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Tue, 4 Aug 2026 21:41:32 -0400 Subject: [PATCH] fix(media): stabilize native ordered preview controls --- web/dist/simple-syrup.js | 541 +++++++++++++++--- web/src/nativeNodePreview.ts | 79 ++- web/src/nativePreviewLifecycle.ts | 3 +- web/src/orderedMediaNode.ts | 25 +- web/src/orderedMediaPreviewActions.ts | 447 +++++++++++++-- web/src/orderedMediaPreviewAffordances.ts | 142 ++++- web/src/segPreviewNativeSurface.ts | 5 +- web/src/types.ts | 13 + web/tests/nativeNodePreview.test.ts | 111 +++- web/tests/orderedMediaNode.test.ts | 52 +- web/tests/orderedMediaPreviewActions.test.ts | 273 ++++++++- .../orderedMediaPreviewAffordances.test.ts | 147 ++++- 12 files changed, 1645 insertions(+), 193 deletions(-) diff --git a/web/dist/simple-syrup.js b/web/dist/simple-syrup.js index fdd2338..5dceb46 100644 --- a/web/dist/simple-syrup.js +++ b/web/dist/simple-syrup.js @@ -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, diff --git a/web/src/nativeNodePreview.ts b/web/src/nativeNodePreview.ts index 4ef271c..eeedab0 100644 --- a/web/src/nativeNodePreview.ts +++ b/web/src/nativeNodePreview.ts @@ -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 = 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; } diff --git a/web/src/nativePreviewLifecycle.ts b/web/src/nativePreviewLifecycle.ts index c68387b..0ed8848 100644 --- a/web/src/nativePreviewLifecycle.ts +++ b/web/src/nativePreviewLifecycle.ts @@ -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"] }); } diff --git a/web/src/orderedMediaNode.ts b/web/src/orderedMediaNode.ts index 19c1cc1..ba5dbad 100644 --- a/web/src/orderedMediaNode.ts +++ b/web/src/orderedMediaNode.ts @@ -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, diff --git a/web/src/orderedMediaPreviewActions.ts b/web/src/orderedMediaPreviewActions.ts index ddee14f..67cf2aa 100644 --- a/web/src/orderedMediaPreviewActions.ts +++ b/web/src/orderedMediaPreviewActions.ts @@ -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("img") + ).filter((image) => { + const rect = image.getBoundingClientRect(); + return ( + comfyImageSourceKey(image.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. */ + private domPreviewContainer(images: HTMLImageElement[]): HTMLElement | null { + 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. */ + 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; + 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( + '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]; +} diff --git a/web/src/orderedMediaPreviewAffordances.ts b/web/src/orderedMediaPreviewAffordances.ts index 9285d3f..23ca5eb 100644 --- a/web/src/orderedMediaPreviewAffordances.ts +++ b/web/src/orderedMediaPreviewAffordances.ts @@ -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(); 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(); 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 + ): 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): 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; +} diff --git a/web/src/segPreviewNativeSurface.ts b/web/src/segPreviewNativeSurface.ts index 4d6c529..cdf4fbb 100644 --- a/web/src/segPreviewNativeSurface.ts +++ b/web/src/segPreviewNativeSurface.ts @@ -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 & diff --git a/web/src/types.ts b/web/src/types.ts index d502e27..951fce3 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -26,8 +26,20 @@ export interface ComfySettingsApi { ): ComfySetting; } +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; + 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( diff --git a/web/tests/nativeNodePreview.test.ts b/web/tests/nativeNodePreview.test.ts index 90cc48e..2a8e8d5 100644 --- a/web/tests/nativeNodePreview.test.ts +++ b/web/tests/nativeNodePreview.test.ts @@ -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).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( + definition: ComfySettingDefinition + ): ComfySetting { + return { value: definition.defaultValue }; + } + } + }, + registerExtension: () => undefined + }; +} diff --git a/web/tests/orderedMediaNode.test.ts b/web/tests/orderedMediaNode.test.ts index 06d7b1b..5921ccb 100644 --- a/web/tests/orderedMediaNode.test.ts +++ b/web/tests/orderedMediaNode.test.ts @@ -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, diff --git a/web/tests/orderedMediaPreviewActions.test.ts b/web/tests/orderedMediaPreviewActions.test.ts index e770a2c..ac783a2 100644 --- a/web/tests/orderedMediaPreviewActions.test.ts +++ b/web/tests/orderedMediaPreviewActions.test.ts @@ -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( + ".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( + ".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( + '[aria-label="Move image 2 later"]' + )?.disabled + ).toBe(true); + expect( + document.querySelector(".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 + }; 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(".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('[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; + 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, diff --git a/web/tests/orderedMediaPreviewAffordances.test.ts b/web/tests/orderedMediaPreviewAffordances.test.ts index 25b42b6..f99bd45 100644 --- a/web/tests/orderedMediaPreviewAffordances.test.ts +++ b/web/tests/orderedMediaPreviewAffordances.test.ts @@ -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( + ".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( + ".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(".ss-native-preview-affordance") + ?.hidden + ).toBe(false); + }); + + canvas.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); + expect( + document.querySelector(".ss-native-preview-affordance") + ?.hidden + ).toBe(true); + + document.dispatchEvent(new MouseEvent("pointerup", { bubbles: true })); + await vi.waitFor(() => { + expect( + document.querySelector(".ss-native-preview-affordance") + ?.hidden + ).toBe(false); + }); + }); });