fix: MFV seed randomize + subgraph widget live sync

- sidebarRunButton: add _walkAllNodes() to mirror ComfyUI's native
  beforeQueued/afterQueued widget cycle on all nodes incl. inner subgraph
  nodes so control_after_generate (randomize/increment/decrement) is
  applied correctly on each MFV run
- widgetAdapters: _syncWidgetDomElement falls back to
  cachedDeepestByFrame.widget.inputEl for PromotedWidgetView so typing
  in the sidebar updates the canvas textarea of subgraph outer widgets
  in real time
- widgetAdapters: spinner step forced to 1 for integer widgets;
  widget.value restored after callback to bypass step2 snapping
This commit is contained in:
Ewald
2026-04-22 10:19:53 +02:00
parent cb4d57407d
commit 6cfcc84c1a
31 changed files with 20435 additions and 17158 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

+31
View File
@@ -302,6 +302,7 @@ export function mountVideoControls(video, opts = {}) {
const controls = document.createElement("div");
controls.className = `mjr-video-controls mjr-video-controls--${variant}`;
if (isViewerBar) controls.classList.add("mjr-video-controls--modern");
controls.dataset.mjrLayout = "regular";
controls.setAttribute("role", "group");
controls.setAttribute(
"aria-label",
@@ -686,6 +687,36 @@ export function mountVideoControls(video, opts = {}) {
console.debug?.(e);
}
});
const applyResponsiveLayout = () => {
try {
const width = Number(hostEl?.clientWidth) || Number(controls?.clientWidth) || 0;
let layout = "regular";
if (width > 0 && width < 560) layout = "stacked";
else if (width > 0 && width < 860) layout = "compact";
controls.dataset.mjrLayout = layout;
} catch (e) {
console.debug?.(e);
}
};
applyResponsiveLayout();
try {
if (typeof ResizeObserver === "function" && hostEl) {
const ro = new ResizeObserver(() => applyResponsiveLayout());
ro.observe(hostEl);
unsubs.push(() => {
try {
ro.disconnect();
} catch (e) {
console.debug?.(e);
}
});
}
} catch (e) {
console.debug?.(e);
}
// Must NOT run in capture phase, otherwise it blocks pointerdown on child controls
// like the draggable In/Out handles. We stop bubbling instead, and rely on the
// window-capture guards below to prevent viewer pan/zoom handlers.
+3 -3
View File
@@ -366,12 +366,12 @@ app.registerExtension({
// 8b. Proactive early fetch — start the first assets page in the background
// so the data is ready (or already in-flight) when the user opens the
// sidebar. A 4-second delay lets ComfyUI finish its own startup work
// and the backend DB warm-up to settle before we hit /mjr/am/list.
// sidebar. 800ms lets ComfyUI finish registering extensions before we
// hit /mjr/am/list (was 4000ms — too long, caused visible blank grid).
// startEarlyFetch() is a no-op when ComfyUI is executing a prompt.
setTimeout(() => {
startEarlyFetch();
}, 4000);
}, 800);
// 9. WebSocket / realtime listeners.
void setupApiListeners(runtimeApp, executionRuntime).catch((e) =>
+1 -1
View File
@@ -1086,7 +1086,7 @@ async function _mountPanelRuntimeImpl(container, { useComfyThemeUI = true, exter
} catch (e) { console.debug?.(e); }
_autoLoadTimer =
assetsQueryController?.scheduleAutoLoad?.(initialLoadPromise, 2000) ?? null;
assetsQueryController?.scheduleAutoLoad?.(initialLoadPromise, 300) ?? null;
const renderDuration = endTimer("panelRender", "gridRender");
trackGridRender(renderDuration);
+300 -10
View File
@@ -90,7 +90,11 @@ import { disposeFloatingViewerProgressBar } from "./floatingViewerProgress.js";
function _hasSimplePlayerControls(mediaEl) {
try {
return !!mediaEl?.classList?.contains("mjr-mfv-simple-player");
return (
!!mediaEl?.classList?.contains("mjr-mfv-simple-player")
|| !!mediaEl?.classList?.contains("mjr-mfv-player-host")
|| !!mediaEl?.querySelector?.(".mjr-video-controls, .mjr-mfv-simple-player-controls")
);
} catch (e) {
console.debug?.(e);
return false;
@@ -183,6 +187,14 @@ export class FloatingViewer {
this._progressCurrentNodeId = null;
this._docClickHost = null;
this._handleDocClick = null;
this._mediaControlHandles = [];
this._layoutObserver = null;
this._channel = "rgb";
this._exposureEV = 0;
this._gridMode = 0;
this._overlayMaskEnabled = false;
this._overlayMaskOpacity = 0.65;
this._overlayFormat = "image";
}
_dispatchControllerAction(methodName, fallbackEventType) {
@@ -387,6 +399,223 @@ export class FloatingViewer {
this._dragging ? "mjr-mfv-content--grabbing" : "mjr-mfv-content--grab",
);
}
this._applyMediaToneControls();
this._redrawOverlayGuides();
}
_ensureToneFilterDefs() {
if (this._toneFilterDefsEl?.isConnected) return this._toneFilterDefsEl;
const svgNs = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(svgNs, "svg");
svg.setAttribute("aria-hidden", "true");
svg.style.position = "absolute";
svg.style.width = "0";
svg.style.height = "0";
svg.style.pointerEvents = "none";
const defs = document.createElementNS(svgNs, "defs");
const filters = [
["mjr-mfv-ch-r", "1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 1 0"],
["mjr-mfv-ch-g", "0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 1 0"],
["mjr-mfv-ch-b", "0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 1 0"],
["mjr-mfv-ch-a", "0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0"],
["mjr-mfv-ch-l", "0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0 0 0 1 0"],
];
for (const [id, values] of filters) {
const filter = document.createElementNS(svgNs, "filter");
filter.setAttribute("id", id);
const matrix = document.createElementNS(svgNs, "feColorMatrix");
matrix.setAttribute("type", "matrix");
matrix.setAttribute("values", values);
filter.appendChild(matrix);
defs.appendChild(filter);
}
svg.appendChild(defs);
this.element?.appendChild(svg);
this._toneFilterDefsEl = svg;
return svg;
}
_applyMediaToneControls() {
this._ensureToneFilterDefs();
if (!this._contentEl) return;
const channel = String(this._channel || "rgb");
const exposureScale = Math.pow(2, Number(this._exposureEV) || 0);
const channelFilter =
channel === "rgb" ? "" : `url(#mjr-mfv-ch-${channel})`;
const brightnessFilter =
Math.abs(exposureScale - 1) < 0.0001 ? "" : `brightness(${exposureScale})`;
const filterValue = [channelFilter, brightnessFilter].filter(Boolean).join(" ").trim();
const mediaEls = this._contentEl.querySelectorAll?.(".mjr-mfv-media") || [];
for (const el of mediaEls) {
try {
el.style.filter = filterValue || "";
} catch (e) {
console.debug?.(e);
}
}
}
_getOverlayAspect(format, mediaEl, panelRect) {
try {
const f = String(format || "image");
if (f === "image") {
const nw =
Number(mediaEl?.videoWidth)
|| Number(mediaEl?.naturalWidth)
|| Number(panelRect?.width)
|| 1;
const nh =
Number(mediaEl?.videoHeight)
|| Number(mediaEl?.naturalHeight)
|| Number(panelRect?.height)
|| 1;
const a = nw / nh;
return Number.isFinite(a) && a > 0 ? a : 1;
}
if (f === "16:9") return 16 / 9;
if (f === "9:16") return 9 / 16;
if (f === "1:1") return 1;
if (f === "4:3") return 4 / 3;
if (f === "2.39") return 2.39;
} catch (e) {
console.debug?.(e);
}
return 1;
}
_fitAspectInBox(boxW, boxH, aspect) {
try {
const bw = Number(boxW) || 0;
const bh = Number(boxH) || 0;
const a = Number(aspect) || 1;
if (!(bw > 0 && bh > 0 && a > 0)) return { x: 0, y: 0, w: bw, h: bh };
const boxA = bw / bh;
let w = bw;
let h = bh;
if (a >= boxA) {
h = bw / a;
} else {
w = bh * a;
}
return { x: (bw - w) / 2, y: (bh - h) / 2, w, h };
} catch (e) {
console.debug?.(e);
return { x: 0, y: 0, w: Number(boxW) || 0, h: Number(boxH) || 0 };
}
}
_drawMaskOutside(ctx, canvas, rects, alpha) {
try {
const a = Math.max(0, Math.min(0.92, Number(alpha) || 0));
if (!(a > 0)) return;
ctx.save();
ctx.fillStyle = `rgba(0,0,0,${a})`;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.globalCompositeOperation = "destination-out";
for (const r of rects) {
if (!r || !(r.w > 1 && r.h > 1)) continue;
ctx.fillRect(r.x, r.y, r.w, r.h);
}
ctx.restore();
} catch (e) {
console.debug?.(e);
}
}
_redrawOverlayGuides() {
const canvas = this._overlayCanvas;
const host = this._contentEl;
if (!canvas || !host) return;
const ctx = canvas.getContext?.("2d");
if (!ctx) return;
const dpr = Math.max(1, Math.min(3, Number(window.devicePixelRatio) || 1));
const cssW = host.clientWidth || 0;
const cssH = host.clientHeight || 0;
canvas.width = Math.max(1, Math.floor(cssW * dpr));
canvas.height = Math.max(1, Math.floor(cssH * dpr));
canvas.style.width = `${cssW}px`;
canvas.style.height = `${cssH}px`;
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!(this._gridMode || this._overlayMaskEnabled)) return;
const hostRect = host.getBoundingClientRect?.();
if (!hostRect) return;
const panels = Array.from(
host.querySelectorAll?.(
".mjr-mfv-simple-container, .mjr-mfv-side-panel, .mjr-mfv-grid-cell, .mjr-mfv-ab-layer",
) || [],
);
const panelEls = panels.length ? panels : [host];
const formatRects = [];
for (const panelEl of panelEls) {
const mediaEl = panelEl.querySelector?.(".mjr-mfv-media");
if (!mediaEl) continue;
const panelRect = panelEl.getBoundingClientRect?.();
if (!panelRect?.width || !panelRect?.height) continue;
const baseW = Number(panelRect.width) || 0;
const baseH = Number(panelRect.height) || 0;
const aspect = this._getOverlayAspect(this._overlayFormat, mediaEl, panelRect);
const fit = this._fitAspectInBox(baseW, baseH, aspect);
const centerX = (panelRect.left - hostRect.left) + baseW / 2;
const centerY = (panelRect.top - hostRect.top) + baseH / 2;
const z = Math.max(0.1, Math.min(16, Number(this._zoom) || 1));
const rectCss = {
x: centerX + fit.x * z - baseW * z / 2 + (Number(this._panX) || 0),
y: centerY + fit.y * z - baseH * z / 2 + (Number(this._panY) || 0),
w: fit.w * z,
h: fit.h * z,
};
formatRects.push({
x: rectCss.x * dpr,
y: rectCss.y * dpr,
w: rectCss.w * dpr,
h: rectCss.h * dpr,
});
}
if (!formatRects.length) return;
if (this._overlayMaskEnabled) {
this._drawMaskOutside(ctx, canvas, formatRects, this._overlayMaskOpacity);
ctx.save();
ctx.setLineDash?.([Math.max(2, 4 * dpr), Math.max(2, 3 * dpr)]);
ctx.strokeStyle = "rgba(255,255,255,0.22)";
ctx.lineWidth = Math.max(1, Math.floor(dpr));
for (const r of formatRects) {
ctx.strokeRect(r.x + 0.5, r.y + 0.5, r.w - 1, r.h - 1);
}
ctx.restore();
}
if (this._mode !== MFV_MODES.SIMPLE || !this._gridMode) return;
const rect = formatRects[0];
if (!rect) return;
ctx.save();
ctx.translate(rect.x, rect.y);
ctx.strokeStyle = "rgba(255,255,255,0.22)";
ctx.lineWidth = Math.max(2, Math.round(1.25 * dpr));
const drawLine = (x1, y1, x2, y2) => {
ctx.beginPath();
ctx.moveTo(Math.round(x1) + 0.5, Math.round(y1) + 0.5);
ctx.lineTo(Math.round(x2) + 0.5, Math.round(y2) + 0.5);
ctx.stroke();
};
if (this._gridMode === 1) {
drawLine(rect.w / 3, 0, rect.w / 3, rect.h);
drawLine((2 * rect.w) / 3, 0, (2 * rect.w) / 3, rect.h);
drawLine(0, rect.h / 3, rect.w, rect.h / 3);
drawLine(0, (2 * rect.h) / 3, rect.w, (2 * rect.h) / 3);
} else if (this._gridMode === 2) {
drawLine(rect.w / 2, 0, rect.w / 2, rect.h);
drawLine(0, rect.h / 2, rect.w, rect.h / 2);
} else if (this._gridMode === 3) {
ctx.strokeRect(rect.w * 0.1 + 0.5, rect.h * 0.1 + 0.5, rect.w * 0.8 - 1, rect.h * 0.8 - 1);
ctx.strokeRect(rect.w * 0.05 + 0.5, rect.h * 0.05 + 0.5, rect.w * 0.9 - 1, rect.h * 0.9 - 1);
}
ctx.restore();
}
/**
@@ -422,6 +651,30 @@ export class FloatingViewer {
this._applyTransform();
}
_bindLayoutObserver() {
this._unbindLayoutObserver();
const target = this._contentEl;
if (!target || typeof ResizeObserver === "undefined") return;
try {
this._layoutObserver = new ResizeObserver(() => {
this._applyTransform();
});
this._layoutObserver.observe(target);
} catch (e) {
console.debug?.(e);
this._layoutObserver = null;
}
}
_unbindLayoutObserver() {
try {
this._layoutObserver?.disconnect?.();
} catch (e) {
console.debug?.(e);
}
this._layoutObserver = null;
}
/** Bind wheel + pointer events to the clip viewport element. */
_initPanZoom(contentEl) {
this._destroyPanZoom();
@@ -434,7 +687,7 @@ export class FloatingViewer {
"wheel",
(e) => {
if (e.target?.closest?.("audio")) return;
if (e.target?.closest?.(".mjr-mfv-simple-player-controls")) return;
if (e.target?.closest?.(".mjr-video-controls, .mjr-mfv-simple-player-controls")) return;
if (isModel3DInteractionTarget(e.target)) return;
const scrollableAncestor = _findScrollableAncestor(e.target, contentEl);
if (
@@ -470,7 +723,7 @@ export class FloatingViewer {
// Let native video controls and the AB divider handle their own events.
if (e.target?.closest?.("video")) return;
if (e.target?.closest?.("audio")) return;
if (e.target?.closest?.(".mjr-mfv-simple-player-controls")) return;
if (e.target?.closest?.(".mjr-video-controls, .mjr-mfv-simple-player-controls")) return;
if (e.target?.closest?.(".mjr-mfv-ab-divider")) return;
if (isModel3DInteractionTarget(e.target)) return;
e.preventDefault();
@@ -521,7 +774,7 @@ export class FloatingViewer {
(e) => {
if (e.target?.closest?.("video")) return;
if (e.target?.closest?.("audio")) return;
if (e.target?.closest?.(".mjr-mfv-simple-player-controls")) return;
if (e.target?.closest?.(".mjr-video-controls, .mjr-mfv-simple-player-controls")) return;
if (isModel3DInteractionTarget(e.target)) return;
const isNearFit = Math.abs(this._zoom - 1) < 0.05;
this._setMfvZoom(isNearFit ? Math.min(4, this._zoom * 4) : 1, e.clientX, e.clientY);
@@ -550,6 +803,28 @@ export class FloatingViewer {
this._compareSyncAC = null;
}
_destroyMediaControls() {
const handles = Array.isArray(this._mediaControlHandles) ? this._mediaControlHandles : [];
for (const handle of handles) {
try {
handle?.destroy?.();
} catch (e) {
console.debug?.(e);
}
}
this._mediaControlHandles = [];
}
_trackMediaControls(rootEl) {
try {
const handle = rootEl?._mjrMediaControlsHandle || null;
if (handle?.destroy) this._mediaControlHandles.push(handle);
} catch (e) {
console.debug?.(e);
}
return rootEl;
}
_initCompareSync() {
this._destroyCompareSync();
if (!this._contentEl) return;
@@ -573,6 +848,8 @@ export class FloatingViewer {
// Tear down previous panzoom bindings before clearing DOM.
this._destroyPanZoom();
this._destroyCompareSync();
this._destroyMediaControls();
const overlayCanvas = this._overlayCanvas || null;
this._contentEl.replaceChildren();
this._contentEl.style.overflow = "hidden";
@@ -591,10 +868,15 @@ export class FloatingViewer {
break;
}
if (overlayCanvas) {
this._contentEl.appendChild(overlayCanvas);
}
if (this._mediaProgressEl) {
this._contentEl.appendChild(this._mediaProgressEl);
}
this._applyMediaToneControls();
this._applyTransform();
this._initPanZoom(this._contentEl);
this._initCompareSync();
@@ -606,7 +888,8 @@ export class FloatingViewer {
return;
}
const mediaKind = _mediaKind(this._mediaA);
const mediaEl = _buildMediaEl(this._mediaA);
const rawMediaEl = _buildMediaEl(this._mediaA);
const mediaEl = this._trackMediaControls?.(rawMediaEl) || rawMediaEl;
if (!mediaEl) {
this._contentEl.appendChild(_makeEmptyState("Could not load media"));
return;
@@ -631,8 +914,10 @@ export class FloatingViewer {
}
_renderAB() {
const elA = this._mediaA ? _buildMediaEl(this._mediaA, { fill: true }) : null;
const elB = this._mediaB ? _buildMediaEl(this._mediaB, { fill: true }) : null;
const rawElA = this._mediaA ? _buildMediaEl(this._mediaA, { fill: true }) : null;
const rawElB = this._mediaB ? _buildMediaEl(this._mediaB, { fill: true }) : null;
const elA = this._trackMediaControls?.(rawElA) || rawElA;
const elB = this._trackMediaControls?.(rawElB) || rawElB;
const kindA = this._mediaA ? _mediaKind(this._mediaA) : "";
const kindB = this._mediaB ? _mediaKind(this._mediaB) : "";
@@ -745,8 +1030,10 @@ export class FloatingViewer {
}
_renderSide() {
const elA = this._mediaA ? _buildMediaEl(this._mediaA) : null;
const elB = this._mediaB ? _buildMediaEl(this._mediaB) : null;
const rawElA = this._mediaA ? _buildMediaEl(this._mediaA) : null;
const rawElB = this._mediaB ? _buildMediaEl(this._mediaB) : null;
const elA = this._trackMediaControls?.(rawElA) || rawElA;
const elB = this._trackMediaControls?.(rawElB) || rawElB;
const kindA = this._mediaA ? _mediaKind(this._mediaA) : "";
const kindB = this._mediaB ? _mediaKind(this._mediaB) : "";
@@ -822,7 +1109,8 @@ export class FloatingViewer {
cell.className = "mjr-mfv-grid-cell";
if (media) {
const kind = _mediaKind(media);
const el = _buildMediaEl(media);
const rawEl = _buildMediaEl(media);
const el = this._trackMediaControls?.(rawEl) || rawEl;
if (el) cell.appendChild(el);
else cell.appendChild(_makeEmptyState("—"));
cell.appendChild(
@@ -970,6 +1258,8 @@ export class FloatingViewer {
disposeFloatingViewerProgressBar(this);
this._destroyPanZoom();
this._destroyCompareSync();
this._destroyMediaControls();
this._unbindLayoutObserver();
this._stopEdgeResize();
this._clearPopoutCloseWatch();
try {
+26 -4
View File
@@ -4,7 +4,9 @@ import {
createModel3DMediaElement,
MODEL3D_EXTS,
} from "./model3dRenderer.js";
import { mountUnifiedMediaControls } from "./mediaPlayer.js";
import { mountFloatingViewerSimplePlayer } from "./floatingViewerSimplePlayer.js";
import { readAssetFps, readAssetFrameCount } from "../../utils/mediaFps.js";
const VIDEO_EXTS = new Set([".mp4", ".webm", ".mov", ".avi", ".mkv"]);
const AUDIO_EXTS = new Set([".mp3", ".wav", ".flac", ".ogg", ".m4a", ".aac", ".opus", ".wma"]);
@@ -128,6 +130,28 @@ export function buildFloatingViewerMediaElement(fileData, { fill = false } = {})
ext === ".webp" &&
Number(fileData?.duration ?? fileData?.metadata_raw?.duration ?? 0) > 0;
const buildPlayableMediaHost = (mediaEl, mediaKind) => {
const host = document.createElement("div");
host.className = `mjr-mfv-player-host${fill ? " mjr-mfv-player-host--fill" : ""}`;
host.appendChild(mediaEl);
const mounted = mountUnifiedMediaControls(mediaEl, {
variant: "viewer",
hostEl: host,
mediaKind,
initialFps: readAssetFps(fileData) || undefined,
initialFrameCount: readAssetFrameCount(fileData) || undefined,
});
try {
if (mounted) host._mjrMediaControlsHandle = mounted;
} catch (e) {
console.debug?.(e);
}
return host;
};
if (kind === "audio") {
const audio = document.createElement("audio");
audio.className = mediaClass;
@@ -143,8 +167,7 @@ export function buildFloatingViewerMediaElement(fileData, { fill = false } = {})
console.debug?.(e);
}
attemptFloatingViewerAutoplay(audio);
const playerRoot = mountFloatingViewerSimplePlayer(audio, fileData, { kind: "audio" });
return playerRoot || audio;
return buildPlayableMediaHost(audio, "audio");
}
if (kind === "video") {
@@ -156,8 +179,7 @@ export function buildFloatingViewerMediaElement(fileData, { fill = false } = {})
v.muted = true;
v.autoplay = true;
v.playsInline = true;
const playerRoot = mountFloatingViewerSimplePlayer(v, fileData, { kind: "video" });
return playerRoot || v;
return buildPlayableMediaHost(v, "video");
}
if (kind === "model3d") {
+5 -1
View File
@@ -45,8 +45,10 @@ export function updateFloatingViewerPinUI(viewer) {
const active = viewer._pinnedSlots.has(slot);
btn.classList.toggle("is-pinned", active);
btn.setAttribute("aria-pressed", String(active));
btn.title = active ? `Unpin ${slot}` : `Pin ${slot}`;
btn.title = active ? `Unpin Asset ${slot}` : `Pin Asset ${slot}`;
}
// Highlight pin trigger when any slot is pinned
viewer._pinBtn?.classList.toggle("is-on", (viewer._pinnedSlots?.size ?? 0) > 0);
}
export function updateFloatingViewerModeButtonUI(viewer) {
@@ -69,6 +71,8 @@ export function updateFloatingViewerModeButtonUI(viewer) {
viewer._modeBtn.title = tooltip;
viewer._modeBtn.setAttribute("aria-label", tooltip);
viewer._modeBtn.removeAttribute("aria-pressed");
// Highlight when any comparison mode is active
viewer._modeBtn.classList.toggle("is-on", viewer._mode !== MFV_MODES.SIMPLE);
}
export function setFloatingViewerLiveActive(viewer, active) {
+672 -14
View File
@@ -22,6 +22,100 @@ import {
sanitizePromptForDisplay,
} from "../../components/sidebar/parsers/geninfoParser.js";
// ---------------------------------------------------------------------------
// Toolbar icon-dropdown helper
// ---------------------------------------------------------------------------
function _mkIconDrop(triggerHtml, title, items, viewer) {
const wrap = document.createElement("div");
wrap.className = "mjr-mfv-idrop";
const trigger = document.createElement("button");
trigger.type = "button";
trigger.className = "mjr-icon-btn mjr-mfv-idrop-trigger";
trigger.title = title || "";
trigger.innerHTML = triggerHtml;
trigger.setAttribute("aria-haspopup", "listbox");
trigger.setAttribute("aria-expanded", "false");
wrap.appendChild(trigger);
const menu = document.createElement("div");
menu.className = "mjr-mfv-idrop-menu";
menu.setAttribute("role", "listbox");
wrap.appendChild(menu);
// Hidden select: existing event handlers read .value from it and bind "change"
const hiddenSel = document.createElement("select");
hiddenSel.style.cssText = "position:absolute;opacity:0;pointer-events:none;width:0;height:0;overflow:hidden;";
wrap.appendChild(hiddenSel);
const itemEls = [];
for (const item of items) {
const opt = document.createElement("option");
opt.value = String(item.value);
hiddenSel.appendChild(opt);
const row = document.createElement("button");
row.type = "button";
row.className = "mjr-mfv-idrop-item";
row.setAttribute("role", "option");
row.dataset.value = String(item.value);
row.innerHTML = item.html ?? String(item.label ?? item.value);
menu.appendChild(row);
itemEls.push(row);
}
const _closeMenu = () => {
menu.classList.remove("is-open");
trigger.setAttribute("aria-expanded", "false");
};
trigger.addEventListener("click", (e) => {
e.stopPropagation();
const isOpen = menu.classList.contains("is-open");
// Close all other open icon-dropdowns in this viewer
viewer?.element?.querySelectorAll?.(".mjr-mfv-idrop-menu.is-open").forEach(m => m.classList.remove("is-open"));
if (!isOpen) {
menu.classList.add("is-open");
trigger.setAttribute("aria-expanded", "true");
}
});
menu.addEventListener("click", (e) => {
const row = e.target.closest(".mjr-mfv-idrop-item");
if (!row) return;
hiddenSel.value = row.dataset.value;
hiddenSel.dispatchEvent(new Event("change", { bubbles: true }));
itemEls.forEach(i => {
i.classList.toggle("is-selected", i === row);
i.setAttribute("aria-selected", String(i === row));
});
_closeMenu();
});
const selectItem = (val) => {
hiddenSel.value = String(val);
itemEls.forEach(i => {
i.classList.toggle("is-selected", i.dataset.value === String(val));
i.setAttribute("aria-selected", String(i.dataset.value === String(val)));
});
};
return { wrap, trigger, menu, select: hiddenSel, selectItem };
}
// Channel letter coloring
const _CH_COLOR = { rgb: "#e0e0e0", r: "#ff5555", g: "#44dd66", b: "#5599ff", a: "#ffffff", l: "#bbbbbb" };
const _CH_LABEL = { rgb: "RGB", r: "R", g: "G", b: "B", a: "A", l: "L" };
const _CH_WEIGHT = { rgb: "500", r: "700", g: "700", b: "700", a: "700", l: "400" };
function _channelHtml(ch) {
const k = String(ch || "rgb").toLowerCase();
const color = _CH_COLOR[k] || "#e0e0e0";
const label = _CH_LABEL[k] || k.toUpperCase();
const fw = _CH_WEIGHT[k] || "500";
return `<span class="mjr-mfv-ch-label" style="color:${color};font-weight:${fw}">${label}</span>`;
}
export function renderFloatingViewer(viewer) {
const el = document.createElement("div");
el.className = "mjr-mfv";
@@ -42,6 +136,9 @@ export function renderFloatingViewer(viewer) {
viewer._contentEl = document.createElement("div");
viewer._contentEl.className = "mjr-mfv-content";
viewer._contentWrapper.appendChild(viewer._contentEl);
viewer._overlayCanvas = document.createElement("canvas");
viewer._overlayCanvas.className = "mjr-mfv-overlay-canvas";
viewer._contentEl.appendChild(viewer._overlayCanvas);
viewer._contentEl.appendChild(buildFloatingViewerMediaProgressOverlay(viewer));
viewer._sidebar = new WorkflowSidebar({
@@ -56,6 +153,7 @@ export function renderFloatingViewer(viewer) {
viewer._rebindControlHandlers();
viewer._bindPanelInteractions();
viewer._bindDocumentUiHandlers();
viewer._bindLayoutObserver?.();
viewer._onSidebarPosChanged = (e) => {
if (e?.detail?.key === "viewer.mfvSidebarPosition") {
@@ -75,7 +173,7 @@ export function buildFloatingViewerHeader(viewer) {
const title = document.createElement("span");
title.className = "mjr-mfv-header-title";
title.id = viewer._titleId;
title.textContent = "〽️ Majoor Viewer Lite";
title.textContent = "〽️ Majoor Floating Viewer";
const closeBtn = document.createElement("button");
viewer._closeBtn = closeBtn;
@@ -93,6 +191,37 @@ export function buildFloatingViewerHeader(viewer) {
}
export function buildFloatingViewerToolbar(viewer) {
const createMiniSelect = (title, options) => {
const s = document.createElement("select");
s.className = "mjr-mfv-toolbar-select";
s.title = title || "";
for (const option of options || []) {
const opt = document.createElement("option");
opt.value = String(option.value);
opt.textContent = String(option.label);
s.appendChild(opt);
}
return s;
};
const createMiniRange = (title, { min, max, step, value } = {}) => {
const wrap = document.createElement("div");
wrap.className = "mjr-mfv-toolbar-range";
const input = document.createElement("input");
input.type = "range";
input.min = String(min);
input.max = String(max);
input.step = String(step);
input.value = String(value);
input.title = title || "";
const out = document.createElement("span");
out.className = "mjr-mfv-toolbar-range-out";
out.textContent = Number(value).toFixed(2);
wrap.appendChild(input);
wrap.appendChild(out);
return { wrap, input, out };
};
const bar = document.createElement("div");
bar.className = "mjr-mfv-toolbar";
@@ -102,24 +231,448 @@ export function buildFloatingViewerToolbar(viewer) {
viewer._updateModeBtnUI();
bar.appendChild(viewer._modeBtn);
viewer._pinGroup = document.createElement("div");
viewer._pinGroup.className = "mjr-mfv-pin-group";
viewer._pinGroup.setAttribute("role", "group");
viewer._pinGroup.setAttribute("aria-label", "Pin References");
// Pin: icon button + sort-style context menu popover (stays open for multi-toggle)
const pinBtn = document.createElement("button");
pinBtn.type = "button";
pinBtn.className = "mjr-icon-btn mjr-mfv-pin-trigger";
pinBtn.title = "Pin slots A/B/C/D";
pinBtn.setAttribute("aria-haspopup", "dialog");
pinBtn.setAttribute("aria-expanded", "false");
pinBtn.innerHTML = '<i class="pi pi-map-marker" aria-hidden="true"></i>';
bar.appendChild(pinBtn);
const pinPopover = document.createElement("div");
pinPopover.className = "mjr-mfv-pin-popover";
viewer.element.appendChild(pinPopover);
const pinMenuDiv = document.createElement("div");
pinMenuDiv.className = "mjr-menu";
pinMenuDiv.style.cssText = "display:grid;gap:4px;";
pinMenuDiv.setAttribute("role", "group");
pinMenuDiv.setAttribute("aria-label", "Pin References");
pinPopover.appendChild(pinMenuDiv);
viewer._pinGroup = pinMenuDiv;
viewer._pinBtns = {};
for (const slot of ["A", "B", "C", "D"]) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "mjr-mfv-pin-btn";
btn.textContent = slot;
btn.className = "mjr-menu-item mjr-mfv-pin-btn";
btn.dataset.slot = slot;
btn.title = `Pin ${slot}`;
btn.title = `Pin Asset ${slot}`;
btn.setAttribute("aria-pressed", "false");
const lbl = document.createElement("span");
lbl.className = "mjr-menu-item-label";
lbl.textContent = `Asset ${slot}`;
const chk = document.createElement("i");
chk.className = "pi pi-map-marker mjr-menu-item-check";
chk.style.opacity = "0";
btn.appendChild(lbl);
btn.appendChild(chk);
pinMenuDiv.appendChild(btn);
viewer._pinBtns[slot] = btn;
viewer._pinGroup.appendChild(btn);
}
viewer._updatePinUI();
bar.appendChild(viewer._pinGroup);
viewer._pinBtn = pinBtn;
viewer._pinPopover = pinPopover;
const _openPinPopover = () => {
const btnRect = pinBtn.getBoundingClientRect();
const parentRect = viewer.element.getBoundingClientRect();
pinPopover.style.left = `${btnRect.left - parentRect.left}px`;
pinPopover.style.top = `${btnRect.bottom - parentRect.top + 4}px`;
pinPopover.classList.add("is-open");
pinBtn.setAttribute("aria-expanded", "true");
};
viewer._closePinPopover = () => {
pinPopover.classList.remove("is-open");
pinBtn.setAttribute("aria-expanded", "false");
};
pinBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (pinPopover.classList.contains("is-open")) {
viewer._closePinPopover();
} else {
viewer._closeAllToolbarPopovers?.();
_openPinPopover();
}
});
// Note: pinMenuDiv click is handled by rebindFloatingViewerControlHandlers via event delegation.
// Dropdown stays open to allow toggling multiple slots.
// Guides: icon button + sort-style context menu popover
const guideBtn = document.createElement("button");
guideBtn.type = "button";
guideBtn.className = "mjr-icon-btn mjr-mfv-guides-trigger";
guideBtn.title = "Guides";
guideBtn.setAttribute("aria-haspopup", "listbox");
guideBtn.setAttribute("aria-expanded", "false");
const guideImg = document.createElement("img");
guideImg.src = new URL("../../assets/guides-icon.png", import.meta.url).href;
guideImg.className = "mjr-mfv-guides-icon";
guideImg.alt = "";
guideImg.setAttribute("aria-hidden", "true");
guideBtn.appendChild(guideImg);
bar.appendChild(guideBtn);
const guidePopover = document.createElement("div");
guidePopover.className = "mjr-mfv-guides-popover";
viewer.element.appendChild(guidePopover);
const guideMenu = document.createElement("div");
guideMenu.className = "mjr-menu";
guideMenu.style.cssText = "display:grid;gap:4px;";
guidePopover.appendChild(guideMenu);
const guideHiddenSel = document.createElement("select");
guideHiddenSel.style.cssText = "position:absolute;opacity:0;pointer-events:none;width:0;height:0;overflow:hidden;";
guidePopover.appendChild(guideHiddenSel);
const _GUIDE_OPTS = [
{ value: "0", label: "Off" },
{ value: "1", label: "Thirds" },
{ value: "2", label: "Center" },
{ value: "3", label: "Safe" },
];
const _initGuide = String(viewer._gridMode || 0);
const guideItemEls = [];
for (const opt of _GUIDE_OPTS) {
const optEl = document.createElement("option");
optEl.value = opt.value;
guideHiddenSel.appendChild(optEl);
const item = document.createElement("button");
item.type = "button";
item.className = "mjr-menu-item";
item.dataset.value = opt.value;
const lbl = document.createElement("span");
lbl.className = "mjr-menu-item-label";
lbl.textContent = opt.label;
const chk = document.createElement("i");
chk.className = "pi pi-check mjr-menu-item-check";
chk.style.opacity = opt.value === _initGuide ? "1" : "0";
item.appendChild(lbl);
item.appendChild(chk);
guideMenu.appendChild(item);
guideItemEls.push(item);
if (opt.value === _initGuide) item.classList.add("is-active");
}
guideHiddenSel.value = _initGuide;
guideBtn.classList.toggle("is-on", _initGuide !== "0");
viewer._guidesSelect = guideHiddenSel;
viewer._guideBtn = guideBtn;
viewer._guidePopover = guidePopover;
const _openGuidePopover = () => {
const btnRect = guideBtn.getBoundingClientRect();
const parentRect = viewer.element.getBoundingClientRect();
guidePopover.style.left = `${btnRect.left - parentRect.left}px`;
guidePopover.style.top = `${btnRect.bottom - parentRect.top + 4}px`;
guidePopover.classList.add("is-open");
guideBtn.setAttribute("aria-expanded", "true");
};
viewer._closeGuidePopover = () => {
guidePopover.classList.remove("is-open");
guideBtn.setAttribute("aria-expanded", "false");
};
guideBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (guidePopover.classList.contains("is-open")) {
viewer._closeGuidePopover();
} else {
viewer._closeAllToolbarPopovers?.();
_openGuidePopover();
}
});
guideMenu.addEventListener("click", (e) => {
const item = e.target.closest(".mjr-menu-item");
if (!item) return;
const val = item.dataset.value;
guideHiddenSel.value = val;
guideHiddenSel.dispatchEvent(new Event("change", { bubbles: true }));
guideItemEls.forEach(it => {
const active = it.dataset.value === val;
it.classList.toggle("is-active", active);
it.querySelector(".mjr-menu-item-check").style.opacity = active ? "1" : "0";
});
guideBtn.classList.toggle("is-on", val !== "0");
viewer._closeGuidePopover();
});
// Channel: icon button + sort-style context menu popover
const _initCh = String(viewer._channel || "rgb");
const chBtn = document.createElement("button");
chBtn.type = "button";
chBtn.className = "mjr-icon-btn mjr-mfv-ch-trigger";
chBtn.title = "Channel";
chBtn.setAttribute("aria-haspopup", "listbox");
chBtn.setAttribute("aria-expanded", "false");
const chImg = document.createElement("img");
chImg.src = new URL("../../assets/channel-icon.png", import.meta.url).href;
chImg.className = "mjr-mfv-ch-icon";
chImg.alt = "";
chImg.setAttribute("aria-hidden", "true");
chBtn.appendChild(chImg);
bar.appendChild(chBtn);
const _updateChBtnDisplay = (val) => {
if (!val || val === "rgb") {
chBtn.replaceChildren(chImg);
} else {
const color = _CH_COLOR[val] || "#e0e0e0";
const weight = _CH_WEIGHT[val] || "500";
const label = _CH_LABEL[val] || val.toUpperCase();
const span = document.createElement("span");
span.className = "mjr-mfv-ch-label";
span.style.color = color;
span.style.fontWeight = weight;
span.textContent = label;
chBtn.replaceChildren(span);
}
};
const chPopover = document.createElement("div");
chPopover.className = "mjr-mfv-ch-popover";
viewer.element.appendChild(chPopover);
const chMenu = document.createElement("div");
chMenu.className = "mjr-menu";
chMenu.style.cssText = "display:grid;gap:4px;";
chPopover.appendChild(chMenu);
const chHiddenSel = document.createElement("select");
chHiddenSel.style.cssText = "position:absolute;opacity:0;pointer-events:none;width:0;height:0;overflow:hidden;";
chPopover.appendChild(chHiddenSel);
const _CH_OPTS = [
{ value: "rgb", color: "#e0e0e0", weight: "500", label: "RGB" },
{ value: "r", color: "#ff5555", weight: "700", label: "R" },
{ value: "g", color: "#44dd66", weight: "700", label: "G" },
{ value: "b", color: "#5599ff", weight: "700", label: "B" },
{ value: "a", color: "#ffffff", weight: "700", label: "A" },
{ value: "l", color: "#bbbbbb", weight: "400", label: "L" },
];
const chItemEls = [];
for (const opt of _CH_OPTS) {
const optEl = document.createElement("option");
optEl.value = opt.value;
chHiddenSel.appendChild(optEl);
const item = document.createElement("button");
item.type = "button";
item.className = "mjr-menu-item";
item.dataset.value = opt.value;
const lbl = document.createElement("span");
lbl.className = "mjr-menu-item-label";
const colored = document.createElement("span");
colored.textContent = opt.label;
colored.style.color = opt.color;
colored.style.fontWeight = opt.weight;
lbl.appendChild(colored);
const chk = document.createElement("i");
chk.className = "pi pi-check mjr-menu-item-check";
chk.style.opacity = opt.value === _initCh ? "1" : "0";
item.appendChild(lbl);
item.appendChild(chk);
chMenu.appendChild(item);
chItemEls.push(item);
if (opt.value === _initCh) item.classList.add("is-active");
}
chHiddenSel.value = _initCh;
_updateChBtnDisplay(_initCh);
chBtn.classList.toggle("is-on", _initCh !== "rgb");
viewer._channelSelect = chHiddenSel;
viewer._chBtn = chBtn;
viewer._chPopover = chPopover;
const _openChPopover = () => {
const btnRect = chBtn.getBoundingClientRect();
const parentRect = viewer.element.getBoundingClientRect();
chPopover.style.left = `${btnRect.left - parentRect.left}px`;
chPopover.style.top = `${btnRect.bottom - parentRect.top + 4}px`;
chPopover.classList.add("is-open");
chBtn.setAttribute("aria-expanded", "true");
};
viewer._closeChPopover = () => {
chPopover.classList.remove("is-open");
chBtn.setAttribute("aria-expanded", "false");
};
chBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (chPopover.classList.contains("is-open")) {
viewer._closeChPopover();
} else {
viewer._closeAllToolbarPopovers?.();
_openChPopover();
}
});
chMenu.addEventListener("click", (e) => {
const item = e.target.closest(".mjr-menu-item");
if (!item) return;
const val = item.dataset.value;
chHiddenSel.value = val;
chHiddenSel.dispatchEvent(new Event("change", { bubbles: true }));
chItemEls.forEach(it => {
const active = it.dataset.value === val;
it.classList.toggle("is-active", active);
it.querySelector(".mjr-menu-item-check").style.opacity = active ? "1" : "0";
});
_updateChBtnDisplay(val);
chBtn.classList.toggle("is-on", val !== "rgb");
viewer._closeChPopover();
});
// Mutual exclusion: close all toolbar popovers (called before any one opens)
viewer._closeAllToolbarPopovers = () => {
viewer._closeChPopover?.();
viewer._closeGuidePopover?.();
viewer._closePinPopover?.();
viewer._closeFormatPopover?.();
viewer._closeGenDropdown?.();
};
viewer._exposureCtl = createMiniRange("Exposure (EV)", {
min: -10,
max: 10,
step: 0.1,
value: Number(viewer._exposureEV || 0),
});
viewer._exposureCtl.out.textContent = `${Number(viewer._exposureEV || 0).toFixed(1)}EV`;
viewer._exposureCtl.out.title = "Click to reset to 0 EV";
viewer._exposureCtl.out.style.cursor = "pointer";
viewer._exposureCtl.wrap.classList.toggle("is-active", (viewer._exposureEV || 0) !== 0);
bar.appendChild(viewer._exposureCtl.wrap);
// Format mask: single trigger button + popover (format options + opacity slider)
viewer._formatToggle = document.createElement("button");
viewer._formatToggle.type = "button";
viewer._formatToggle.className = "mjr-icon-btn mjr-mfv-format-trigger";
viewer._formatToggle.setAttribute("aria-haspopup", "dialog");
viewer._formatToggle.setAttribute("aria-expanded", "false");
viewer._formatToggle.setAttribute("aria-pressed", "false");
viewer._formatToggle.title = "Format mask";
viewer._formatToggle.innerHTML = '<i class="pi pi-stop" aria-hidden="true"></i>';
bar.appendChild(viewer._formatToggle);
const fmtPopover = document.createElement("div");
fmtPopover.className = "mjr-mfv-format-popover";
viewer.element.appendChild(fmtPopover);
const fmtMenu = document.createElement("div");
fmtMenu.className = "mjr-menu";
fmtMenu.style.cssText = "display:grid;gap:4px;";
fmtPopover.appendChild(fmtMenu);
const fmtHiddenSel = document.createElement("select");
fmtHiddenSel.style.cssText = "position:absolute;opacity:0;pointer-events:none;width:0;height:0;overflow:hidden;";
fmtPopover.appendChild(fmtHiddenSel);
const _FMT_OPTS = [
{ value: "off", label: "Off" },
{ value: "image",label: "Image" },
{ value: "16:9", label: "16:9" },
{ value: "1:1", label: "1:1" },
{ value: "4:3", label: "4:3" },
{ value: "9:16", label: "9:16" },
{ value: "2.39", label: "2.39" },
];
const fmtItemEls = [];
const _initFmt = viewer._overlayMaskEnabled ? String(viewer._overlayFormat || "image") : "off";
for (const opt of _FMT_OPTS) {
const optEl = document.createElement("option");
optEl.value = opt.value;
fmtHiddenSel.appendChild(optEl);
const item = document.createElement("button");
item.type = "button";
item.className = "mjr-menu-item";
item.dataset.value = opt.value;
const lbl = document.createElement("span");
lbl.className = "mjr-menu-item-label";
lbl.textContent = opt.label;
const chk = document.createElement("i");
chk.className = "pi pi-check mjr-menu-item-check";
chk.style.opacity = opt.value === _initFmt ? "1" : "0";
item.appendChild(lbl);
item.appendChild(chk);
fmtMenu.appendChild(item);
fmtItemEls.push(item);
if (opt.value === _initFmt) item.classList.add("is-active");
}
fmtHiddenSel.value = _initFmt;
// Separator + opacity slider row
const fmtSep = document.createElement("div");
fmtSep.className = "mjr-mfv-format-sep";
fmtPopover.appendChild(fmtSep);
const fmtSliderRow = document.createElement("div");
fmtSliderRow.className = "mjr-mfv-format-slider-row";
fmtPopover.appendChild(fmtSliderRow);
const fmtSliderLabel = document.createElement("span");
fmtSliderLabel.className = "mjr-mfv-format-slider-label";
fmtSliderLabel.textContent = "Opacity";
fmtSliderRow.appendChild(fmtSliderLabel);
viewer._maskOpacityCtl = createMiniRange("Mask opacity", {
min: 0,
max: 0.9,
step: 0.01,
value: Number(viewer._overlayMaskOpacity ?? 0.65),
});
fmtSliderRow.appendChild(viewer._maskOpacityCtl.input);
fmtSliderRow.appendChild(viewer._maskOpacityCtl.out);
viewer._formatSelect = fmtHiddenSel;
viewer._formatPopover = fmtPopover;
const _openFmtPopover = () => {
const btnRect = viewer._formatToggle.getBoundingClientRect();
const parentRect = viewer.element.getBoundingClientRect();
fmtPopover.style.left = `${btnRect.left - parentRect.left}px`;
fmtPopover.style.top = `${btnRect.bottom - parentRect.top + 4}px`;
fmtPopover.classList.add("is-open");
viewer._formatToggle.setAttribute("aria-expanded", "true");
};
viewer._closeFormatPopover = () => {
fmtPopover.classList.remove("is-open");
viewer._formatToggle.setAttribute("aria-expanded", "false");
};
viewer._formatToggle.addEventListener("click", (e) => {
e.stopPropagation();
if (fmtPopover.classList.contains("is-open")) {
viewer._closeFormatPopover();
} else {
viewer._closeAllToolbarPopovers?.();
_openFmtPopover();
}
});
fmtMenu.addEventListener("click", (e) => {
const item = e.target.closest(".mjr-menu-item");
if (!item) return;
const val = item.dataset.value;
fmtHiddenSel.value = val;
fmtHiddenSel.dispatchEvent(new Event("change", { bubbles: true }));
fmtItemEls.forEach(it => {
const active = it.dataset.value === val;
it.classList.toggle("is-active", active);
it.querySelector(".mjr-menu-item-check").style.opacity = active ? "1" : "0";
});
viewer._closeFormatPopover();
});
const sep = document.createElement("div");
sep.className = "mjr-mfv-toolbar-sep";
@@ -229,11 +782,42 @@ export function buildFloatingViewerToolbar(viewer) {
bar.appendChild(viewer._runHandle.el);
viewer._handleDocClick = (ev) => {
if (!viewer._genDropdown) return;
const target = ev?.target;
if (viewer._genBtn?.contains?.(target)) return;
if (viewer._genDropdown.contains(target)) return;
viewer._closeGenDropdown();
// Gen dropdown
if (viewer._genDropdown) {
if (!viewer._genBtn?.contains?.(target) && !viewer._genDropdown.contains(target)) {
viewer._closeGenDropdown();
}
}
// Channel popover
if (viewer._chPopover?.classList?.contains("is-open")) {
if (!viewer._chBtn?.contains?.(target) && !viewer._chPopover.contains(target)) {
viewer._closeChPopover?.();
}
}
// Guides popover
if (viewer._guidePopover?.classList?.contains("is-open")) {
if (!viewer._guideBtn?.contains?.(target) && !viewer._guidePopover.contains(target)) {
viewer._closeGuidePopover?.();
}
}
// Pin popover
if (viewer._pinPopover?.classList?.contains("is-open")) {
if (!viewer._pinBtn?.contains?.(target) && !viewer._pinPopover.contains(target)) {
viewer._closePinPopover?.();
}
}
// Format popover
if (viewer._formatPopover?.classList?.contains("is-open")) {
if (!viewer._formatToggle?.contains?.(target) && !viewer._formatPopover.contains(target)) {
viewer._closeFormatPopover?.();
}
}
// Icon dropdowns: close if click is outside any .mjr-mfv-idrop
if (!target?.closest?.(".mjr-mfv-idrop")) {
viewer.element?.querySelectorAll?.(".mjr-mfv-idrop-menu.is-open")
.forEach(m => m.classList.remove("is-open"));
}
};
viewer._bindDocumentUiHandlers();
@@ -312,6 +896,7 @@ export function rebindFloatingViewerControlHandlers(viewer) {
if (viewer._genDropdown?.classList?.contains("is-visible")) {
viewer._closeGenDropdown();
} else {
viewer._closeAllToolbarPopovers?.();
viewer._openGenDropdown();
}
},
@@ -336,6 +921,79 @@ export function rebindFloatingViewerControlHandlers(viewer) {
},
{ signal },
);
viewer._guidesSelect?.addEventListener(
"change",
() => {
viewer._gridMode = Number(viewer._guidesSelect.value) || 0;
viewer._guideBtn?.classList.toggle("is-on", viewer._gridMode !== 0);
viewer._redrawOverlayGuides?.();
},
{ signal },
);
viewer._channelSelect?.addEventListener(
"change",
() => {
viewer._channel = String(viewer._channelSelect.value || "rgb");
viewer._chBtn?.classList.toggle("is-on", viewer._channel !== "rgb");
viewer._applyMediaToneControls?.();
},
{ signal },
);
viewer._exposureCtl?.input?.addEventListener(
"input",
() => {
const v = Math.max(-10, Math.min(10, Number(viewer._exposureCtl.input.value) || 0));
viewer._exposureEV = Math.round(v * 10) / 10;
viewer._exposureCtl.out.textContent = `${viewer._exposureEV.toFixed(1)}EV`;
viewer._exposureCtl.wrap.classList.toggle("is-active", viewer._exposureEV !== 0);
viewer._applyMediaToneControls?.();
},
{ signal },
);
viewer._exposureCtl?.out?.addEventListener(
"click",
() => {
viewer._exposureEV = 0;
viewer._exposureCtl.input.value = "0";
viewer._exposureCtl.out.textContent = "0.0EV";
viewer._exposureCtl.wrap.classList.remove("is-active");
viewer._applyMediaToneControls?.();
},
{ signal },
);
viewer._formatSelect?.addEventListener(
"change",
() => {
const val = String(viewer._formatSelect.value || "image");
if (val === "off") {
viewer._overlayMaskEnabled = false;
} else {
viewer._overlayMaskEnabled = true;
viewer._overlayFormat = val;
}
viewer._formatToggle?.classList.toggle("is-on", Boolean(viewer._overlayMaskEnabled));
viewer._formatToggle?.setAttribute("aria-pressed", String(Boolean(viewer._overlayMaskEnabled)));
viewer._redrawOverlayGuides?.();
},
{ signal },
);
viewer._maskOpacityCtl?.input?.addEventListener(
"input",
() => {
const v = Number(viewer._maskOpacityCtl.input.value);
const clamped = Math.max(0, Math.min(0.9, Number.isFinite(v) ? v : 0.65));
viewer._overlayMaskOpacity = Math.round(clamped * 100) / 100;
viewer._maskOpacityCtl.out.textContent = viewer._overlayMaskOpacity.toFixed(2);
viewer._redrawOverlayGuides?.();
},
{ signal },
);
}
export function updateFloatingViewerSettingsBtnState(viewer, active) {
@@ -1,25 +1,77 @@
/**
* NodeWidgetRenderer — renders a single ComfyUI LGraphNode's widgets
* NodeWidgetRenderer renders a single ComfyUI LGraphNode's widgets
* as an editable HTML section.
*/
import { getComfyApp } from "../../../app/comfyApiBridge.js";
import { createWidgetInput } from "./widgetAdapters.js";
/** Widget types we skip (too complex for a quick-edit sidebar). */
const SKIP_TYPES = new Set(["imageupload", "button", "hidden"]);
// Note nodes store their text in node.properties instead of widgets.
const NOTE_TYPE_RE = /\bnote\b|markdown/i;
function _isNoteNode(node) {
return NOTE_TYPE_RE.test(String(node?.type || ""));
}
function _getNoteText(node) {
const props = node?.properties ?? {};
if (typeof props.text === "string") return props.text;
if (typeof props.value === "string") return props.value;
if (typeof props.markdown === "string") return props.markdown;
const w0 = node?.widgets?.[0];
if (w0 != null && w0.value != null) return String(w0.value);
return "";
}
function _setNoteText(node, value) {
const props = node?.properties;
if (props) {
if ("text" in props) props.text = value;
else if ("value" in props) props.value = value;
else if ("markdown" in props) props.markdown = value;
else props.text = value;
}
const w0 = node?.widgets?.[0];
if (w0) w0.value = value;
}
// UUID or long hex hash — not a human-readable type name.
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const HEX_HASH_RE = /^[0-9a-f]{20,}$/i;
function _getNodeDisplayType(node) {
const type = String(node?.type || "");
if (UUID_RE.test(type) || HEX_HASH_RE.test(type)) {
return String(node?.title || "Subgraph").trim() || "Subgraph";
}
return type || `Node #${node?.id}`;
}
export class NodeWidgetRenderer {
/**
* @param {object} node LGraphNode instance
* @param {object} node
* @param {object} [opts]
* @param {() => void} [opts.onLocate] Custom locate handler (optional)
* @param {() => void} [opts.onLocate]
* @param {boolean} [opts.collapsible]
* @param {boolean} [opts.expanded]
* @param {(expanded: boolean) => void} [opts.onToggle]
* @param {number} [opts.depth] — 0 = top-level, >0 = inside a subgraph
*/
constructor(node, opts = {}) {
this._node = node;
this._onLocate = opts.onLocate ?? null;
this._onToggle = typeof opts.onToggle === "function" ? opts.onToggle : null;
this._collapsible = Boolean(opts.collapsible);
this._expanded = opts.expanded !== false;
this._depth = opts.depth ?? 0;
this._el = null;
this._inputMap = new Map(); // widgetName → inputEl
this._body = null;
this._toggleBtn = null;
this._inputMap = new Map();
this._autoFits = [];
this._noteTextarea = null;
}
get el() {
@@ -27,77 +79,174 @@ export class NodeWidgetRenderer {
return this._el;
}
/** Re-read widget values from the live graph and update inputs. */
syncFromGraph() {
// Note nodes: sync from node.properties.text instead of widgets
if (this._noteTextarea) {
const doc = this._el?.ownerDocument || document;
if (doc?.activeElement !== this._noteTextarea) {
const text = _getNoteText(this._node);
if (this._noteTextarea.value !== text) {
this._noteTextarea.value = text;
this._noteTextarea._mjrAutoFit?.();
}
}
return;
}
if (!this._node?.widgets) return;
const doc = this._el?.ownerDocument || document;
const activeEl = doc?.activeElement || null;
for (const w of this._node.widgets) {
const storedInput = this._inputMap.get(w.name);
let input = _resolveSyncInput(storedInput);
const input = _resolveSyncInput(storedInput);
if (!input) continue;
if (input.type === "checkbox") {
const nextChecked = Boolean(w.value);
if (input.checked !== nextChecked) {
input.checked = nextChecked;
}
} else {
const nextValue = w.value != null ? String(w.value) : "";
if (String(input.value ?? "") === nextValue) continue;
if (activeEl && input === activeEl) continue;
input.value = nextValue;
storedInput?._mjrAutoFit?.();
input?._mjrAutoFit?.();
if (input.checked !== nextChecked) input.checked = nextChecked;
continue;
}
const nextValue = w.value != null ? String(w.value) : "";
if (String(input.value ?? "") === nextValue) continue;
if (activeEl && input === activeEl) continue;
input.value = nextValue;
storedInput?._mjrAutoFit?.();
input?._mjrAutoFit?.();
}
}
dispose() {
this._el?.remove();
this._el = null;
this._autoFits = [];
this._inputMap.clear();
}
// ── Private ───────────────────────────────────────────────────────────────
setExpanded(expanded) {
this._expanded = Boolean(expanded);
this._applyExpandedState();
// Trigger textarea autofit now that the body is visible
if (this._expanded && this._autoFits?.length) {
requestAnimationFrame(() => {
for (const fn of this._autoFits) fn();
});
}
this._onToggle?.(this._expanded);
}
_render() {
const node = this._node;
const section = document.createElement("section");
section.className = "mjr-ws-node";
section.dataset.nodeId = String(node.id ?? "");
if (this._depth > 0) {
section.dataset.depth = String(this._depth);
section.classList.add("mjr-ws-node--nested");
}
// Header
const header = document.createElement("div");
header.className = "mjr-ws-node-header";
const title = document.createElement("span");
title.className = "mjr-ws-node-title";
title.textContent = node.title || node.type || `Node #${node.id}`;
header.appendChild(title);
if (this._collapsible) {
this._header = header;
const toggleBtn = document.createElement("button");
toggleBtn.type = "button";
toggleBtn.className = "mjr-icon-btn mjr-ws-node-toggle";
toggleBtn.title = this._expanded ? "Collapse node" : "Expand node";
toggleBtn.addEventListener("click", (event) => {
event.stopPropagation();
this.setExpanded(!this._expanded);
});
header.appendChild(toggleBtn);
this._toggleBtn = toggleBtn;
header.addEventListener("click", (event) => {
if (event.target.closest("button")) return;
this.setExpanded(!this._expanded);
});
header.title = this._expanded ? "Collapse node" : "Expand node";
}
const titleWrap = document.createElement("div");
titleWrap.className = "mjr-ws-node-title-wrap";
const typeText = document.createElement("span");
typeText.className = "mjr-ws-node-type";
typeText.textContent = _getNodeDisplayType(node);
titleWrap.appendChild(typeText);
const customTitle = String(node.title || "").trim();
if (customTitle && customTitle !== node.type) {
const title = document.createElement("span");
title.className = "mjr-ws-node-title";
title.textContent = customTitle;
titleWrap.appendChild(title);
}
header.appendChild(titleWrap);
const locateBtn = document.createElement("button");
locateBtn.type = "button";
locateBtn.className = "mjr-icon-btn mjr-ws-locate";
locateBtn.title = "Locate on canvas";
const locIcon = document.createElement("i");
locIcon.className = "pi pi-map-marker";
locIcon.setAttribute("aria-hidden", "true");
locateBtn.appendChild(locIcon);
locateBtn.addEventListener("click", () => this._locateNode());
locateBtn.innerHTML = '<i class="pi pi-map-marker" aria-hidden="true"></i>';
locateBtn.addEventListener("click", (event) => {
event.stopPropagation();
this._locateNode();
});
header.appendChild(locateBtn);
section.appendChild(header);
// Widgets
const body = document.createElement("div");
body.className = "mjr-ws-node-body";
// Note nodes store text in node.properties.text — widgets array is empty.
if (_isNoteNode(node)) {
const textarea = document.createElement("textarea");
textarea.className = "mjr-ws-input mjr-ws-textarea mjr-ws-note-textarea";
textarea.value = _getNoteText(node);
textarea.rows = 4;
const autoFit = () => {
textarea.style.height = "auto";
textarea.style.height = textarea.scrollHeight + "px";
};
textarea.addEventListener("input", () => {
_setNoteText(node, textarea.value);
// Sync ComfyUI's own DOM element for this widget if it exists.
const w0 = node?.widgets?.[0];
const domEl = w0?.inputEl ?? w0?.element ?? w0?.el ?? null;
if (domEl != null && "value" in domEl && domEl.value !== textarea.value) {
domEl.value = textarea.value;
}
autoFit();
try {
const app = getComfyApp();
const canvas = app?.canvas ?? null;
canvas?.setDirty?.(true, true);
canvas?.draw?.(true, true);
const nodeGraph = node?.graph ?? null;
if (nodeGraph && nodeGraph !== app?.graph) {
nodeGraph.setDirtyCanvas?.(true, true);
nodeGraph.change?.();
}
app?.graph?.change?.();
} catch (_) {}
});
textarea._mjrAutoFit = autoFit;
this._noteTextarea = textarea;
this._autoFits.push(autoFit);
body.appendChild(textarea);
this._body = body;
section.appendChild(body);
this._el = section;
this._applyExpandedState();
requestAnimationFrame(autoFit);
return section;
}
const widgets = node.widgets ?? [];
let hasVisibleWidget = false;
for (const w of widgets) {
const wType = String(w.type || "").toLowerCase();
if (SKIP_TYPES.has(wType)) continue;
// Skip hidden widgets (ComfyUI uses w.hidden or w.options.hidden)
if (w.hidden || w.options?.hidden) continue;
hasVisibleWidget = true;
@@ -113,11 +262,12 @@ export class NodeWidgetRenderer {
const inputWrap = document.createElement("div");
inputWrap.className = "mjr-ws-widget-input";
const inputEl = createWidgetInput(w, () => {
/* value already written by adapter */
});
const inputEl = createWidgetInput(w, () => {}, node);
inputWrap.appendChild(inputEl);
this._inputMap.set(w.name, inputEl);
// Collect autoFit functions from text wrappers so we can re-run them on expand
const autoFit = inputEl._mjrAutoFit ?? inputEl.querySelector?.("textarea")?._mjrAutoFit;
if (autoFit) this._autoFits.push(autoFit);
row.appendChild(label);
row.appendChild(inputWrap);
@@ -131,10 +281,29 @@ export class NodeWidgetRenderer {
body.appendChild(empty);
}
this._body = body;
section.appendChild(body);
// Assign _el before calling _applyExpandedState so its guard passes.
this._el = section;
this._applyExpandedState();
return section;
}
_applyExpandedState() {
if (!this._el || !this._body) return;
this._el.classList.toggle("is-collapsible", this._collapsible);
this._el.classList.toggle("is-collapsed", this._collapsible && !this._expanded);
if (this._toggleBtn) {
const iconClass = this._expanded ? "pi pi-chevron-down" : "pi pi-chevron-right";
this._toggleBtn.innerHTML = `<i class="${iconClass}" aria-hidden="true"></i>`;
this._toggleBtn.title = this._expanded ? "Collapse node" : "Expand node";
this._toggleBtn.setAttribute("aria-expanded", String(this._expanded));
}
if (this._header) {
this._header.title = this._expanded ? "Collapse node" : "Expand node";
}
}
_locateNode() {
if (this._onLocate) {
this._onLocate();
@@ -0,0 +1,321 @@
/**
* WorkflowNodesTab lists all workflow nodes and exposes each node's parameters
* inline through a collapsible section.
*
* Subgraph container nodes show an extra toggle to expand/collapse their inner
* nodes as a nested group below the container.
*
* The node list DOM is only rebuilt when the set of nodes actually changes —
* value sync runs every frame without touching the DOM structure.
*/
import { getComfyApp } from "../../../app/comfyApiBridge.js";
import { NodeWidgetRenderer } from "./NodeWidgetRenderer.js";
export class WorkflowNodesTab {
constructor() {
this._searchQuery = "";
/** Which node widget panels are open (at most one at a time). */
this._expandedNodeIds = new Set();
/** Which subgraph container nodes have their children list open. */
this._expandedChildrenIds = new Set();
/** Flat list of every NodeWidgetRenderer currently in the DOM. */
this._renderers = [];
this._el = this._build();
this._lastNodeSig = "";
this._lastSelectedId = "";
}
get el() { return this._el; }
refresh() {
this._maybeRebuildList();
for (const renderer of this._renderers) renderer.syncFromGraph();
}
forceRebuild() {
this._lastNodeSig = "";
this._maybeRebuildList();
}
dispose() {
for (const renderer of this._renderers) renderer.dispose();
this._renderers = [];
this._el?.remove?.();
}
_build() {
const wrap = document.createElement("div");
wrap.className = "mjr-ws-nodes-tab";
const searchWrap = document.createElement("div");
searchWrap.className = "mjr-ws-search-wrap";
const searchIcon = document.createElement("i");
searchIcon.className = "pi pi-search mjr-ws-search-icon";
searchIcon.setAttribute("aria-hidden", "true");
searchWrap.appendChild(searchIcon);
this._searchInput = document.createElement("input");
this._searchInput.type = "text";
this._searchInput.className = "mjr-ws-search";
this._searchInput.placeholder = "Search nodes...";
this._searchInput.addEventListener("input", () => {
this._searchQuery = this._searchInput.value;
this.forceRebuild();
});
searchWrap.appendChild(this._searchInput);
const clearBtn = document.createElement("button");
clearBtn.type = "button";
clearBtn.className = "mjr-ws-search-clear";
clearBtn.title = "Clear search";
clearBtn.innerHTML = '<i class="pi pi-times" aria-hidden="true"></i>';
clearBtn.addEventListener("click", () => {
this._searchInput.value = "";
this._searchQuery = "";
this.forceRebuild();
});
searchWrap.appendChild(clearBtn);
wrap.appendChild(searchWrap);
this._list = document.createElement("div");
this._list.className = "mjr-ws-nodes-list";
wrap.appendChild(this._list);
return wrap;
}
_syncCanvasSelection() {
const selectedIds = _getSelectedNodeIds();
const selectedId = selectedIds[0] || "";
if (!selectedId || selectedId === this._lastSelectedId) return;
this._lastSelectedId = selectedId;
for (const renderer of this._renderers) {
if (String(renderer._node?.id ?? "") === selectedId && !renderer._expanded) {
renderer.setExpanded(true);
break;
}
}
}
_maybeRebuildList() {
const graph = _getGraph();
const tree = graph ? _buildNodeTree(graph) : [];
const q = (this._searchQuery || "").toLowerCase().trim();
const filtered = q ? _filterTree(tree, q) : tree;
const sig = _treeSignature(filtered);
this._syncCanvasSelection();
if (sig === this._lastNodeSig) return;
this._lastNodeSig = sig;
for (const renderer of this._renderers) renderer.dispose();
this._renderers = [];
this._list.innerHTML = "";
if (!filtered.length) {
const empty = document.createElement("div");
empty.className = "mjr-ws-sidebar-empty";
empty.textContent = tree.length ? "No nodes match your search" : "No nodes in workflow";
this._list.appendChild(empty);
return;
}
this._renderItems(filtered, this._list, 0);
}
/**
* Recursively render tree items into a container.
* @param {{node: object, children: object[]}[]} items
* @param {HTMLElement} container
* @param {number} depth
*/
_renderItems(items, container, depth) {
for (const { node, children } of items) {
const nodeId = String(node?.id ?? "");
const renderer = new NodeWidgetRenderer(node, {
collapsible: true,
expanded: this._expandedNodeIds.has(nodeId),
depth,
onLocate: () => _focusNode(node),
onToggle: (expanded) => {
if (expanded) {
this._expandedNodeIds = new Set([nodeId]);
for (const r of this._renderers) {
if (r !== renderer) r.setExpanded(false);
}
} else {
this._expandedNodeIds.delete(nodeId);
}
},
});
this._renderers.push(renderer);
container.appendChild(renderer.el);
if (children.length > 0) {
const isOpen = this._expandedChildrenIds.has(nodeId);
const toggleBtn = document.createElement("button");
toggleBtn.type = "button";
toggleBtn.className = "mjr-ws-children-toggle";
if (depth > 0) toggleBtn.classList.add("mjr-ws-children-toggle--nested");
_setChildrenToggleLabel(toggleBtn, children.length, isOpen);
const childrenEl = document.createElement("div");
childrenEl.className = "mjr-ws-children";
childrenEl.hidden = !isOpen;
this._renderItems(children, childrenEl, depth + 1);
toggleBtn.addEventListener("click", () => {
const nowOpen = childrenEl.hidden;
childrenEl.hidden = !nowOpen;
if (nowOpen) this._expandedChildrenIds.add(nodeId);
else this._expandedChildrenIds.delete(nodeId);
_setChildrenToggleLabel(toggleBtn, children.length, nowOpen);
});
container.appendChild(toggleBtn);
container.appendChild(childrenEl);
}
}
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function _getGraph() {
try {
const app = getComfyApp();
return app?.graph ?? app?.canvas?.graph ?? null;
} catch { return null; }
}
function _getSubgraphsFromNode(node) {
const candidates = [
node?.subgraph,
node?._subgraph,
node?.subgraph?.graph,
node?.subgraph?.lgraph,
node?.properties?.subgraph,
node?.subgraph_instance,
node?.subgraph_instance?.graph,
node?.inner_graph,
node?.subgraph_graph,
].filter((g) => g && typeof g === "object" && Array.isArray(g.nodes));
// Some GROUP-style nodes expose inner nodes directly as node.nodes
// (a different array from the parent graph's nodes).
if (
Array.isArray(node?.nodes) &&
node.nodes.length > 0 &&
node.nodes !== node?.graph?.nodes
) {
candidates.push({ nodes: node.nodes });
}
return candidates;
}
/**
* Build a recursive tree: [{node, children}] from a LiteGraph graph object.
*/
function _buildNodeTree(graph, visited = new Set()) {
if (!graph || visited.has(graph)) return [];
visited.add(graph);
const nodes = Array.isArray(graph.nodes) ? graph.nodes : [];
const result = [];
for (const node of nodes) {
if (!node) continue;
const subgraphs = _getSubgraphsFromNode(node);
const children = subgraphs.flatMap((sg) => _buildNodeTree(sg, visited));
result.push({ node, children });
}
return result;
}
/**
* Recursively filter tree items. A container is kept if it matches OR any child matches.
*/
function _filterTree(items, q) {
const result = [];
for (const { node, children } of items) {
const nodeMatches =
(node.type || "").toLowerCase().includes(q) ||
(node.title || "").toLowerCase().includes(q);
const filteredChildren = _filterTree(children, q);
if (nodeMatches || filteredChildren.length > 0) {
result.push({ node, children: filteredChildren });
}
}
return result;
}
/** Depth-first signature of all node IDs — encodes both set and tree order. */
function _treeSignature(items) {
const ids = [];
function collect(arr) {
for (const { node, children } of arr) {
ids.push(node.id);
collect(children);
}
}
collect(items);
return ids.join(",");
}
function _setChildrenToggleLabel(btn, count, isOpen) {
const icon = isOpen ? "pi-chevron-down" : "pi-chevron-right";
btn.innerHTML = `<i class="pi ${icon}" aria-hidden="true"></i><span>${count} inner node${count !== 1 ? "s" : ""}</span>`;
btn.setAttribute("aria-expanded", String(isOpen));
}
function _focusNode(node) {
try {
const app = getComfyApp();
const canvas = app?.canvas;
if (!canvas) return;
canvas.selectNode?.(node, false);
if (typeof canvas.centerOnNode === "function") {
canvas.centerOnNode(node);
} else if (node.pos && canvas.ds) {
const canvasEl = canvas.canvas;
const w = canvasEl?.width ?? 800;
const h = canvasEl?.height ?? 600;
const scale = canvas.ds.scale ?? 1;
canvas.ds.offset = [
-node.pos[0] + w / (2 * scale) - ((node.size?.[0] ?? 100) / 2),
-node.pos[1] + h / (2 * scale) - ((node.size?.[1] ?? 80) / 2),
];
canvas.setDirty?.(true, true);
}
canvas.canvas?.focus?.();
} catch (e) {
console.debug?.("[MFV] _focusNode", e);
}
}
function _getSelectedNodeIds() {
try {
const app = getComfyApp();
const selected = app?.canvas?.selected_nodes ?? app?.canvas?.selectedNodes ?? null;
if (!selected) return [];
if (Array.isArray(selected)) return selected.map((node) => String(node?.id ?? "")).filter(Boolean);
if (selected instanceof Map) {
return Array.from(selected.values()).map((node) => String(node?.id ?? "")).filter(Boolean);
}
if (typeof selected === "object") {
return Object.values(selected).map((node) => String(node?.id ?? "")).filter(Boolean);
}
} catch (e) {
console.debug?.("[MFV] _getSelectedNodeIds", e);
}
return [];
}
@@ -1,31 +1,23 @@
/**
* WorkflowSidebar — slide-in panel for the Floating Viewer (MFV).
* WorkflowSidebar is the slide-in panel for the Floating Viewer.
*
* Shows editable widgets for the currently selected ComfyUI nodes.
* Reads from `app.canvas.selected_nodes` via comfyApiBridge.
* Writes back via widgetAdapters (widget.value + callback).
* It now exposes a single unified "Nodes" view: each workflow node can be
* expanded inline to reveal editable parameters.
*/
import { getComfyApp } from "../../../app/comfyApiBridge.js";
import { NodeWidgetRenderer } from "./NodeWidgetRenderer.js";
import { WorkflowNodesTab } from "./WorkflowNodesTab.js";
const LIVE_SYNC_FALLBACK_MS = 16;
export class WorkflowSidebar {
/**
* @param {object} opts
* @param {HTMLElement} opts.hostEl The MFV root element to append to
* @param {() => void} [opts.onClose]
*/
constructor({ hostEl, onClose } = {}) {
this._hostEl = hostEl;
this._onClose = onClose ?? null;
/** @type {NodeWidgetRenderer[]} */
this._renderers = [];
this._visible = false;
this._selectionKey = "";
this._liveSyncHandle = null;
this._liveSyncMode = "";
this._resizeCleanup = null;
this._nodesTab = new WorkflowNodesTab();
this._el = this._build();
}
@@ -35,7 +27,7 @@ export class WorkflowSidebar {
show() {
this._visible = true;
this._el.classList.add("open");
this.refresh({ force: true });
this._nodesTab.refresh();
this._startLiveSync();
}
@@ -54,98 +46,63 @@ export class WorkflowSidebar {
}
}
/** Re-read selected nodes and rebuild widget sections. */
refresh({ force = false } = {}) {
refresh() {
if (!this._visible) return;
const { key, nodes } = _getSelectedNodeSnapshot();
if (!force && key === this._selectionKey && this._renderers.length === nodes.length) {
this.syncFromGraph({ allowSelectionRefresh: false });
return;
}
this._clear();
this._selectionKey = key;
if (!nodes.length) {
this._showEmpty();
return;
}
for (const node of nodes) {
const renderer = new NodeWidgetRenderer(node);
this._renderers.push(renderer);
this._body.appendChild(renderer.el);
}
this.syncFromGraph({ allowSelectionRefresh: false });
this._nodesTab.refresh();
}
/** Sync existing renderers from graph values without full rebuild. */
syncFromGraph({ allowSelectionRefresh = true } = {}) {
syncFromGraph() {
if (!this._visible) return;
const { key } = _getSelectedNodeSnapshot();
if (allowSelectionRefresh && key !== this._selectionKey) {
this.refresh({ force: true });
return;
}
for (const r of this._renderers) r.syncFromGraph();
this._nodesTab.refresh();
}
dispose() {
this._stopLiveSync();
this._clear();
this._disposeResize();
this._nodesTab?.dispose?.();
this._nodesTab = null;
this._el?.remove();
}
// ── Private ───────────────────────────────────────────────────────────────
_build() {
const panel = document.createElement("div");
panel.className = "mjr-ws-sidebar";
// Header
const header = document.createElement("div");
header.className = "mjr-ws-sidebar-header";
const title = document.createElement("span");
title.className = "mjr-ws-sidebar-title";
title.textContent = "Node Parameters";
title.textContent = "Nodes";
header.appendChild(title);
const closeBtn = document.createElement("button");
closeBtn.type = "button";
closeBtn.className = "mjr-icon-btn";
closeBtn.title = "Close sidebar";
const closeIcon = document.createElement("i");
closeIcon.className = "pi pi-times";
closeIcon.setAttribute("aria-hidden", "true");
closeBtn.appendChild(closeIcon);
closeBtn.innerHTML = '<i class="pi pi-times" aria-hidden="true"></i>';
closeBtn.addEventListener("click", () => {
this.hide();
this._onClose?.();
});
header.appendChild(closeBtn);
panel.appendChild(header);
// Scrollable body
this._body = document.createElement("div");
this._body.className = "mjr-ws-sidebar-body";
const resizer = document.createElement("div");
resizer.className = "mjr-ws-sidebar-resizer";
resizer.setAttribute("role", "separator");
resizer.setAttribute("aria-orientation", "vertical");
resizer.setAttribute("aria-hidden", "true");
panel.appendChild(resizer);
this._bindResize(resizer);
this._body = this._nodesTab.el;
this._body.classList.add("mjr-ws-sidebar-body");
panel.appendChild(this._body);
return panel;
}
_clear() {
for (const r of this._renderers) r.dispose();
this._renderers = [];
if (this._body) this._body.innerHTML = "";
}
_showEmpty() {
const empty = document.createElement("div");
empty.className = "mjr-ws-sidebar-empty";
empty.textContent = "Select nodes on the canvas to edit their parameters";
this._body.appendChild(empty);
}
_startLiveSync() {
if (this._liveSyncHandle != null) return;
const frameHost = _getFrameHost(this._el);
@@ -182,38 +139,67 @@ export class WorkflowSidebar {
this._liveSyncHandle = null;
this._liveSyncMode = "";
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
_bindResize(handle) {
if (!handle) return;
const doc = handle.ownerDocument || document;
const win = doc.defaultView || window;
const MIN_WIDTH = 180;
/**
* Read the currently selected nodes from the ComfyUI canvas.
* Supports multiple ComfyUI versions (object, Map, Array).
*/
function _getSelectedNodes() {
try {
const app = getComfyApp();
const selected =
app?.canvas?.selected_nodes ??
app?.canvas?.selectedNodes ??
null;
if (!selected) return [];
if (Array.isArray(selected)) return selected.filter(Boolean);
if (selected instanceof Map) return Array.from(selected.values()).filter(Boolean);
if (typeof selected === "object") return Object.values(selected).filter(Boolean);
} catch (e) {
console.debug?.("[MFV sidebar] _getSelectedNodes error", e);
const onPointerDown = (event) => {
if (event.button !== 0 || !this._el?.classList.contains("open")) return;
const wrapper = this._el.parentElement;
if (!wrapper) return;
const sidebarRect = this._el.getBoundingClientRect();
const wrapperRect = wrapper.getBoundingClientRect();
const sidebarPos = wrapper.getAttribute("data-sidebar-pos") || "right";
const maxWidth = Math.max(
MIN_WIDTH,
Math.floor(wrapperRect.width * (sidebarPos === "bottom" ? 1 : 0.65)),
);
if (sidebarPos === "bottom") return;
event.preventDefault();
event.stopPropagation();
handle.classList.add("is-dragging");
this._el.classList.add("is-resizing");
const startX = event.clientX;
const startWidth = sidebarRect.width;
const onPointerMove = (moveEvent) => {
const deltaX = moveEvent.clientX - startX;
const nextWidth =
sidebarPos === "left" ? startWidth - deltaX : startWidth + deltaX;
const clampedWidth = Math.max(MIN_WIDTH, Math.min(maxWidth, nextWidth));
this._el.style.width = `${Math.round(clampedWidth)}px`;
};
const stopResize = () => {
handle.classList.remove("is-dragging");
this._el.classList.remove("is-resizing");
win.removeEventListener("pointermove", onPointerMove);
win.removeEventListener("pointerup", stopResize);
win.removeEventListener("pointercancel", stopResize);
};
win.addEventListener("pointermove", onPointerMove);
win.addEventListener("pointerup", stopResize);
win.addEventListener("pointercancel", stopResize);
};
handle.addEventListener("pointerdown", onPointerDown);
this._resizeCleanup = () => handle.removeEventListener("pointerdown", onPointerDown);
}
return [];
}
function _getSelectedNodeSnapshot() {
const nodes = _getSelectedNodes();
const key = nodes
.map((node) => String(node?.id ?? ""))
.filter(Boolean)
.join("|");
return { key, nodes };
_disposeResize() {
try {
this._resizeCleanup?.();
} catch (e) {
console.debug?.(e);
}
this._resizeCleanup = null;
}
}
function _getFrameHost(el) {
@@ -295,6 +295,21 @@ function resolveClientId(api, app) {
return "";
}
/**
* Walk all nodes in a ComfyUI graph recursively, including inner nodes of any
* subgraphs, and invoke callback(node) for each. This mirrors the graph-walk
* ComfyUI's native queuePrompt performs before/after serialising the prompt.
*/
function _walkAllNodes(graph, callback) {
for (const node of graph?.nodes ?? []) {
callback(node);
// Recurse into ComfyUI subgraph inner nodes.
if (node.subgraph?.nodes) {
_walkAllNodes(node.subgraph, callback);
}
}
}
/**
* Queue the current workflow for execution.
*
@@ -303,31 +318,61 @@ function resolveClientId(api, app) {
* current auth session, same source as the native ComfyUI Queue button).
* 2. api.queuePrompt — broader detection via getComfyApi() (window.api, …).
* 3. api.fetchApi — direct HTTP via ComfyUI's authenticated fetch helper.
* 4. app.queuePrompt(0) — native ComfyUI app-level path; handles auth tokens
* and CSRF checks natively; no preview-method enrichment on this path.
* 4. app.queuePrompt(0) — native ComfyUI app-level path; handles auth tokens,
* beforeQueued/afterQueued and CSRF natively; used only when no direct API
* path is available (no preview-method enrichment on this path).
* 5. fetch (last resort) — raw fetch with credentials:'include' for cookie auth.
*
* Paths 1-3 and 5 manually mirror ComfyUI's beforeQueued/afterQueued widget cycle
* so that control_after_generate (randomize / increment / decrement) is applied
* correctly on every run, including inside subgraph inner nodes.
*/
async function queueCurrentPrompt() {
const app = getComfyApp();
if (!app) throw new Error("ComfyUI app not available");
// Resolve API references early so we can choose the path before touching
// widget state (beforeQueued must not be called twice for the same run).
const liveApi = (app?.api && typeof app.api.queuePrompt === "function") ? app.api : null;
const api = liveApi ?? getComfyApi(app);
const hasApiPath = Boolean(
(api && typeof api.queuePrompt === "function") ||
(api && typeof api.fetchApi === "function"),
);
// Path 4: delegate entirely to app.queuePrompt which handles beforeQueued,
// graphToPrompt, afterQueued, and auth internally. Only taken when no direct
// API path is available because it cannot carry MFV preview-method enrichment.
if (!hasApiPath && typeof app.queuePrompt === "function") {
await app.queuePrompt(0);
return { tracked: true };
}
// Paths 1-3 and 5: mirror ComfyUI's native beforeQueued cycle.
// ComfyUI's queuePrompt walks ALL nodes (including inner subgraph nodes) and
// calls widget.beforeQueued() before serialising the graph. This is what
// triggers control_after_generate (randomize / increment / decrement) to
// update the seed widget value before graphToPrompt reads it.
const rootGraph = app.rootGraph ?? app.graph;
_walkAllNodes(rootGraph, (node) => {
for (const w of node.widgets ?? []) {
w.beforeQueued?.({ isPartialExecution: false });
}
});
const promptData = typeof app.graphToPrompt === "function"
? await app.graphToPrompt()
: null;
if (!promptData?.output) throw new Error("graphToPrompt returned empty output");
// Prefer the live app.api reference — this matches the native Queue button's
// auth context and avoids stale cached references when ComfyUI refreshes its
// auth state (e.g. after user logs in or session is renewed).
const liveApi = (app?.api && typeof app.api.queuePrompt === "function") ? app.api : null;
const api = liveApi ?? getComfyApi(app);
let result;
if (api && typeof api.queuePrompt === "function") {
// Prefer the live app.api reference — matches the native Queue button's
// auth context and avoids stale cached references after session renewal.
await api.queuePrompt(0, enrichPromptDataForMfv(promptData));
return { tracked: true };
}
if (api && typeof api.fetchApi === "function") {
result = { tracked: true };
} else if (api && typeof api.fetchApi === "function") {
const resp = await api.fetchApi("/prompt", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -338,26 +383,30 @@ async function queueCurrentPrompt() {
),
});
if (!resp?.ok) throw new Error(`POST /prompt failed (${resp?.status})`);
return { tracked: true };
result = { tracked: true };
} else {
const resp = await fetch("/prompt", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
buildPromptRequestBody(promptData, {
clientId: resolveClientId(null, app),
}),
),
});
if (!resp.ok) throw new Error(`POST /prompt failed (${resp.status})`);
result = { tracked: false };
}
// Native app-level path: uses ComfyUI's own auth handling (same as the
// native Queue button). No preview-method enrichment on this fallback.
if (typeof app.queuePrompt === "function") {
await app.queuePrompt(0);
return { tracked: true };
}
const resp = await fetch("/prompt", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
buildPromptRequestBody(promptData, {
clientId: resolveClientId(null, app),
}),
),
// Mirror ComfyUI's afterQueued cycle and redraw the canvas so that widget
// displays (e.g. the new seed value) reflect the post-queue state immediately.
_walkAllNodes(rootGraph, (node) => {
for (const w of node.widgets ?? []) {
w.afterQueued?.({ isPartialExecution: false });
}
});
if (!resp.ok) throw new Error(`POST /prompt failed (${resp.status})`);
return { tracked: false };
app.canvas?.draw?.(true, true);
return result;
}
@@ -11,86 +11,159 @@ import { getComfyApp } from "../../../app/comfyApiBridge.js";
* Create an HTML input element matching a ComfyUI widget type.
* @param {object} widget LGraphNode widget ({ name, type, value, options, callback })
* @param {(newValue: unknown) => void} onChange Called when the user edits the input
* @param {object|null} [node] Parent LGraphNode — passed to widget.callback for proper ComfyUI compat
* @returns {HTMLElement}
*/
export function createWidgetInput(widget, onChange) {
export function createWidgetInput(widget, onChange, node = null) {
const type = String(widget?.type || "").toLowerCase();
switch (type) {
case "number":
return _numberInput(widget, onChange);
case "int":
case "float":
return _numberInput(widget, onChange, node);
case "combo":
return _comboInput(widget, onChange);
return _comboInput(widget, onChange, node);
case "text":
case "string":
case "customtext":
return _textInput(widget, onChange);
return _textInput(widget, onChange, node);
case "toggle":
return _toggleInput(widget, onChange);
case "boolean":
return _toggleInput(widget, onChange, node);
default:
// Unsupported / complex widget type — show read-only value
return _readonlyInput(widget);
}
}
/**
* Write a value into a ComfyUI widget and trigger its callback.
* Returns true on success.
* Write a value into a ComfyUI widget and trigger its callback + canvas repaint.
* @param {object} widget
* @param {unknown} newValue
* @param {object|null} [node] Parent LGraphNode
* @returns {boolean}
*/
export function writeWidgetValue(widget, newValue) {
export function writeWidgetValue(widget, newValue, node = null) {
if (!widget) return false;
const type = String(widget.type || "").toLowerCase();
if (type === "number") {
if (type === "number" || type === "int" || type === "float") {
const n = Number(newValue);
if (Number.isNaN(n)) return false;
const opts = widget.options ?? {};
const min = opts.min ?? -Infinity;
const max = opts.max ?? Infinity;
widget.value = Math.min(max, Math.max(min, n));
} else if (type === "toggle") {
let clamped = Math.min(max, Math.max(min, n));
// Round integers
if (type === "int" || opts.precision === 0 || opts.round === 1) {
clamped = Math.round(clamped);
}
widget.value = clamped;
} else if (type === "toggle" || type === "boolean") {
widget.value = Boolean(newValue);
} else {
widget.value = newValue;
}
try { widget.callback?.(widget.value); } catch (e) { console.debug?.(e); }
// Sync ComfyUI's own DOM element for this widget.
// customtext/string widgets use widget.inputEl (a textarea positioned on the canvas).
// Without this, the canvas widget DOM shows the old value even though widget.value changed.
// Called AFTER the callback so it reflects the final snapped/clamped value.
try {
const app = getComfyApp();
app?.canvas?.setDirty?.(true, true);
const canvas = app?.canvas ?? null;
const resolvedNode = node ?? widget?.parent ?? null;
// Save value before callback: LiteGraph number callbacks snap widget.value to
// multiples of step2 (e.g. 1025 → 1024 for step2=16). We restore afterwards so
// the sidebar always writes the exact value the user typed/clicked.
const savedValue = widget.value;
// Call with full LiteGraph/ComfyUI signature: (value, canvas, node, event, widget)
widget.callback?.(widget.value, canvas, resolvedNode, null, widget);
// Restore exact value (bypasses step2 snapping while keeping callback side-effects).
if (type === "number" || type === "int" || type === "float") {
widget.value = savedValue;
}
_syncWidgetDomElement(widget);
canvas?.setDirty?.(true, true);
canvas?.draw?.(true, true);
// Notify the node's own graph (may be an inner subgraph graph, different from root).
const nodeGraph = resolvedNode?.graph ?? null;
if (nodeGraph && nodeGraph !== app?.graph) {
nodeGraph.setDirtyCanvas?.(true, true);
nodeGraph.change?.();
}
// Always notify root graph (triggers ComfyUI workflow tracking).
app?.graph?.setDirtyCanvas?.(true, true);
} catch (e) { console.debug?.(e); }
app?.graph?.change?.();
} catch (e) { console.debug?.("[MFV] writeWidgetValue", e); }
return true;
}
// ── Private builders ──────────────────────────────────────────────────────────
function _numberInput(widget, onChange) {
/**
* Push widget.value into ComfyUI's own DOM element for this widget.
* ComfyUI creates a real textarea/input DOM node for customtext/string widgets
* (widget.inputEl) and positions it on top of the canvas. Updating widget.value
* alone does not refresh that element — we must set its value directly.
*/
function _syncWidgetDomElement(widget) {
const strVal = String(widget.value ?? "");
// ComfyUI customtext: widget.inputEl is the canvas-overlay textarea.
// For PromotedWidgetView (subgraph exposed widgets), there is no direct inputEl:
// the real DOM textarea lives on cachedDeepestByFrame.widget.inputEl.
const el = widget?.inputEl
?? widget?.element
?? widget?.el
?? widget?.cachedDeepestByFrame?.widget?.inputEl
?? widget?.cachedDeepestByFrame?.widget?.element
?? widget?.cachedDeepestByFrame?.widget?.el
?? null;
if (el != null && "value" in el && el.value !== strVal) {
el.value = strVal;
}
}
function _numberInput(widget, onChange, node) {
const input = document.createElement("input");
input.type = "number";
input.className = "mjr-ws-input";
input.value = widget.value ?? "";
const opts = widget.options ?? {};
const wtype = String(widget?.type || "").toLowerCase();
const isInt = wtype === "int" || opts.precision === 0 || opts.round === 1;
if (opts.min != null) input.min = String(opts.min);
if (opts.max != null) input.max = String(opts.max);
if (opts.step != null) input.step = String(opts.step);
// Real-time: write on every keystroke (skip invalid partial states like "-" or ".").
// Arrow step: always 1 for integers so the spinner increments by 1.
// options.step2 (the LiteGraph snap multiplier) is intentionally ignored here
// because writeWidgetValue restores widget.value after the callback, bypassing snap.
if (isInt) {
input.step = "1";
} else {
const precision = opts.precision;
input.step = precision != null ? String(Math.pow(10, -precision)) : "any";
}
// Real-time: write on every keystroke / arrow click.
input.addEventListener("input", () => {
const raw = input.value;
if (raw === "" || raw === "-" || raw === "." || raw.endsWith(".")) return;
writeWidgetValue(widget, raw);
writeWidgetValue(widget, raw, node);
onChange?.(widget.value);
});
// On blur/Enter: sync display with possibly-clamped widget value.
// On blur / Enter: snap display to the clamped/rounded widget value.
input.addEventListener("change", () => {
if (writeWidgetValue(widget, input.value)) {
input.value = widget.value;
if (writeWidgetValue(widget, input.value, node)) {
input.value = String(widget.value);
onChange?.(widget.value);
}
});
return input;
}
function _comboInput(widget, onChange) {
function _comboInput(widget, onChange, node) {
const select = document.createElement("select");
select.className = "mjr-ws-input";
let values = widget.options?.values ?? [];
@@ -107,14 +180,12 @@ function _comboInput(widget, onChange) {
select.appendChild(opt);
}
select.addEventListener("change", () => {
if (writeWidgetValue(widget, select.value)) onChange?.(widget.value);
if (writeWidgetValue(widget, select.value, node)) onChange?.(widget.value);
});
return select;
}
function _textInput(widget, onChange) {
const COLLAPSED_MAX = 80;
function _textInput(widget, onChange, node) {
const wrapper = document.createElement("div");
wrapper.className = "mjr-ws-text-wrapper";
@@ -123,56 +194,30 @@ function _textInput(widget, onChange) {
textarea.value = widget.value ?? "";
textarea.rows = 2;
const expandBtn = document.createElement("button");
expandBtn.type = "button";
expandBtn.className = "mjr-ws-expand-btn";
expandBtn.textContent = "Expand";
expandBtn.style.display = "none";
let expanded = false;
const autoFit = () => {
textarea.style.height = "auto";
const full = textarea.scrollHeight;
if (expanded) {
textarea.style.height = full + "px";
textarea.style.maxHeight = "none";
expandBtn.textContent = "Collapse";
} else {
textarea.style.height = Math.min(full, COLLAPSED_MAX) + "px";
textarea.style.maxHeight = COLLAPSED_MAX + "px";
expandBtn.textContent = "Expand";
}
expandBtn.style.display = full > COLLAPSED_MAX ? "" : "none";
textarea.style.height = textarea.scrollHeight + "px";
};
textarea.addEventListener("change", () => {
if (writeWidgetValue(widget, textarea.value)) onChange?.(widget.value);
if (writeWidgetValue(widget, textarea.value, node)) onChange?.(widget.value);
});
// Real-time: write on every keystroke.
textarea.addEventListener("input", () => {
writeWidgetValue(widget, textarea.value);
writeWidgetValue(widget, textarea.value, node);
onChange?.(widget.value);
autoFit();
});
expandBtn.addEventListener("click", () => {
expanded = !expanded;
autoFit();
});
wrapper.appendChild(textarea);
wrapper.appendChild(expandBtn);
wrapper._mjrAutoFit = autoFit;
textarea._mjrAutoFit = autoFit;
// Initial fit after insertion
requestAnimationFrame(autoFit);
return wrapper;
}
function _toggleInput(widget, onChange) {
function _toggleInput(widget, onChange, node) {
const label = document.createElement("label");
label.className = "mjr-ws-toggle-label";
const input = document.createElement("input");
@@ -180,7 +225,7 @@ function _toggleInput(widget, onChange) {
input.className = "mjr-ws-checkbox";
input.checked = Boolean(widget.value);
input.addEventListener("change", () => {
if (writeWidgetValue(widget, input.checked)) onChange?.(widget.value);
if (writeWidgetValue(widget, input.checked, node)) onChange?.(widget.value);
});
label.appendChild(input);
return label;
@@ -0,0 +1,84 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from "vitest";
const mountUnifiedMediaControlsMock = vi.fn(() => ({ destroy: vi.fn() }));
const mountFloatingViewerSimplePlayerMock = vi.fn((mediaEl) => mediaEl);
vi.mock("../api/endpoints.js", () => ({
buildViewURL: vi.fn((filename) => `/view/${filename}`),
buildAssetViewURL: vi.fn((fileData) => `/asset/${fileData?.filename || ""}`),
}));
vi.mock("../features/viewer/model3dRenderer.js", () => ({
MODEL3D_EXTS: new Set(),
createModel3DMediaElement: vi.fn(),
}));
vi.mock("../features/viewer/mediaPlayer.js", () => ({
mountUnifiedMediaControls: mountUnifiedMediaControlsMock,
}));
vi.mock("../features/viewer/floatingViewerSimplePlayer.js", () => ({
mountFloatingViewerSimplePlayer: mountFloatingViewerSimplePlayerMock,
}));
vi.mock("../utils/mediaFps.js", () => ({
readAssetFps: vi.fn(() => 24),
readAssetFrameCount: vi.fn(() => 48),
}));
describe("floating viewer media player bridge", () => {
beforeEach(() => {
document.body.innerHTML = "";
mountUnifiedMediaControlsMock.mockClear();
mountFloatingViewerSimplePlayerMock.mockClear();
});
it("uses unified video controls for video assets", async () => {
const { buildFloatingViewerMediaElement } = await import(
"../features/viewer/floatingViewerMedia.js"
);
const root = buildFloatingViewerMediaElement({
filename: "clip.mp4",
kind: "video",
});
expect(root?.classList.contains("mjr-mfv-player-host")).toBe(true);
expect(root?._mjrMediaControlsHandle?.destroy).toBeTypeOf("function");
expect(root?.querySelector("video")).toBeTruthy();
expect(mountUnifiedMediaControlsMock).toHaveBeenCalledWith(
expect.any(HTMLVideoElement),
expect.objectContaining({
variant: "viewer",
mediaKind: "video",
initialFps: 24,
initialFrameCount: 48,
}),
);
expect(mountFloatingViewerSimplePlayerMock).not.toHaveBeenCalled();
});
it("uses unified video controls for audio assets", async () => {
const { buildFloatingViewerMediaElement } = await import(
"../features/viewer/floatingViewerMedia.js"
);
const root = buildFloatingViewerMediaElement({
filename: "track.mp3",
kind: "audio",
});
expect(root?.classList.contains("mjr-mfv-player-host")).toBe(true);
expect(root?.querySelector("audio")).toBeTruthy();
expect(mountUnifiedMediaControlsMock).toHaveBeenCalledWith(
expect.any(HTMLAudioElement),
expect.objectContaining({
variant: "viewer",
mediaKind: "audio",
}),
);
expect(mountFloatingViewerSimplePlayerMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,83 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from "vitest";
vi.mock("../app/i18n.js", () => ({
t: (_key, fallback) => fallback || "",
}));
vi.mock("../app/events.js", () => ({
EVENTS: {},
}));
vi.mock("../app/config.js", () => ({
APP_CONFIG: {},
}));
vi.mock("../utils/tooltipShortcuts.js", () => ({
appendTooltipHint: vi.fn(),
setTooltipHint: vi.fn(),
}));
vi.mock("../features/viewer/nodeStream/nodeStreamFeatureFlag.js", () => ({
NODE_STREAM_FEATURE_ENABLED: false,
}));
vi.mock("../features/viewer/workflowSidebar/WorkflowSidebar.js", () => ({
WorkflowSidebar: class {
constructor() {
this.el = document.createElement("div");
this.isVisible = false;
}
toggle() {
this.isVisible = !this.isVisible;
}
},
}));
vi.mock("../features/viewer/workflowSidebar/sidebarRunButton.js", () => ({
createRunButton: () => ({ el: document.createElement("div") }),
}));
vi.mock("../features/viewer/floatingViewerProgress.js", () => ({
buildFloatingViewerMediaProgressOverlay: () => document.createElement("div"),
buildFloatingViewerProgressBar: () => document.createElement("div"),
}));
vi.mock("../components/sidebar/parsers/geninfoParser.js", () => ({
normalizeGenerationMetadata: vi.fn((v) => v),
sanitizePromptForDisplay: vi.fn((v) => v),
}));
describe("floating viewer toolbar overlay controls", () => {
it("renders guides and mask/format controls in the toolbar", async () => {
const { buildFloatingViewerToolbar } = await import("../features/viewer/floatingViewerUi.js");
const viewer = {
_channel: "rgb",
_exposureEV: 0,
_gridMode: 0,
_overlayFormat: "image",
_overlayMaskOpacity: 0.65,
_overlayMaskEnabled: false,
_updateModeBtnUI: vi.fn(),
_updatePinUI: vi.fn(),
_updateGenBtnUI: vi.fn(),
_bindDocumentUiHandlers: vi.fn(),
_redrawOverlayGuides: vi.fn(),
};
const bar = buildFloatingViewerToolbar(viewer);
expect(bar.querySelector(".mjr-mfv-toolbar-select")).toBeTruthy();
expect(viewer._guidesSelect).toBeTruthy();
expect(viewer._channelSelect).toBeTruthy();
expect(viewer._exposureCtl?.input).toBeTruthy();
expect(viewer._formatSelect).toBeTruthy();
expect(viewer._formatToggle).toBeTruthy();
expect(viewer._maskOpacityCtl?.input).toBeTruthy();
expect(viewer._guidesSelect.value).toBe("0");
expect(viewer._channelSelect.value).toBe("rgb");
expect(viewer._formatSelect.value).toBe("image");
});
});
@@ -0,0 +1,50 @@
import { Window } from "happy-dom";
import { describe, expect, it, vi } from "vitest";
vi.mock("../app/i18n.js", () => ({
t: (_key, fallback) => fallback || "",
}));
describe("video controls responsive layout", () => {
it("switches to stacked layout when the host is narrow", async () => {
const window = new Window();
globalThis.window = window;
globalThis.document = window.document;
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: window.navigator,
});
const resizeObservers = [];
globalThis.ResizeObserver = class {
constructor(cb) {
this.cb = cb;
resizeObservers.push(this);
}
observe() {}
disconnect() {}
};
const { mountVideoControls } = await import("../components/VideoControls.js");
const host = document.createElement("div");
Object.defineProperty(host, "clientWidth", {
configurable: true,
get: () => 520,
});
const video = document.createElement("video");
host.appendChild(video);
document.body.appendChild(host);
const mounted = mountVideoControls(video, {
variant: "viewer",
mediaKind: "video",
hostEl: host,
});
resizeObservers[0]?.cb?.();
expect(mounted.controlsEl?.dataset?.mjrLayout).toBe("stacked");
mounted.destroy();
});
});
+749 -20
View File
@@ -3270,7 +3270,7 @@
gap: 12px;
display: grid;
align-items: center;
grid-template-columns: auto 1fr auto auto auto;
grid-template-columns: auto auto 1fr auto auto;
}
.mjr-video-controls--viewerbar .mjr-video-row--top {
@@ -4228,6 +4228,13 @@ body.mjr-mfv-popout-body {
border-bottom: 1px solid var(--mjr-border);
flex-shrink: 0;
min-height: 36px;
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.mjr-mfv-toolbar::-webkit-scrollbar {
display: none;
}
.mjr-mfv-toolbar-sep {
@@ -4336,7 +4343,199 @@ body.mjr-mfv-popout-body {
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--mjr-accent) 24%, transparent);
}
/* ── Icon buttons inside MFV (outside .mjr-assets-manager scope) ─────── */
/* ── Pin group: now a dropdown menu (display:none by default, flex when .is-open) ── */
.mjr-mfv-pin-group {
display: none;
flex-direction: column;
gap: 2px;
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 10010;
min-width: 70px;
padding: 4px;
background: var(--comfy-menu-bg, #2a2a2a);
border: 1px solid var(--mjr-border, rgba(255,255,255,0.12));
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.55);
}
.mjr-mfv-pin-group.is-open {
display: flex;
}
.mjr-mfv-pin-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 30px;
border-radius: 6px;
border: 1px solid transparent;
background: none;
color: var(--content-fg, #ddd);
font-size: 12px;
font-weight: 600;
cursor: pointer;
padding: 0 10px;
text-align: center;
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
}
.mjr-mfv-pin-btn:hover {
background: rgba(255,255,255,0.08);
border-color: color-mix(in srgb, var(--mjr-accent) 35%, transparent);
}
.mjr-mfv-pin-btn.is-pinned {
border-color: color-mix(in srgb, var(--mjr-accent) 58%, #ffffff 12%);
background: color-mix(in srgb, var(--mjr-surface-2, #333) 42%, var(--mjr-accent, #5fb3ff) 58%);
color: #eef7ff;
}
/* ── Icon Dropdown component (.mjr-mfv-idrop) ─────────────────────────── */
.mjr-mfv-idrop {
position: relative;
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.mjr-mfv-idrop-menu {
display: none;
flex-direction: column;
gap: 2px;
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 10010;
min-width: 110px;
padding: 4px;
background: var(--comfy-menu-bg, #2a2a2a);
border: 1px solid var(--mjr-border, rgba(255,255,255,0.12));
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.55);
white-space: nowrap;
}
.mjr-mfv-idrop-menu.is-open {
display: flex;
}
.mjr-mfv-idrop-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 10px;
background: none;
border: none;
border-radius: 6px;
color: var(--input-text, #ddd);
font-size: 12px;
font-family: inherit;
text-align: left;
cursor: pointer;
white-space: nowrap;
transition: background 0.1s ease;
}
.mjr-mfv-idrop-item:hover {
background: rgba(255,255,255,0.08);
}
.mjr-mfv-idrop-item.is-selected {
background: rgba(95,179,255,0.16);
color: #a8d8ff;
}
.mjr-mfv-idrop-trigger[aria-expanded="true"] {
background: rgba(255,255,255,0.1) !important;
border-color: rgba(255,255,255,0.2) !important;
}
/* Channel trigger: slightly wider to fit "RGB" */
.mjr-mfv-ch-label {
font-size: 12px;
letter-spacing: 0.04em;
line-height: 1;
}
/* Toolbar icon-button popovers (channel, guides, pin, format — sort-style context menus) */
.mjr-mfv-ch-popover,
.mjr-mfv-guides-popover,
.mjr-mfv-pin-popover,
.mjr-mfv-format-popover {
position: absolute;
min-width: 120px;
padding: 6px;
border-radius: var(--radius-lg, 0.5rem);
background: var(--mjr-surface-1, var(--comfy-menu-bg, #2a2a2a));
border: 1px solid var(--mjr-border, rgba(255,255,255,0.12));
box-shadow: 0 8px 24px rgba(0,0,0,0.55);
z-index: 10010;
display: none;
}
.mjr-mfv-ch-popover.is-open,
.mjr-mfv-guides-popover.is-open,
.mjr-mfv-pin-popover.is-open,
.mjr-mfv-format-popover.is-open {
display: block;
}
/* Toolbar icon images — ch icon is black (inverted), guides icon is native white */
.mjr-mfv-ch-icon {
width: 18px;
height: 18px;
filter: invert(1);
opacity: 0.85;
display: block;
}
.mjr-mfv-guides-icon {
width: 18px;
height: 18px;
opacity: 0.85;
display: block;
}
.mjr-mfv-ch-trigger[aria-expanded="true"],
.mjr-mfv-guides-trigger[aria-expanded="true"],
.mjr-mfv-pin-trigger[aria-expanded="true"],
.mjr-mfv-format-trigger[aria-expanded="true"] {
background: rgba(255,255,255,0.1) !important;
border-color: rgba(255,255,255,0.2) !important;
}
/* Pin slot active state */
.mjr-mfv-pin-popover .mjr-menu-item.is-pinned {
background: rgba(95,179,255,0.16);
color: #a8d8ff;
}
.mjr-mfv-pin-popover .mjr-menu-item.is-pinned .mjr-menu-item-check {
opacity: 1 !important;
}
/* Format popover: separator + opacity slider row */
.mjr-mfv-format-sep {
height: 1px;
background: var(--mjr-border, rgba(255,255,255,0.1));
margin: 4px 2px;
}
.mjr-mfv-format-slider-row {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px 2px;
}
.mjr-mfv-format-slider-label {
font-size: 11px;
color: var(--input-text, #ddd);
opacity: 0.65;
white-space: nowrap;
flex-shrink: 0;
}
.mjr-mfv-format-popover input[type="range"] {
flex: 1;
min-width: 70px;
accent-color: var(--mjr-accent, #5fb3ff);
}
.mjr-mfv-format-popover .mjr-mfv-toolbar-range-out {
font-size: 11px;
color: var(--input-text, #ddd);
opacity: 0.65;
min-width: 30px;
text-align: right;
flex-shrink: 0;
}
.mjr-mfv .mjr-icon-btn {
width: 30px;
height: 30px;
@@ -4371,6 +4570,18 @@ body.mjr-mfv-popout-body {
opacity: 0.9;
}
/* Universal active highlight — any aria-pressed="true" button gets accent glow.
Placed before the colored active classes so they can override with their own color. */
.mjr-mfv .mjr-icon-btn[aria-pressed="true"] {
border-color: rgba(95, 179, 255, 0.55);
color: var(--mjr-accent, #5fb3ff);
background: color-mix(in srgb, var(--mjr-surface-2, #333) 80%, var(--mjr-accent, #5fb3ff) 15%);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--mjr-accent, #5fb3ff) 25%, transparent);
}
.mjr-mfv .mjr-icon-btn[aria-pressed="true"] .pi {
opacity: 1;
}
.mjr-mfv .mjr-icon-btn.mjr-live-active {
border-color: color-mix(in srgb, var(--mjr-status-success) 60%, var(--mjr-border));
color: var(--mjr-status-success);
@@ -4681,7 +4892,6 @@ body.mjr-mfv-popout-body {
width: 100%;
height: 220px;
overflow-y: auto;
resize: vertical;
min-height: 120px;
max-height: 65%;
}
@@ -4695,6 +4905,53 @@ body.mjr-mfv-popout-body {
min-height: 0;
}
.mjr-mfv-overlay-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 12;
}
.mjr-mfv-toolbar-select {
height: 24px;
padding: 0 6px;
border-radius: 6px;
border: 0.8px solid rgba(196, 202, 210, 0.24);
background: linear-gradient(180deg, rgba(210,214,220,0.06), rgba(210,214,220,0.02));
color: rgba(230,233,238,0.92);
font-size: 11px;
outline: none;
}
.mjr-mfv-toolbar-range {
display: inline-flex;
align-items: center;
gap: 6px;
}
.mjr-mfv-toolbar-range input[type="range"] {
width: 72px;
accent-color: rgba(255,255,255,0.85);
}
.mjr-mfv-toolbar-range-out {
min-width: 30px;
text-align: right;
font-size: 11px;
color: rgba(255,255,255,0.9);
}
/* EV slider active state (value ≠ 0) */
.mjr-mfv-toolbar-range.is-active input[type="range"] {
accent-color: #f5c842;
}
.mjr-mfv-toolbar-range.is-active .mjr-mfv-toolbar-range-out {
color: #f5c842;
font-weight: 600;
}
.mjr-mfv-media-progress {
position: absolute;
top: 10px;
@@ -4737,6 +4994,9 @@ body.mjr-mfv-popout-body {
/* ── Workflow Sidebar ────────────────────────────────────────────────── */
.mjr-ws-sidebar {
position: relative;
display: flex;
flex-direction: column;
width: 0;
overflow: hidden;
background: var(--mjr-surface-1, #2a2a2a);
@@ -4747,17 +5007,12 @@ body.mjr-mfv-popout-body {
.mjr-ws-sidebar.open {
width: 260px;
overflow-y: auto;
resize: horizontal;
overflow: hidden;
min-width: 180px;
max-width: 65%;
transition: none;
}
.mjr-mfv-content-wrapper[data-sidebar-pos="bottom"] .mjr-ws-sidebar {
resize: none;
}
.mjr-ws-sidebar-header {
display: flex;
align-items: center;
@@ -4775,6 +5030,9 @@ body.mjr-mfv-popout-body {
opacity: 0.65;
}
.mjr-ws-sidebar-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0;
}
.mjr-ws-sidebar-empty {
@@ -4785,26 +5043,275 @@ body.mjr-mfv-popout-body {
line-height: 1.5;
}
/* ── Tab bar ──────────────────────────────────────────────────────────── */
.mjr-ws-tab-bar {
display: flex;
flex-shrink: 0;
gap: 2px;
padding: 4px 8px;
border-bottom: 1px solid var(--mjr-border, rgba(255, 255, 255, 0.12));
background: rgba(0, 0, 0, 0.18);
}
.mjr-ws-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 4px 8px;
border: none;
border-radius: 4px;
background: transparent;
color: rgba(255, 255, 255, 0.45);
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.mjr-ws-tab:hover {
background: rgba(255, 255, 255, 0.07);
color: rgba(255, 255, 255, 0.75);
}
.mjr-ws-tab.is-active {
background: rgba(99, 155, 255, 0.15);
color: #639bff;
}
/* ── Nodes tab ────────────────────────────────────────────────────────── */
.mjr-ws-nodes-tab {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.mjr-ws-search-wrap {
position: relative;
display: flex;
align-items: center;
flex-shrink: 0;
padding: 6px 8px;
border-bottom: 1px solid var(--mjr-border, rgba(255, 255, 255, 0.12));
}
.mjr-ws-search-icon {
position: absolute;
left: 16px;
font-size: 11px;
color: rgba(255, 255, 255, 0.3);
pointer-events: none;
}
.mjr-ws-search {
flex: 1;
padding: 4px 28px 4px 26px;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
color: rgba(255, 255, 255, 0.85);
font-size: 11px;
outline: none;
}
.mjr-ws-search::placeholder {
color: rgba(255, 255, 255, 0.28);
}
.mjr-ws-search:focus {
border-color: rgba(99, 155, 255, 0.5);
}
.mjr-ws-search-clear {
position: absolute;
right: 14px;
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.3);
font-size: 10px;
cursor: pointer;
border-radius: 3px;
}
.mjr-ws-search-clear:hover {
color: rgba(255, 255, 255, 0.7);
background: rgba(255, 255, 255, 0.08);
}
.mjr-ws-nodes-list {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.mjr-ws-node-row {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px 5px 12px;
cursor: pointer;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
min-height: 32px;
transition: background 0.1s;
}
.mjr-ws-node-row:hover {
background: rgba(255, 255, 255, 0.06);
}
.mjr-ws-node-row-type {
flex: 1;
font-size: 11px;
color: rgba(255, 255, 255, 0.78);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mjr-ws-node-row-title {
font-size: 10px;
color: rgba(255, 255, 255, 0.4);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 80px;
flex-shrink: 0;
}
.mjr-ws-node-locate {
flex-shrink: 0;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.3);
font-size: 11px;
border-radius: 3px;
cursor: pointer;
opacity: 0;
transition: opacity 0.1s, color 0.1s, background 0.1s;
}
.mjr-ws-node-row:hover .mjr-ws-node-locate {
opacity: 1;
}
.mjr-ws-node-locate:hover {
color: #639bff;
background: rgba(99, 155, 255, 0.15);
}
/* ── Node sections ────────────────────────────────────────────────────── */
.mjr-ws-node {
border-bottom: 1px solid var(--mjr-border, rgba(255, 255, 255, 0.12));
}
.mjr-ws-sidebar-resizer {
position: absolute;
top: 0;
bottom: 0;
width: 14px;
z-index: 2;
cursor: ew-resize;
pointer-events: auto;
}
.mjr-ws-sidebar-resizer::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 4px;
height: 56px;
transform: translate(-50%, -50%);
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06);
opacity: 0;
transition: opacity 0.15s ease, background 0.15s ease;
}
.mjr-ws-sidebar.open:hover .mjr-ws-sidebar-resizer::before,
.mjr-ws-sidebar.is-resizing .mjr-ws-sidebar-resizer::before,
.mjr-ws-sidebar-resizer.is-dragging::before {
opacity: 1;
}
.mjr-ws-sidebar.is-resizing .mjr-ws-sidebar-resizer::before,
.mjr-ws-sidebar-resizer:hover::before,
.mjr-ws-sidebar-resizer.is-dragging::before {
background: rgba(255, 255, 255, 0.34);
}
.mjr-mfv-content-wrapper[data-sidebar-pos="right"] .mjr-ws-sidebar-resizer {
right: 0;
}
.mjr-mfv-content-wrapper[data-sidebar-pos="left"] .mjr-ws-sidebar-resizer {
left: 0;
}
.mjr-mfv-content-wrapper[data-sidebar-pos="bottom"] .mjr-ws-sidebar-resizer {
display: none;
}
.mjr-ws-node-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
background: color-mix(in srgb, var(--mjr-surface-0, #1e1e1e) 40%, var(--mjr-surface-1, #2a2a2a) 60%);
cursor: default;
user-select: none;
transition: background 0.12s ease;
}
.mjr-ws-node-title {
.mjr-ws-node.is-collapsible .mjr-ws-node-header {
cursor: pointer;
}
.mjr-ws-node.is-collapsible .mjr-ws-node-header:hover {
background: color-mix(in srgb, var(--mjr-surface-0, #1e1e1e) 28%, var(--mjr-surface-1, #2a2a2a) 72%);
}
.mjr-ws-node.is-collapsible.is-collapsed .mjr-ws-node-header:hover {
background: color-mix(in srgb, var(--mjr-accent, #5b8af0) 8%, var(--mjr-surface-1, #2a2a2a) 92%);
}
.mjr-ws-node-toggle {
flex: 0 0 auto;
}
.mjr-ws-node-title-wrap {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 1px;
}
.mjr-ws-node-type {
font-weight: 600;
font-size: 12px;
color: var(--content-fg, #ddd);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
margin-right: 6px;
}
.mjr-ws-node-title {
font-size: 10px;
color: rgba(255, 255, 255, 0.48);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mjr-ws-node.is-collapsed .mjr-ws-node-body {
display: none;
}
.mjr-ws-node-body {
display: flex;
@@ -4818,7 +5325,51 @@ body.mjr-mfv-popout-body {
padding: 4px 0;
}
/* ── Widget rows ──────────────────────────────────────────────────────── */
/* ── Nested nodes (inside subgraphs) ─────────────────────────────────── */
.mjr-ws-node--nested {
border-left: 2px solid rgba(91, 138, 240, 0.35);
}
.mjr-ws-node--nested .mjr-ws-node-header {
padding-left: 16px;
background: color-mix(in srgb, var(--mjr-accent, #5b8af0) 5%, var(--mjr-surface-1, #2a2a2a) 95%);
}
.mjr-ws-node--nested.is-collapsible .mjr-ws-node-header:hover {
background: color-mix(in srgb, var(--mjr-accent, #5b8af0) 10%, var(--mjr-surface-1, #2a2a2a) 90%);
}
/* ── Subgraph children group ──────────────────────────────────────────── */
.mjr-ws-children-toggle {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 5px 10px 5px 20px;
background: color-mix(in srgb, var(--mjr-accent, #5b8af0) 6%, var(--mjr-surface-0, #1e1e1e) 94%);
border: none;
border-bottom: 1px solid var(--mjr-border, rgba(255, 255, 255, 0.12));
color: var(--mjr-accent, #5b8af0);
font-size: 11px;
cursor: pointer;
text-align: left;
opacity: 0.8;
transition: opacity 0.1s ease, background 0.1s ease;
}
.mjr-ws-children-toggle--nested {
padding-left: 28px;
}
.mjr-ws-children-toggle:hover {
opacity: 1;
background: color-mix(in srgb, var(--mjr-accent, #5b8af0) 12%, var(--mjr-surface-0, #1e1e1e) 88%);
}
.mjr-ws-children-toggle[aria-expanded="true"] {
opacity: 1;
}
.mjr-ws-children {
border-left: 2px solid rgba(91, 138, 240, 0.25);
margin-left: 6px;
}
.mjr-ws-widget-row {
display: flex;
align-items: flex-start;
@@ -4864,12 +5415,10 @@ body.mjr-mfv-popout-body {
border-color: var(--mjr-accent, #5fb3ff);
}
.mjr-ws-textarea {
resize: vertical;
resize: none;
min-height: 28px;
max-height: 80px;
line-height: 1.4;
overflow-y: auto;
transition: max-height 150ms ease;
overflow-y: hidden;
}
.mjr-ws-text-wrapper {
display: flex;
@@ -5070,6 +5619,186 @@ body.mjr-mfv-popout-body {
font-variant-numeric: tabular-nums;
}
.mjr-mfv-player-host {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
container-type: inline-size;
}
.mjr-mfv-player-host .mjr-video-controls {
z-index: 30;
}
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="compact"],
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="stacked"] {
left: 8px;
right: 8px;
bottom: 8px;
padding: 8px;
gap: 6px;
}
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="compact"] .mjr-video-row--top,
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="stacked"] .mjr-video-row--top {
gap: 8px;
}
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="compact"] .mjr-video-timegroup,
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="stacked"] .mjr-video-timegroup {
justify-content: flex-start;
gap: 6px;
}
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="compact"] .mjr-video-row--bottom,
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="stacked"] .mjr-video-row--bottom {
flex-wrap: nowrap;
overflow: hidden;
gap: 6px;
}
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="compact"] .mjr-video-bottom,
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="stacked"] .mjr-video-bottom {
flex-shrink: 1;
flex-wrap: nowrap;
}
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="compact"] .mjr-video-transport,
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="stacked"] .mjr-video-transport {
flex-wrap: nowrap;
}
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="compact"] .mjr-video-group,
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="stacked"] .mjr-video-group {
flex-wrap: nowrap;
}
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="compact"] .mjr-video-seek-wrap,
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="stacked"] .mjr-video-seek-wrap {
min-width: 0;
}
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="compact"] .mjr-video-bar-timeline,
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="stacked"] .mjr-video-bar-timeline {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="compact"] .mjr-video-bar-timeline .mjr-video-seek-wrap,
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="stacked"] .mjr-video-bar-timeline .mjr-video-seek-wrap {
order: -1;
flex: 1 1 100%;
width: 100%;
}
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="compact"] .mjr-video-bar-timeline .mjr-video-group--in,
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="compact"] .mjr-video-bar-timeline .mjr-video-group--out,
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="stacked"] .mjr-video-bar-timeline .mjr-video-group--in,
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="stacked"] .mjr-video-bar-timeline .mjr-video-group--out {
flex: 0 1 auto;
}
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="compact"] .mjr-video-range-count,
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="compact"] .mjr-video-frame {
min-width: auto;
}
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="stacked"] .mjr-video-range-count {
display: none !important;
}
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="stacked"] .mjr-video-frame {
min-width: auto;
padding: 3px 6px;
}
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="stacked"] .mjr-video-btn--in,
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="stacked"] .mjr-video-btn--out,
.mjr-mfv-player-host .mjr-video-controls[data-mjr-layout="stacked"] .mjr-video-btn--mark {
min-width: 32px;
}
@container (max-width: 900px) {
.mjr-mfv-player-host .mjr-video-controls {
left: 8px;
right: 8px;
bottom: 8px;
padding: 8px;
gap: 6px;
}
.mjr-mfv-player-host .mjr-video-row--top {
gap: 8px;
}
.mjr-mfv-player-host .mjr-video-timegroup {
justify-content: flex-start;
gap: 6px;
}
.mjr-mfv-player-host .mjr-video-row--bottom {
flex-wrap: nowrap;
overflow: hidden;
gap: 6px;
}
.mjr-mfv-player-host .mjr-video-bottom {
flex-shrink: 1;
flex-wrap: nowrap;
}
.mjr-mfv-player-host .mjr-video-transport {
flex-wrap: nowrap;
}
.mjr-mfv-player-host .mjr-video-group {
flex-wrap: nowrap;
}
.mjr-mfv-player-host .mjr-video-seek-wrap {
min-width: 0;
}
.mjr-mfv-player-host .mjr-video-controls--viewerbar .mjr-video-bar-timeline {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.mjr-mfv-player-host .mjr-video-controls--viewerbar .mjr-video-bar-timeline .mjr-video-seek-wrap {
order: -1;
flex: 1 1 100%;
width: 100%;
}
}
@container (max-width: 680px) {
.mjr-mfv-player-host .mjr-video-controls {
padding: 6px;
}
.mjr-mfv-player-host .mjr-video-btn {
padding: 5px 8px;
font-size: 12px;
}
.mjr-mfv-player-host .mjr-video-num {
padding: 4px 6px;
font-size: 11px;
}
.mjr-mfv-player-host .mjr-video-time,
.mjr-mfv-player-host .mjr-video-frame,
.mjr-mfv-player-host .mjr-video-range-count {
font-size: 11px;
}
}
/* ── Settings button active state ─────────────────────────────────────── */
.mjr-mfv-settings-btn.active {
border-color: color-mix(in srgb, var(--mjr-accent, #5fb3ff) 55%, var(--mjr-border, rgba(255, 255, 255, 0.12)));
@@ -5158,8 +5887,8 @@ body.mjr-mfv-popout-body {
position: fixed;
top: var(--mjr-mfv-top-offset, 60px);
right: 20px;
width: 480px;
height: 380px;
width: 640px;
height: 360px;
min-width: 220px;
min-height: 160px;
/* Always flex; visibility + opacity control show/hide so CSS transitions work.
+16 -2
View File
@@ -37,6 +37,20 @@ function syncAssetsQueryVisibility() {
}
}
function onKeepAliveAttached() {
// Re-register the grid container in case the global ref was cleared or
// became stale during the keep-alive detach/reattach cycle.
try {
const container = gridContainerRef.value;
if (container) {
setActiveGridContainer(container);
}
} catch (e) {
console.debug?.(e);
}
syncAssetsQueryVisibility();
}
function installVisibilityObservers() {
try {
disposeVisibilityObservers?.();
@@ -143,7 +157,7 @@ onMounted(() => {
}
installVisibilityObservers();
try {
window.addEventListener("mjr:keepalive-attached", syncAssetsQueryVisibility);
window.addEventListener("mjr:keepalive-attached", onKeepAliveAttached);
document.addEventListener("visibilitychange", syncAssetsQueryVisibility);
} catch (e) {
console.debug?.(e);
@@ -182,7 +196,7 @@ onBeforeUnmount(() => {
}
assetsQueryController = null;
try {
window.removeEventListener("mjr:keepalive-attached", syncAssetsQueryVisibility);
window.removeEventListener("mjr:keepalive-attached", onKeepAliveAttached);
document.removeEventListener("visibilitychange", syncAssetsQueryVisibility);
} catch (e) {
console.debug?.(e);
+14 -11
View File
@@ -1040,19 +1040,22 @@ function _forceVirtualizerMeasure() {
function handleKeepAliveAttached() {
_forceVirtualizerMeasure();
if (!isGridVisible()) {
// Le layout du nouveau container n'est pas encore calculé au moment
// où keepalive-attached est dispatché. On reporte sur le prochain
// cycle de rendu pour que le navigateur ait fini la mise en page.
// Le layout du nouveau container n'est pas encore calculé au moment
// où keepalive-attached est dispatché. Double-rAF + setTimeout 150ms
// couvrent les layouts complexes (animations ComfyUI, heavy CSS recalc).
requestAnimationFrame(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
_forceVirtualizerMeasure();
void maybeFillViewport();
});
_forceVirtualizerMeasure();
void maybeFillViewport();
});
return;
}
void maybeFillViewport();
});
// Fallback: certains environnements (animations, heavy reflow) ne
// stabilisent pas les dimensions dans deux frames. Ce setTimeout garantit
// une mesure finale après que le navigateur ait fini la mise en page.
setTimeout(() => {
_forceVirtualizerMeasure();
void maybeFillViewport();
}, 150);
}
function bindInfiniteScroll(element) {
+6
View File
@@ -723,6 +723,12 @@ export function useGridLoader({
const gridContainer = getGridContainer();
if (!gridContainer || state.loading || state.done) return { ok: true, skipped: true };
if (!canLoadFromHost()) {
// When the grid is empty (initial load or post-reattach) and the host
// isn't measured yet, schedule a single retry so the first page loads
// once the layout settles — prevents a permanent blank grid.
if (!state.assets.length && !state.loading && APP_CONFIG.PREFETCH_NEXT_PAGE) {
scheduleNextPagePrefetch(state.requestId, 400);
}
return { ok: true, skipped: true, hidden: true };
}
if (isExecutionBusy()) {
@@ -1 +1 @@
.mjr-rating-badge[data-v-821bf1b1]{position:absolute;top:6px;right:6px;background:#0000008c;border:1px solid rgba(255,255,255,.12);padding:2px 6px;border-radius:6px;font-size:13px;letter-spacing:1px;display:inline-flex;align-items:center;justify-content:center;pointer-events:none;z-index:10;text-shadow:0 2px 6px rgba(0,0,0,.6);box-shadow:0 6px 18px #00000040;animation:mjr-fade-in-821bf1b1 .2s ease-out}@keyframes mjr-fade-in-821bf1b1{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}.mjr-tags-badge[data-v-15044260]{position:absolute;bottom:6px;left:6px;padding:3px 6px;border-radius:4px;background:#000c;font-size:9px;max-width:calc(100% - 56px);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;z-index:10;box-shadow:0 2px 4px #0000004d;animation:mjr-fade-in-15044260 .2s ease-out}@keyframes mjr-fade-in-15044260{0%{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}.mjr-gentime-badge[data-v-60fe3677]{position:absolute;bottom:6px;right:6px;padding:2px 6px;border-radius:4px;font-size:10px;font-weight:800;letter-spacing:.015em;line-height:1.1;background:#0000008c;border:1px solid rgba(255,255,255,.12);pointer-events:none;z-index:10;box-shadow:0 2px 4px #0000004d;text-shadow:0 1px 2px rgba(0,0,0,.9),0 0 10px color-mix(in srgb,currentColor 35%,transparent);backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px)}.mjr-popover-row[data-v-d1ebebd4]{display:grid;grid-template-columns:110px 1fr;gap:8px;align-items:center;margin-bottom:8px}.mjr-popover-row--3col[data-v-d1ebebd4]{grid-template-columns:110px 1fr 1fr}.mjr-popover-label[data-v-d1ebebd4]{font-size:12px;color:var(--mjr-muted, var(--descrip-text, rgba(255, 255, 255, .65)));white-space:nowrap}.mjr-popover-toggle[data-v-d1ebebd4]{display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;color:var(--fg-color, #e6edf7)}.mjr-filter-group-toggle[data-v-d1ebebd4]{width:100%;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:0;margin:0 0 10px;border:0;background:transparent;color:inherit;cursor:pointer;text-align:left}.mjr-filter-group-title[data-v-d1ebebd4]{font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}.mjr-filter-group-chevron[data-v-d1ebebd4]{font-size:12px;line-height:1;color:var(--mjr-muted, var(--descrip-text, rgba(255, 255, 255, .65)))}.mjr-filter-group-body[data-v-d1ebebd4]{padding-top:2px}.mjr-filter-group[data-v-d1ebebd4]:not(.is-open){padding-bottom:8px}.mjr-filter-group:not(.is-open) .mjr-filter-group-toggle[data-v-d1ebebd4]{margin-bottom:0}.mjr-rating-star[data-v-6d012325]{outline:none}.mjr-rating-star[data-v-6d012325]:hover:not(:disabled){transform:scale(1.2)}.mjr-rating-star[data-v-6d012325]:focus-visible{outline:2px solid var(--focus-outline-color, #4dabf7);outline-offset:2px;border-radius:2px}.mjr-grid-skeleton-layout[data-v-d9ff3613]{display:grid;grid-template-columns:repeat(auto-fill,minmax(132px,1fr));gap:12px}.mjr-grid-skeleton-card[data-v-d9ff3613]{border-radius:12px;border:1px solid rgba(255,255,255,.22);background:#c0c6ce33;overflow:hidden;min-height:146px;box-shadow:0 6px 18px #0000003d}.mjr-grid-skeleton-thumb[data-v-d9ff3613]{aspect-ratio:1 / 1;width:100%;background:linear-gradient(90deg,#9ca2ab85,#dce1e8b8 45%,#9ca2ab85);background-size:240% 100%;animation:mjr-grid-skeleton-shimmer-d9ff3613 1.2s linear infinite}.mjr-grid-skeleton-line[data-v-d9ff3613]{height:10px;margin:8px 10px;border-radius:8px;background:linear-gradient(90deg,#a4aab37a,#d6dbe2b3 45%,#a4aab37a);background-size:240% 100%;animation:mjr-grid-skeleton-shimmer-d9ff3613 1.2s linear infinite}.mjr-grid-skeleton-line--title[data-v-d9ff3613]{width:72%}.mjr-grid-skeleton-line--meta[data-v-d9ff3613]{width:48%;margin-top:2px;margin-bottom:10px}@keyframes mjr-grid-skeleton-shimmer-d9ff3613{0%{background-position:100% 0}to{background-position:-100% 0}}
.mjr-rating-badge[data-v-821bf1b1]{position:absolute;top:6px;right:6px;background:#0000008c;border:1px solid rgba(255,255,255,.12);padding:2px 6px;border-radius:6px;font-size:13px;letter-spacing:1px;display:inline-flex;align-items:center;justify-content:center;pointer-events:none;z-index:10;text-shadow:0 2px 6px rgba(0,0,0,.6);box-shadow:0 6px 18px #00000040;animation:mjr-fade-in-821bf1b1 .2s ease-out}@keyframes mjr-fade-in-821bf1b1{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}.mjr-tags-badge[data-v-15044260]{position:absolute;bottom:6px;left:6px;padding:3px 6px;border-radius:4px;background:#000c;font-size:9px;max-width:calc(100% - 56px);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;z-index:10;box-shadow:0 2px 4px #0000004d;animation:mjr-fade-in-15044260 .2s ease-out}@keyframes mjr-fade-in-15044260{0%{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}.mjr-gentime-badge[data-v-60fe3677]{position:absolute;bottom:6px;right:6px;padding:2px 6px;border-radius:4px;font-size:10px;font-weight:800;letter-spacing:.015em;line-height:1.1;background:#0000008c;border:1px solid rgba(255,255,255,.12);pointer-events:none;z-index:10;box-shadow:0 2px 4px #0000004d;text-shadow:0 1px 2px rgba(0,0,0,.9),0 0 10px color-mix(in srgb,currentColor 35%,transparent);backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px)}.mjr-popover-row[data-v-d1ebebd4]{display:grid;grid-template-columns:110px 1fr;gap:8px;align-items:center;margin-bottom:8px}.mjr-popover-row--3col[data-v-d1ebebd4]{grid-template-columns:110px 1fr 1fr}.mjr-popover-label[data-v-d1ebebd4]{font-size:12px;color:var(--mjr-muted, var(--descrip-text, rgba(255, 255, 255, .65)));white-space:nowrap}.mjr-popover-toggle[data-v-d1ebebd4]{display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;color:var(--fg-color, #e6edf7)}.mjr-filter-group-toggle[data-v-d1ebebd4]{width:100%;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:0;margin:0 0 10px;border:0;background:transparent;color:inherit;cursor:pointer;text-align:left}.mjr-filter-group-title[data-v-d1ebebd4]{font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}.mjr-filter-group-chevron[data-v-d1ebebd4]{font-size:12px;line-height:1;color:var(--mjr-muted, var(--descrip-text, rgba(255, 255, 255, .65)))}.mjr-filter-group-body[data-v-d1ebebd4]{padding-top:2px}.mjr-filter-group[data-v-d1ebebd4]:not(.is-open){padding-bottom:8px}.mjr-filter-group:not(.is-open) .mjr-filter-group-toggle[data-v-d1ebebd4]{margin-bottom:0}.mjr-rating-star[data-v-6d012325]{outline:none}.mjr-rating-star[data-v-6d012325]:hover:not(:disabled){transform:scale(1.2)}.mjr-rating-star[data-v-6d012325]:focus-visible{outline:2px solid var(--focus-outline-color, #4dabf7);outline-offset:2px;border-radius:2px}.mjr-grid-skeleton-layout[data-v-c8da1cb5]{display:grid;grid-template-columns:repeat(auto-fill,minmax(132px,1fr));gap:12px}.mjr-grid-skeleton-card[data-v-c8da1cb5]{border-radius:12px;border:1px solid rgba(255,255,255,.22);background:#c0c6ce33;overflow:hidden;min-height:146px;box-shadow:0 6px 18px #0000003d}.mjr-grid-skeleton-thumb[data-v-c8da1cb5]{aspect-ratio:1 / 1;width:100%;background:linear-gradient(90deg,#9ca2ab85,#dce1e8b8 45%,#9ca2ab85);background-size:240% 100%;animation:mjr-grid-skeleton-shimmer-c8da1cb5 1.2s linear infinite}.mjr-grid-skeleton-line[data-v-c8da1cb5]{height:10px;margin:8px 10px;border-radius:8px;background:linear-gradient(90deg,#a4aab37a,#d6dbe2b3 45%,#a4aab37a);background-size:240% 100%;animation:mjr-grid-skeleton-shimmer-c8da1cb5 1.2s linear infinite}.mjr-grid-skeleton-line--title[data-v-c8da1cb5]{width:72%}.mjr-grid-skeleton-line--meta[data-v-c8da1cb5]{width:48%;margin-top:2px;margin-bottom:10px}@keyframes mjr-grid-skeleton-shimmer-c8da1cb5{0%{background-position:100% 0}to{background-position:-100% 0}}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import { E as S, w as L, e as s } from "./entry-CuODMrGh.js";
import { E as S, w as L, e as s } from "./entry-CfyS4_5Z.js";
let l = null, c = null, d = null, m = !1, u = null, g = null, h = 0, _ = 0, f = null;
const v = /* @__PURE__ */ new WeakMap();
function k() {
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import { _ as nn, j as on, k as tn, A as B, l as en, m as rn, o as cn } from "./entry-CuODMrGh.js";
import { _ as nn, j as on, k as tn, A as B, l as en, m as rn, o as cn } from "./entry-CfyS4_5Z.js";
import { o as E, c as R, t as $, a as O, b as I, n as ln, d as z, e as j } from "./mjr-vue-vendor-DZo-ZbFS.js";
const un = { style: { display: "flex", "flex-direction": "column", gap: "10px", "margin-bottom": "14px" } }, fn = {
key: 0,
@@ -1,4 +1,4 @@
import { M as s, a as o, P as D, b as t, c as M, f as d, g as l, i as E, d as r, r as i } from "./entry-CuODMrGh.js";
import { M as s, a as o, P as D, b as t, c as M, f as d, g as l, i as E, d as r, r as i } from "./entry-CfyS4_5Z.js";
export {
s as MODEL3D_EXTS,
o as MODEL3D_EXT_TO_LOADER,
@@ -1,4 +1,4 @@
import { h as C } from "./entry-CuODMrGh.js";
import { h as C } from "./entry-CfyS4_5Z.js";
function H(t, n, o) {
return 0.2126 * t + 0.7152 * n + 0.0722 * o;
}
@@ -1,4 +1,4 @@
import { i as q, n as Y, s as k } from "./entry-CuODMrGh.js";
import { i as q, n as Y, s as k } from "./entry-CfyS4_5Z.js";
function Z(o) {
if (!o) return null;
try {
+1 -1
View File
@@ -1,2 +1,2 @@
import "./chunks/entry-CuODMrGh.js";
import "./chunks/entry-CfyS4_5Z.js";
import "../../scripts/app.js";