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:
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
@@ -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
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
-1
@@ -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
-1
@@ -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
@@ -1,2 +1,2 @@
|
||||
import "./chunks/entry-CuODMrGh.js";
|
||||
import "./chunks/entry-CfyS4_5Z.js";
|
||||
import "../../scripts/app.js";
|
||||
|
||||
Reference in New Issue
Block a user