Files
IxMxAMAR e8e673caf6 v0.3.1: collapsed-node stripe bleed + wire render mode dropdown off-by-one
- Skip theme decoration (accent stripe + drawForeground/drawBackground)
  when node.flags.collapsed. LiteGraph keeps node.size[1] at the expanded
  height when collapsed and does NOT clip per-node draws, so the 4px
  left-edge accent stripe was painting a vertical bar past the bottom of
  the collapsed title pill.

- Rewrite wire render mode dropdown with explicit label->value pairs.
  v0.3.0 used indexOf on a label-only array which mapped 1:1 to the
  setting value, but LiteGraph's constants are 0-based and non-contiguous
  (HIDDEN=-1, STRAIGHT=0, LINEAR=1, SPLINE=2). So picking "straight"
  actually set LINEAR, picking "linear" set SPLINE, and the bezier/
  manhattan options sent invalid 3/4 that LiteGraph fell back to default
  for. v0.3.1 maps each label to the correct constant, drops the
  fictional "manhattan (L-shape)" option, and adds "hidden".

All 33 JS tests still pass.
2026-05-21 16:19:57 +05:30

255 lines
8.8 KiB
JavaScript

import { invalidateCache } from "./cache.js";
const STATIC_KEYS = ["bgcolor", "color", "boxcolor", "titlecolor", "titletext", "shape"];
// Stripe drawn on the left edge of each node when the theme declares
// `categoryAccents`. Width is in node-local pixels.
const ACCENT_STRIPE_WIDTH = 4;
/**
* Wrap-once theme engine.
*
* v0.2.x snapshot-and-restored `onDrawForeground` on every theme change. If
* ANOTHER extension wrapped the draw method AFTER us, the next theme switch
* blindly restored our snapshot — silently destroying the other extension's
* wrapper.
*
* v0.3.0 installs ONE wrapper per node on first apply, then stores the active
* theme + accent on the node itself. Theme switches just update those fields;
* we never touch `onDrawForeground` again. This means our wrapper survives
* being re-wrapped by other extensions: their wrap calls ours, ours calls the
* pre-MegaPack original.
*
* Static keys (bgcolor/color/etc.) ARE still snapshot/restored — those are
* plain property assignments and aren't subject to the wrap-stomp problem.
*/
const MP_FG_TAG = "_mp_fg_wrapped";
const MP_BG_TAG = "_mp_bg_wrapped";
const MP_STATIC_SNAP = "_mp_static_snap";
export class ThemeEngine {
constructor({ themeApi } = {}) {
this.themeApi = themeApi ?? { motionAllowed: () => true };
}
applyTheme(node, theme, opts = {}) {
invalidateCache(node);
const isDefault = !theme || theme.id === "default";
// Update the live state read by our (already-installed) wrappers.
node._mp_theme = isDefault ? null : theme;
node._mp_accent = (!isDefault && theme.categoryAccents)
? resolveCategoryAccent(node, theme.categoryAccents)
: null;
// Static-config keys: snapshot once, then restore-or-overwrite each apply.
this.#restoreStatic(node);
if (!isDefault && theme.staticConfig) {
this.#snapshotStatic(node);
for (const key of STATIC_KEYS) {
if (theme.staticConfig[key] !== undefined) {
node[key] = theme.staticConfig[key];
}
}
// Auto-contrast: if the theme's titletext has poor contrast against the
// titlecolor (often happens with user-customized themes), flip it to
// pure black or white based on which gives better luminance contrast.
if (opts.autoContrast && theme.staticConfig.titlecolor && theme.staticConfig.titletext) {
const bgL = _relLuminance(theme.staticConfig.titlecolor);
const fgL = _relLuminance(theme.staticConfig.titletext);
const ratio = _contrastRatio(bgL, fgL);
if (ratio < 4.5) { // WCAG AA threshold
node.titletext = bgL > 0.5 ? "#000000" : "#ffffff";
}
}
}
// Install draw wrappers exactly once per node, regardless of theme. Other
// extensions can wrap us before or after and we both compose cleanly.
//
// Collapsed-node note: LiteGraph keeps `node.size` at the expanded values
// even when `flags.collapsed` is true (the node renders as a small title
// pill but the size array doesn't shrink). LiteGraph also does NOT clip
// per-node `onDrawForeground` to the collapsed pill, so a naive accent
// stripe at `node.size[1]` height bleeds past the bottom of the pill.
// Skip our theme decoration entirely when collapsed; the title pill is
// tiny and decorations there look broken anyway.
if (!node[MP_FG_TAG]) {
const origFg = node.onDrawForeground;
node.onDrawForeground = function (ctx) {
if (origFg) origFg.call(this, ctx);
if (this.flags?.collapsed) return;
const accent = this._mp_accent;
if (accent) {
const h = this.size?.[1] ?? 0;
if (h > 0) {
ctx.save();
ctx.fillStyle = accent;
ctx.fillRect(0, 0, ACCENT_STRIPE_WIDTH, h);
ctx.restore();
}
}
const t = this._mp_theme;
if (t?.drawForeground) {
t.drawForeground.call(this, ctx, this, t.themeApi);
}
};
node[MP_FG_TAG] = true;
}
if (!node[MP_BG_TAG]) {
const origBg = node.onDrawBackground;
node.onDrawBackground = function (ctx) {
if (origBg) origBg.call(this, ctx);
if (this.flags?.collapsed) return;
const t = this._mp_theme;
if (t?.drawBackground) {
t.drawBackground.call(this, ctx, this, t.themeApi);
}
};
node[MP_BG_TAG] = true;
}
if (node.setDirtyCanvas) node.setDirtyCanvas(true, true);
}
#snapshotStatic(node) {
if (node[MP_STATIC_SNAP]) return;
const snap = {};
for (const key of STATIC_KEYS) snap[key] = node[key];
node[MP_STATIC_SNAP] = snap;
}
#restoreStatic(node) {
const snap = node[MP_STATIC_SNAP];
if (!snap) return;
for (const key of STATIC_KEYS) node[key] = snap[key];
delete node[MP_STATIC_SNAP];
}
}
/**
* Match a node's category against the theme's category-accent map.
*
* Categories are typically slash-delimited strings like "image/upscaling".
* We match the first path segment so themes can declare just "image" once
* and have it apply to "image", "image/upscaling", "image/postprocessing".
*
* Falls back to `accents.default` if no segment matches.
*/
export function resolveCategoryAccent(node, accents) {
const raw = node?.constructor?.category ?? node?.category ?? "";
if (!raw || typeof accents !== "object") return accents?.default ?? null;
const segments = String(raw).toLowerCase().split("/").filter(Boolean);
for (const seg of segments) {
if (accents[seg]) return accents[seg];
}
return accents.default ?? null;
}
/**
* Apply theme-level global tweaks: group colors + canvas background.
*
* Originals are stashed on the GROUP OBJECTS (via WeakMap-ish properties) so
* deleted groups can be garbage collected. v0.2.x kept strong refs in a
* module-level array, which leaked.
*/
let _originalCanvasBg = null;
const _groupOriginals = new WeakMap();
// Track which theme is currently driving the global tweaks. Lets newly
// created groups (via onGroupAdded in extension.js) pick up the theme.
let _activeGlobalTheme = null;
export function getActiveGlobalTheme() { return _activeGlobalTheme; }
export function applyGlobalTweaks(app, theme) {
if (!app) return;
_activeGlobalTheme = theme ?? null;
// ----- Background grid color
if (theme?.bgGridColor) {
if (_originalCanvasBg === null) {
_originalCanvasBg = app.canvas?.clear_background_color ?? null;
}
if (app.canvas) {
app.canvas.clear_background_color = theme.bgGridColor;
app.canvas.setDirty?.(true, true);
}
} else if (_originalCanvasBg !== null && app.canvas) {
app.canvas.clear_background_color = _originalCanvasBg;
_originalCanvasBg = null;
app.canvas.setDirty?.(true, true);
}
// ----- Group colors (stashed on each group via WeakMap)
if (!app.graph?._groups) return;
if (theme?.groupColors) {
for (const grp of app.graph._groups) {
if (!_groupOriginals.has(grp)) {
_groupOriginals.set(grp, { color: grp.color, bgcolor: grp.bgcolor });
}
if (theme.groupColors.color) grp.color = theme.groupColors.color;
if (theme.groupColors.bgcolor) grp.bgcolor = theme.groupColors.bgcolor;
}
app.canvas?.setDirty?.(true, true);
} else {
let restored = false;
for (const grp of app.graph._groups) {
const orig = _groupOriginals.get(grp);
if (!orig) continue;
grp.color = orig.color;
grp.bgcolor = orig.bgcolor;
_groupOriginals.delete(grp);
restored = true;
}
if (restored) app.canvas?.setDirty?.(true, true);
}
}
// ---------------- Color helpers for auto-contrast ----------------
function _parseHex(c) {
if (typeof c !== "string") return null;
const m = /^#?([0-9a-f]{6})$/i.exec(c.trim());
if (!m) {
const m3 = /^#?([0-9a-f]{3})$/i.exec(c.trim());
if (!m3) return null;
const [r, g, b] = m3[1].split("").map((x) => parseInt(x + x, 16));
return [r, g, b];
}
const v = parseInt(m[1], 16);
return [(v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff];
}
function _relLuminance(color) {
const rgb = _parseHex(color);
if (!rgb) return 0.5;
const [r, g, b] = rgb.map((c) => {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function _contrastRatio(l1, l2) {
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
/** Apply the active theme's group colors to a freshly-added group. */
export function applyThemeToGroup(group) {
const theme = _activeGlobalTheme;
if (!theme?.groupColors || !group) return;
if (!_groupOriginals.has(group)) {
_groupOriginals.set(group, { color: group.color, bgcolor: group.bgcolor });
}
if (theme.groupColors.color) group.color = theme.groupColors.color;
if (theme.groupColors.bgcolor) group.bgcolor = theme.groupColors.bgcolor;
}