Files

617 lines
30 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ============================================================================
* 🚀 ШПАРГАЛКА И РУКОВОДСТВО ПО АДАПТАЦИИ НОД ПОД COMFYUI NODES 2.0 (VUE MODE)
* ============================================================================
*
* Данное руководство собрано на основе реального боевого опыта разработки,
* оптимизации и устранения сложнейших багов в супер-нодах (Trix NDSuper LoRA Loader,
* Trix Bypasser Simple & Groups и др.).
*
* Оно содержит проверенные архитектурные паттерны, готовые сниппеты кода,
* формулы точного математического расчёта геометрии, решения проблем с ресайзом,
* зумом, кликами, двойным хромом и исчезновением элементов.
*
* ============================================================================
* 📋 СОДЕРЖАНИЕ:
* 1. В ЧЁМ РАЗНИЦА МЕЖДУ NODES 1.0 (LiteGraph) И NODES 2.0 (Vue)
* 2. ТОП-7 БАГОВ И ГРАБЛЕЙ ПРИ ПЕРЕХОДЕ НА NODES 2.0
* 3. УНИВЕРСАЛЬНАЯ DOM-АРХИТЕКТУРА (ЗОЛОТОЙ СТАНДАРТ)
* 4. МЕТОД ОПРЕДЕЛЕНИЯ РЕЖИМА: isVueNodes()
* 5. РЕШЕНИЕ ПРОБЛЕМЫ ИСЧЕЗНОВЕНИЯ ПРИ ЗУМЕ: applyAdaptiveCanvasOnly()
* 6. ПРОБРОС ЗУМА КОЛЕСОМ МЫШИ: installCanvasZoomPassthrough()
* 7. СВОБОДНЫЙ РЕСАЙЗ ЗА УГЛЫ И СТЯГИВАНИЕ ПО ШИРИНЕ: gateResizeAndDraw()
* 8. РАСЧЁТ ВЫСОТЫ, БОРЬБА С ПУСТОТОЙ И АВТО-СТЯГИВАНИЕ (Auto-Shrink)
* 9. ЦЕНТРАЛИЗОВАННОЕ ДЕЛЕГИРОВАНИЕ КЛИКОВ (data-act)
* 10. АДАПТИВНЫЙ РЕСПОНСИВ (ResizeObserver + CSS data-mode)
* 11. МАТЕМАТИЧЕСКИ ТОЧНЫЕ ИКОНКИ И ВЫРАВНИВАНИЕ ПО ЛИНЕЙКЕ
* 12. МОДАЛЬНЫЕ ОКНА И ПОИСК НОД В ПОДГРАФАХ (Subgraphs)
* 13. ПОЛНЫЙ ГОТОВЫЙ ШАБЛОН ДЛЯ БЫСТРОГО СТАРТА (Boilerplate)
* ============================================================================
*/
// ============================================================================
// 1. В ЧЁМ РАЗНИЦА МЕЖДУ NODES 1.0 И NODES 2.0
// ============================================================================
/*
* 🏛 Nodes 1.0 (Classic Canvas / LiteGraph):
* - Вся сцена рисуется на одном гигантском элементе <canvas> через 2D-контекст LiteGraph.
* - DOM-виджеты абсолютно позиционируются поверх канваса через `style.left` и `style.top`.
* - Высота ноды (`node.size[1]`) ДОЛЖНА включать высоту заголовка (title bar, обычно 28px)
* плюс высоту контента виджета.
*
* 🌐 Nodes 2.0 (Vue Mode):
* - Каждая нода рендерится как независимый HTML/DOM Vue-компонент.
* - Vue сам оборачивает ноду в свою карточку и САМ добавляет свой заголовок сверху.
* - DOM-виджет монтируется внутрь тела ноды с `width: 100%`.
* - Высота виджета передается через `computeLayoutSize.minHeight = contentH`.
* - Если передать в `node.setSize` значение с запасом под заголовок (`contentH + 34`),
* Vue добавит СВОЙ заголовок СВЕРХУ этого, и внизу образуется огромная пустота (Double-Chrome Bug)!
*/
// ============================================================================
// 2. ТОП-7 БАГОВ И ГРАБЛЕЙ ПРИ ПЕРЕХОДЕ НА NODES 2.0
// ============================================================================
/*
* ❌ Баг 1: «Бесконечный рост ноды» (Runaway Height Growth).
* Причина: Чтение `element.scrollHeight` или `offsetHeight` внутри `calculateHeight`.
* При увеличении размера ноды контейнер растет -> scrollHeight растет -> setSize растет ->
* нода бесконечно ползет вниз каждый кадр.
* Решение: 100% чистая детерминированная математика на основе данных (properties).
*
* ❌ Баг 2: «Лишняя пустота снизу» (Double-Chrome Void Bug).
* Причина: Добавление отступа под заголовок (28-34px) в `node.setSize` в режиме Vue.
* Решение: В Vue режиме передавать в `node.setSize([w, contentH])` только ЧИСТУЮ высоту контента!
*
* ❌ Баг 3: «Нода не стягивается обратно» (Non-Shrinking on Collapse/Delete).
* Причина: В `fitNode` стояла проверка `if (node.size[1] < reqH)`, которая умеет только расти,
* или в Vue режиме был полностью отключен `setSize`.
* Решение: Разделять вызовы на `isStructural = true` (добавление, удаление, сворачивание)
* и `isStructural = false` (обычный перерендер). При `isStructural = true` принудительно
* вызывать `node.setSize([curW, contentH])`, возвращая ноду к минимальной высоте.
*
* ❌ Баг 4: «Обрезание нижних кнопок/элементов» (Clipping Bug).
* Причина: Неверный расчёт геометрии CSS (забыли учесть padding-top, flex-gap, margin-bottom
* или отступ под кнопку добавления).
* Решение: Считать каждый пиксель по точной CSS-модели (см. Раздел 8).
*
* ❌ Баг 5: «Ноду невозможно стянуть обратно по ширине».
* Причина: `widget.computeSize` или `onResize` жестко фиксировали ширину `[Math.max(w, 340), ...]`.
* Решение: Использовать архитектуру `gateResizeAndDraw` с минимальным порогом 240px.
*
* ❌ Баг 6: «Исчезновение ноды при отдалении камеры» (Zoom Out Disappearing).
* Причина: LiteGraph по умолчанию имеет `canvasOnly = true`, и ComfyUI скрывает DOM при зуме.
* Решение: Динамический геттер `applyAdaptiveCanvasOnly(widget)` (`canvasOnly = false` в Vue).
*
* ❌ Баг 7: «Блокировка колесика мыши над нодой в Nodes 1.0».
* Причина: DOM-элемент перехватывает событие `wheel` и не пускает его к холсту.
* Решение: Функция `installCanvasZoomPassthrough(root)`.
*/
// ============================================================================
// 3. УНИВЕРСАЛЬНАЯ DOM-АРХИТЕКТУРА (ЗОЛОТОЙ СТАНДАРТ)
// ============================================================================
/*
* Для 100% стабильной работы в ОБЕИХ версиях ComfyUI:
*
* 1. Создается корневой DOM-контейнер (`div.trix-bp-root`).
* 2. Монтируется через `node.addDOMWidget()`.
* 3. Настраиваются `computeSize`, `computeLayoutSize` и `draw`.
* 4. На класс ноды вешается хук `gateResizeAndDraw(nodeType, minW, minH)`.
* 5. Вся разметка генерируется чистым методом `render(node, isStructural)`.
* 6. Высота управляется методом `fitNode(node, isStructural)`.
*/
// ============================================================================
// 4. МЕТОД ОПРЕДЕЛЕНИЯ РЕЖИМА: isVueNodes()
// ============================================================================
export function isVueNodes() {
return !!(
window.LiteGraph?.vueNodesMode ||
app.canvas?.vueNodesMode ||
(typeof LGraphCanvas !== "undefined" && LGraphCanvas.vueNodesMode)
);
}
// ============================================================================
// 5. РЕШЕНИЕ ПРОБЛЕМЫ ИСЧЕЗНОВЕНИЯ ПРИ ЗУМЕ: applyAdaptiveCanvasOnly()
// ============================================================================
/**
* В LiteGraph classic canvasOnly=true оптимизирует скрытие,
* но в Nodes 2.0 canvasOnly ДОЛЖЕН быть false, иначе нода пропадет при зуме.
*/
export function applyAdaptiveCanvasOnly(widget) {
if (!widget || !widget.options) return widget;
try {
Object.defineProperty(widget.options, "canvasOnly", {
configurable: true,
enumerable: true,
get() {
return !isVueNodes(); // В Nodes 2.0 всегда false!
},
});
} catch (_e) {
widget.options.canvasOnly = !isVueNodes();
}
return widget;
}
// ============================================================================
// 6. ПРОБРОС ЗУМА КОЛЕСОМ МЫШИ: installCanvasZoomPassthrough()
// ============================================================================
/**
* В Nodes 1.0 колесо мыши над DOM-элементом не зумирует холст.
* Этот хук перенаправляет WheelEvent на нативный холст ComfyUI.
*/
export function installCanvasZoomPassthrough(root) {
if (!root || typeof root.addEventListener !== "function") return () => {};
const onWheel = (e) => {
if (isVueNodes()) return; // В Nodes 2.0 Vue сам обрабатывает события
if (e.ctrlKey || e.metaKey) return;
const canvasEl = app?.canvas?.canvas;
if (!canvasEl) return;
e.preventDefault();
e.stopPropagation();
const { clientX, clientY, deltaX, deltaY, deltaMode, ctrlKey, metaKey, shiftKey } = e;
canvasEl.dispatchEvent(new WheelEvent("wheel", {
clientX, clientY, deltaX, deltaY, deltaMode,
ctrlKey, metaKey, shiftKey, bubbles: true, cancelable: true
}));
};
root.addEventListener("wheel", onWheel, { passive: false });
return () => root.removeEventListener("wheel", onWheel);
}
// ============================================================================
// 7. СВОБОДНЫЙ РЕСАЙЗ ПО ШИРИНЕ И 100% АВТО-СТЯГИВАНИЕ: gateResizeAndDraw()
// ============================================================================
/**
* 💡 ВАЖНЕЙШИЕ ПРАВИЛА ДЛЯ СВОБОДНОГО ЗАХВАТА И РЕСАЙЗА НОДЫ:
* 1. В `addDOMWidget` ВСЕГДА ставьте `margin: 4` (или `margin: 2`), а НЕ `margin: 0`!
* При `margin: 0` DOM-виджет перекрывает 4px рамку ноды `.lg-node`, и курсор мыши
* не может зацепить ручки изменения размера на нижних углах и гранях!
* 2. У корневого элемента `.my-root` в CSS должно быть `pointer-events: none`,
* а у кнопок/строк — `pointer-events: auto`.
* 3. Для динамических нод-списков:
* - `calculateRequiredNodeHeight(node)` возвращает `(root.offsetHeight > 0 ? root.offsetHeight : calculateHeight(node)) + 28`.
* - `fitNode(node)` вызывает `node.setSize([curW, reqH])`, обеспечивая мгновенное сжатие в обоих режимах (Nodes 1.0 и 2.0).
* - В `onResize` фиксируем `size[1] = reqMinH`, разрешая пользователю менять только ширину `size[0]`.
* - Никаких глобальных слушателей на `window` — чужие ноды никогда не будут вызывать вздрагиваний!
*/
export function gateResizeAndDraw(nodeType, minW = 240, minH = 60) {
const origOnResize = nodeType.prototype.onResize;
nodeType.prototype.onResize = function (size) {
const domH = (this._trixDomRoot && this._trixDomRoot.offsetHeight > 0)
? this._trixDomRoot.offsetHeight
: (MyDOMRenderer.calculateHeight ? MyDOMRenderer.calculateHeight(this) : (minH || 60));
const reqMinH = domH + 28;
const reqMinW = minW || 240;
if (Array.isArray(size)) {
if (size[0] < reqMinW) size[0] = reqMinW;
size[1] = reqMinH;
} else if (size && typeof size === "object") {
if (typeof size[0] === "number") size[0] = Math.max(size[0], reqMinW);
if (typeof size[1] === "number") size[1] = reqMinH;
if (typeof size.x === "number") size.x = Math.max(size.x, reqMinW);
if (typeof size.y === "number") size.y = reqMinH;
}
if (this.size && Array.isArray(this.size)) {
if (this.size[0] < reqMinW) this.size[0] = reqMinW;
this.size[1] = reqMinH;
}
if (origOnResize) return origOnResize.apply(this, arguments);
};
const origDraw = nodeType.prototype.onDrawForeground;
nodeType.prototype.onDrawForeground = function (ctx) {
if (origDraw) origDraw.call(this, ctx);
if (this.flags?.collapsed || isVueNodes()) return;
if (this.size && Array.isArray(this.size)) {
if (this.size[0] < minW) this.size[0] = minW;
}
};
}
// ============================================================================
// 8. РАСЧЁТ ВЫСОТЫ, БОРЬБА С ПУСТОТОЙ И АВТО-СТЯГИВАНИЕ (Auto-Shrink)
// ============================================================================
/*
* 📐 ПРАВИЛО ТОЧНОГО РАСЧЁТА ГЕОМЕТРИИ (Exact CSS Geometry):
* Никогда не угадывайте случайные числа. Считайте точно по CSS блокам:
*
* 1. Базовый контейнер (BASE_H):
* - root padding-top: 3px
* - header tray height: 27px
* - header margin-bottom + gap: 5px
* - root padding-bottom: 6px
* = ИТОГО BASE_H = 41px.
*
* 2. Обычная строка (Simple Row):
* - Высота строки: 22px
* - margin-bottom: 2px
* = 24px на каждый элемент.
*
* 3. Карточка группы (Group Card):
* - Свёрнутая группа: header(22px) + padding(6px) + border(2px) + margin(3px) = 33px.
* - Раскрытая группа с N строками:
* header(30px) + gap(2px) + строки(26px * N) + кнопка добавления(23px) + margin(3px)
* = 56 + 26px * N.
*
* 🌟 ЗОЛОТОЙ МЕТОД fitNode + ResizeObserver (АРХИТЕКТУРА TrixPromptAIO):
*
* 1. `calculateRequiredNodeHeight(node)`:
* - Считывает РЕАЛЬНУЮ высоту DOM из браузера: `root.offsetHeight > 0 ? root.offsetHeight : calculateHeight(node)`
* - Добавляет 28px шапки: `domH + 28`.
* 2. `fitNode(node)`:
* - Вызывает `node.setSize([curW, reqH])` в обоих режимах (Nodes 1.0 и 2.0).
* 3. `ResizeObserver` (главный секрет идеального схлопывания):
* - Отслеживает изменение высоты `root` в браузере в реальном времени.
* - При сворачивании групп, удалении или добавлении строк моментально схлопывает рамку ноды пиксель-в-пиксель!
*/
export class MyDOMRenderer {
/**
* Возвращает чистую высоту DOM-контента (детерминированная математика)
*/
static calculateHeight(node) {
if (!node?.properties?.myState) return 50;
const state = node.properties.myState;
const BASE_H = 41; // root pad top(3) + header(27) + gap(5) + pad bot(6)
const items = state.items || [];
return Math.max(50, BASE_H + items.length * 24);
}
/**
* Полная высота ноды (dom widget + 28px шапка)
*/
static calculateRequiredNodeHeight(node) {
const domH = (node && node._myDomRoot && node._myDomRoot.offsetHeight > 0)
? node._myDomRoot.offsetHeight
: this.calculateHeight(node);
return domH + 28;
}
/**
* 🎯 КЛЮЧЕВОЙ МЕТОД АВТО-ПОДГОНКИ РАЗМЕРА:
*/
static fitNode(node, isStructural = false) {
if (!node || node.flags?.collapsed) return;
const requiredH = this.calculateRequiredNodeHeight(node);
const currentW = Math.max(node.size?.[0] || 320, 240);
if (node.setSize) {
node.setSize([currentW, requiredH]);
} else if (node.size) {
node.size[0] = currentW;
node.size[1] = requiredH;
}
if (node.setDirtyCanvas) node.setDirtyCanvas(true, true);
}
}
// ============================================================================
// 9. ЦЕНТРАЛИЗОВАННОЕ ДЕЛЕГИРОВАНИЕ КЛИКОВ (data-act)
// ============================================================================
/*
* Вместо сотен слушателей на каждой кнопке:
* 1. В HTML элементах пишем: `data-act="addItem"`, `data-act="toggleActive"`.
* 2. Вешаем ОДИН слушатель на корневой `root`.
* 3. Индексы извлекаем безопасно:
*/
export function setupClickDelegation(root, node, actionHandler) {
root.addEventListener("click", (e) => {
const actEl = e.target.closest("[data-act]");
if (!actEl) return;
e.stopPropagation();
const act = actEl.dataset.act;
const itemEl = actEl.closest("[data-item-idx]");
const groupEl = actEl.closest("[data-group-idx]");
const itemIdx = actEl.dataset.itemIdx !== undefined
? parseInt(actEl.dataset.itemIdx)
: (itemEl ? parseInt(itemEl.dataset.itemIdx) : null);
const groupIdx = actEl.dataset.groupIdx !== undefined
? parseInt(actEl.dataset.groupIdx)
: (groupEl ? parseInt(groupEl.dataset.groupIdx) : null);
actionHandler(node, act, actEl, { itemIdx, groupIdx, event: e });
});
}
// ============================================================================
// 10. АДАПТИВНЫЙ РЕСПОНСИВ (ResizeObserver + CSS data-mode)
// ============================================================================
/*
* При сужении ноды по ширине не нужно перерисовывать DOM!
* Достаточно отслеживать ширину через ResizeObserver и переключать CSS `data-mode`:
*
* 1. Широкая (> 340px): "full" -> "Single", "Multi", "Add Target"
* 2. Средняя (260-340px): "short" -> "Sngl", "Mult", "+ T"
* 3. Узкая (< 260px): "icon" -> "⛂", "⛃", "+"
*/
export const RESPONSIVE_CSS = `
.my-root:not([data-mode]) .lbl-short,
.my-root:not([data-mode]) .lbl-icon,
.my-root[data-mode="full"] .lbl-short,
.my-root[data-mode="full"] .lbl-icon { display: none !important; }
.my-root[data-mode="short"] .lbl-full,
.my-root[data-mode="short"] .lbl-icon { display: none !important; }
.my-root[data-mode="icon"] .lbl-full,
.my-root[data-mode="icon"] .lbl-short { display: none !important; }
`;
export function setupResponsiveObserver(root, node) {
const updateMode = (w) => {
const newMode = w >= 340 ? "full" : (w >= 260 ? "short" : "icon");
if (root.dataset.mode !== newMode) {
root.dataset.mode = newMode;
}
};
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
updateMode(entry.contentRect.width);
const domH = root.offsetHeight > 0 ? root.offsetHeight : (root.scrollHeight > 0 ? root.scrollHeight : entry.contentRect.height);
if (domH > 0 && node && !node.flags?.collapsed) {
const targetH = Math.ceil(domH + 28);
const curW = Math.max(node.size?.[0] || 320, 240);
if (node.size && node.size[1] !== targetH) {
if (node.setSize) {
node.setSize([curW, targetH]);
} else if (node.size) {
node.size[0] = curW;
node.size[1] = targetH;
}
if (node.setDirtyCanvas) node.setDirtyCanvas(true, true);
}
}
}
});
ro.observe(root);
return ro;
}
// ============================================================================
// 11. МАТЕМАТИЧЕСКИ ТОЧНЫЕ ИКОНКИ И ВЫРАВНИВАНИЕ ПО ЛИНЕЙКЕ
// ============================================================================
/*
* ⚠️ Текстовые символы (✕, 👁) плавают по высоте в разных браузерах и ОС.
* ✅ Идеальное решение — микро-SVG с фиксированным viewBox:
*/
export const ICONS = {
CROSS: `<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="pointer-events:none;display:block;"><path d="M2.2 2.2L7.8 7.8M7.8 2.2L2.2 7.8"/></svg>`,
CROSS_SM: `<svg width="8.5" height="8.5" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="pointer-events:none;display:block;"><path d="M2.2 2.2L7.8 7.8M7.8 2.2L2.2 7.8"/></svg>`,
EYE: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="pointer-events:none;display:block;"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>`,
};
/*
* Изящный эффект Inset для вложенных карточек/групп:
* Чтобы группы не выглядели тяжелыми и широкими, задайте им отступы по краям:
* .trix-bp-group-card {
* margin: 0 5px 3px 5px; // 5px слева и справа
* border-radius: 5px;
* padding: 3px 4px;
* }
*/
// ============================================================================
// 12. МОДАЛЬНЫЕ ОКНА И ПОИСК НОД В ПОДГРАФАХ (Subgraphs)
// ============================================================================
/*
* В ComfyUI ноды могут быть упакованы в Subgraph (подграфы).
* Обычный `app.graph.getNodeById()` их не видит!
* Используйте рекурсивный поиск с поддержкой путей `subgraph_id:inner_id`:
*/
export function findNodeRecursively(graph, id) {
if (!graph) return null;
id = String(id).trim();
if (id.includes(":")) {
const parts = id.split(":");
let currentGraph = app.graph;
let node = null;
for (const pid of parts) {
if (!currentGraph) return null;
node = currentGraph.getNodeById ? currentGraph.getNodeById(parseInt(pid)) : null;
if (!node) {
const nodes = currentGraph._nodes || currentGraph.nodes || [];
node = nodes.find(n => String(n.id) === String(pid));
}
if (!node) return null;
currentGraph = node.getInnerGraph ? node.getInnerGraph() : (node.innerGraph || node.subgraph || null);
}
return node;
}
const numericId = parseInt(id);
if (!isNaN(numericId) && graph.getNodeById) {
const n = graph.getNodeById(numericId);
if (n) return n;
}
const nodes = graph._nodes || graph.nodes || [];
for (const n of nodes) {
if (String(n.id) === String(id)) return n;
const inner = n.getInnerGraph ? n.getInnerGraph() : (n.innerGraph || n.subgraph || null);
if (inner) {
const found = findNodeRecursively(inner, id);
if (found) return found;
}
}
return null;
}
// ============================================================================
// 13. ПОЛНЫЙ ГОТОВЫЙ ШАБЛОН ДЛЯ БЫСТРОГО СТАРТА (Boilerplate)
// ============================================================================
export class MyUniversalNodeDOMRenderer {
static mount(node) {
if (node._myDomRoot) {
this.render(node, true);
return node._myDomWidget;
}
const root = document.createElement("div");
root.className = "my-node-root";
root.dataset.mode = "full";
node._myDomRoot = root;
// 1. Проброс зума для Nodes 1.0
installCanvasZoomPassthrough(root);
// 2. Респонсив наблюдатель + Авто-подгонка высоты (ResizeObserver)
node._myResizeObs = setupResponsiveObserver(root, node);
// 3. Делегирование кликов
root.addEventListener("click", (e) => {
const actEl = e.target.closest("[data-act]");
if (!actEl) return;
e.stopPropagation();
const act = actEl.dataset.act;
this.handleAction(node, act, actEl, e);
});
// 4. Регистрация DOM-виджета (margin: 4 обязателен для доступности рамки ресайза!)
const widget = node.addDOMWidget("my_dom_ui", "my_dom_widget", root, {
getValue: () => node.properties?.myState || null,
setValue: () => {},
getMinHeight: () => this.calculateHeight(node),
margin: 4,
serialize: false,
});
// 5. Размеры для LiteGraph и Vue
applyAdaptiveCanvasOnly(widget);
widget.computeSize = (w) => [w || 240, this.calculateHeight(node)];
// 6. Позиционирование в LiteGraph Classic
const origDraw = widget.draw;
widget.draw = function(ctx, n, widget_width, y, H) {
if (origDraw) origDraw.apply(this, arguments);
if (this.element && !n.flags?.collapsed) {
if (!isVueNodes()) {
const marginLeft = 3;
const marginRight = 3;
const topOffset = Math.max(y || 0, 28);
const curW = (n.size && n.size[0] > 0) ? n.size[0] : (widget_width || 320);
this.element.style.setProperty("left", (n.pos[0] + marginLeft) + "px", "important");
this.element.style.setProperty("top", (n.pos[1] + topOffset) + "px", "important");
this.element.style.setProperty("width", (curW - marginLeft - marginRight) + "px", "important");
this.element.style.setProperty("height", "auto", "important");
this.element.style.setProperty("margin", "0px", "important");
this.element.style.setProperty("box-sizing", "border-box", "important");
} else {
this.element.style.removeProperty("left");
this.element.style.removeProperty("top");
this.element.style.setProperty("width", "100%", "important");
this.element.style.setProperty("box-sizing", "border-box", "important");
this.element.style.removeProperty("margin");
}
}
};
node._myDomWidget = widget;
this.render(node, true);
return widget;
}
static calculateHeight(node) {
if (!node?.properties?.myState) return 50;
const BASE_H = 41;
const count = (node.properties.myState.items || []).length;
return Math.max(50, BASE_H + count * 24);
}
static calculateRequiredNodeHeight(node) {
const domH = (node && node._myDomRoot && node._myDomRoot.offsetHeight > 0)
? node._myDomRoot.offsetHeight
: this.calculateHeight(node);
return domH + 28;
}
static fitNode(node, isStructural = false) {
if (!node) return;
const requiredH = this.calculateRequiredNodeHeight(node);
const curW = Math.max(node.size?.[0] || 320, 240);
if (isStructural) {
if (node.setSize) {
node.setSize([curW, requiredH]);
} else if (node.size) {
node.size[0] = curW;
node.size[1] = requiredH;
}
if (node.setDirtyCanvas) node.setDirtyCanvas(true, true);
} else if (node.size && node.size[1] < requiredH) {
if (node.setSize) {
node.setSize([curW, requiredH]);
} else if (node.size) {
node.size[0] = curW;
node.size[1] = requiredH;
}
if (node.setDirtyCanvas) node.setDirtyCanvas(true, true);
}
}
static handleAction(node, act, actEl, e) {
if (act === "add") {
node.properties.myState.items.push({ text: "New Item" });
this.render(node, true); // true = структурное изменение!
} else if (act === "delete") {
const idx = parseInt(actEl.dataset.idx);
node.properties.myState.items.splice(idx, 1);
this.render(node, true); // true = структурное изменение!
}
}
static render(node, isStructural = false) {
const root = node._myDomRoot;
if (!root) return;
const items = node.properties?.myState?.items || [];
root.innerHTML = `
<div class="my-header">
<button data-act="add">+ Добавить</button>
</div>
<div class="my-list">
${items.map((item, idx) => `
<div class="my-row">
<span>${item.text}</span>
<button data-act="delete" data-idx="${idx}">×</button>
</div>
`).join("")}
</div>
`;
this.fitNode(node, isStructural);
}
}
console.log("[Nodes2tips] Руководство и шпаргалка успешно обновлены с учетом всех боевых правок!");