API редактора и взаимодействия
Всё ниже — методы объекта editor, который возвращает XenolithEditor.init(...).
Построение графа
// Добавить ноду, которую вы собрали сами.editor.addNode(node, { category: 'logic', title: 'Source', collapsed: false })
// Соединить выходной пин с входным (индексы в массиве `pins` каждой ноды).editor.connect(source, 0, sink, 1, { sourceType: 'float' })Но большинство приложений не лепят ноды руками — они загружают документ.
Загрузка и сохранение (формат xenolith.v1)
Редактор говорит на одном каноническом JSON-формате. loadJSON заменяет весь граф, выделение и вьюпорт; toJSON сериализует обратно.
editor.loadJSON(graphDoc) // заменить сцену из документа xenolith.v1const doc = editor.toJSON() // сериализовать ноды + связи + render-опции + вьюпортДокумент — это { version: 'xenolith.v1', nodes: [...], edges: [...], viewport? }. Сериализуются пины, размеры, свёрнутость, category/title и per-edge sourceType. Это рекомендуемый способ задать граф — опишите его как данные, зарегистрируйте схемы, затем загрузите:
for (const schema of schemas) editor.registry.register(schema) // чтобы Tab мог их создаватьeditor.loadJSON(graphDoc)editor.fitView()Вписывание вида
fitView считает общий bounding box всех нод и вписывает его в канвас — надёжный способ показать только что загруженный граф любого размера.
editor.fitView() // отступ 64px, не приближает больше 1×editor.fitView({ padding: 80, maxZoom: 1 })editor.resetView() // вернуться к единичному виду| Опция | По умолчанию | Значение |
|---|---|---|
padding | 64 | Экранный отступ со всех сторон |
maxZoom | 1 | Не приближать сильнее (чтобы маленький граф не занял весь экран) |
minZoom | нижняя граница зума | Не отдалять сильнее |
Вставка нод и reroute
// Создать зарегистрированную схему в мировой точке (undoable, выделяется).editor.insertNode('Transform', { x: 320, y: 200 })editor.insertNode('Transform', midpoint, { center: true }) // по центру точки
// Разрезать ребро инлайн-reroute в мировой точке.editor.insertRerouteOnEdge(edgeId, worldPos)
// Удалить только одно ребро; висящие инлайн-reroute убираются автоматически.editor.deleteEdge(edgeId)Два вида reroute
- Инлайн-точка (
$reroute) — создаётся разрезанием ребра. Неперетягиваемая точка, проводящая провод и берущая его цвет типа. Сама по себе существовать не может: удаление последней связи убирает её. - Reroute-нода (
Reroute) — перемещаемый прямоугольный ретранслятор, из которого можно тянуть новые провода. Встроенная схема, всегда есть в палитре вставки.
Взаимодействия из коробки
| Жест | Действие |
|---|---|
Tab / двойной клик по пустому канвасу | Палитра вставки (fuzzy-поиск; встроенные ноды выше) |
| Правый клик по срединной точке ребра | Контекстное меню: Add Node (только совместимые) · Add Reroute · Delete |
| Тянуть от пина | Живой ghost-edge с проверкой совместимости типов |
Alt+тянуть подключённый пин | Оторвать ребро и перецепить |
| Тянуть ноду / marquee | Перемещение (снап 8px, Alt отключает) / рамка выделения |
Delete · ⌘Z / ⌘⇧Z · ⌘C/⌘V · ⌘D · ⌘A | Удалить · undo/redo · копировать/вставить · дублировать · выделить всё |
Оверлей загрузки
Тяжёлый первый рендер (большие графы) можно обернуть в темизируемый оверлей с блюром и спиннером. Он рисуется первым, выполняет вашу работу за блюром, затем плавно исчезает — большой граф появляется мягко, а не зависает и выскакивает. Стилизуется через paletteStyle активной темы.
await editor.withOverlay('Rendering…', () => { editor.loadJSON(bigGraph) editor.fitView()})
// Или вручную:editor.showOverlay('Loading…')editor.hideOverlay()Виджеты
Ноды несут встроенные контролы. Виджет — это декларативные данные на ноде (или её схеме); значение лежит в node.state[key], поэтому сериализуется с графом, а каждое изменение undoable.
widgets: [ { id: 'steps', type: 'number', label: 'Steps', key: 'steps', min: 1, max: 150, step: 1 }, { id: 'cfg', type: 'slider', label: 'CFG', key: 'cfg', min: 0, max: 20, step: 0.1 }, { id: 'sampler',type: 'combo', label: 'Sampler', key: 'sampler', values: ['euler', 'dpm++', 'ddim'] }, { id: 'prompt', type: 'text', label: 'Prompt', key: 'prompt', multiline: true }, { id: 'hires', type: 'toggle', label: 'Hi-res', key: 'hires' }, { id: 'tint', type: 'color', label: 'Tint', key: 'tint' }, { id: 'run', type: 'button', label: 'Run', action: 'run' },]Встроенные типы: number (scrub + клик-ввод), slider, combo, text (multiline), toggle, color (попап-пикер), button.
editor.getWidgetValue(nodeId, 'steps')editor.setWidgetValue(nodeId, 'steps', 30) // кламп, undoableeditor.on('widget:action', ({ nodeId, action }) => { /* нажата кнопка */ })Каждый виджет темизируется глобально через токены color.widget / geometry.widget и пер-инстанс через style:
{ id: 'gain', type: 'slider', label: 'Gain', key: 'gain', min: 0, max: 1, style: { fill: '#FF3366', radius: 10, borderFocused: '#FF3366' } }Кастомные виджеты
Регистрируем контроллер и ссылаемся на него из custom-виджета (renderer). Два вида:
// Canvas-draw (быстро — рисуется в WebGL-текстуру, без DOM):editor.registerWidget('curve', { draw(ctx, { value, width, height }) { /* рисование в 2D-канвас */ }, onPointer(phase, x, y, ctx) { return newValue },})
// DOM-mount (произвольный HTML — контракт, который оборачивают адаптеры React/Vue/Svelte):editor.registerWidget('chart', { mount(el, { value, setValue }) { /* монтируем компонент в el */; return () => {} }, update({ value }) {},})Canvas-draw — перф-дефолт; DOM-виджеты редактор держит приклеенными к экранному rect ноды при пан/зум/драге.
Импорт ComfyUI
importComfyWorkflow мапит позиционный widgets_values каждой ноды в типизированные виджеты по значению (число→number, bool→toggle, строка→text). Имена/combo/диапазоны требуют серверного object_info — апгрейд на потом.
Плагины
editor.use(plugin) устанавливает плагин. Плагин — это { name, install(ctx) }; install выполняется один раз и может вернуть disposer, который сработает на editor.destroy(). Через плагины редактор остаётся расширяемым без вшитых фич — паки нод, рантаймы, системы типов, кастомные виджеты и валидаторы заходят сюда.
editor.use({ name: 'my-plugin', install(ctx) { ctx.registry.register({ type: 'Custom', title: 'Custom', pins: [/* ... */] }) ctx.types.register({ id: 'vec3', color: '#a0f', shape: 'diamond', compatibleWith: ['vec2'] }) ctx.icons.register('star', '<svg>...</svg>') ctx.setIsValidConnection((from, to) => from.node.type !== 'Frozen') ctx.registerWidget('myKnob', { draw(/* ... */) {}, onPointer(/* ... */) {} }) const off = ctx.on('node:added', e => console.log('added', e.id)) return () => off() },})PluginContext предоставляет:
| Поле | Назначение |
|---|---|
registry | Реестр схем нод (ctx.registry.register(schema)) |
types | TypeRegistry для кастомных типов пинов (id/color/shape/compatibleWith) |
icons | Реестр глифов — встроенный набор Feather + register(name, svg) |
commandBus | Объединять мутации в одну транзакцию (использовать осторожно) |
graph | Read-only вид графа |
app | PIXI Application — escape hatch только для кастомного рендера; трогать здесь scene graph не поддерживается |
registerWidget | Регистрация canvas-draw или DOM-mount контроллеров кастомных виджетов |
setIsValidConnection | Установить глобальный валидатор соединений (выполняется после встроенной проверки совместимости типов) |
on | Полный доступ к шине событий (см. События) |
Делегирование рантайма (для execution-плагинов — в самом редакторе их нет):
| Метод | Назначение |
|---|---|
onTick(cb) | Подписка на per-frame тики |
startLoop() / stopLoop() / step() | Запуск/остановка цикла тиков или шаг одного кадра |
setWidgetValue(nodeId, key, value, { ephemeral: true }) | Неотменяемая запись виджета для симуляций |
setNodePins(nodeId, pins) | Вариативные пины (Sequence / MakeArray) |
setEdgeAnimated(edgeId, on) | Включить/выключить marching-ants анимацию |
expandTemplateInstance(nodeId) | Read-only flatten инстанса темплейта |
graphSnapshot({ expandMacros, flattenReroutes }) | Снапшот для обхода при выполнении |
Кастомные типы
TypeRegistry определяет типы пинов — цвет, форму и совместимые типы для авто-каста. Тип задаёт цвет провода, заливку пина и валидность соединения.
editor.types.register({ id: 'float', color: '#36f', shape: 'circle', compatibleWith: ['int'] })editor.types.register({ id: 'exec', color: '#fff', shape: 'arrow' })editor.types.register({ id: 'struct:Vec3', color: '#a0f', shape: 'diamond' })Формы пинов: circle (data), arrow (exec), diamond (struct/object). compatibleWith включает авто-каст между родственными типами (например int → float).
Кастомная валидация
setIsValidConnection устанавливает предикат, который выполняется после встроенной проверки совместимости типов. Возврат false отклоняет соединение.
editor.setIsValidConnection((from, to) => { return !(from.node.type === 'A' && to.node.type === 'B')})Глифы шапки
Глиф — это маленькая SVG-иконка в шапке ноды рядом с заголовком. Встроенный набор Feather загружен сразу; остальные регистрируем по имени.
editor.icons.register('zap', '<svg viewBox="0 0 24 24"><path d="..."/></svg>')
editor.registry.register({ type: 'Trigger', title: 'Trigger', glyph: { icon: 'zap', side: 'left' }, pins: [{ kind: 'exec', direction: 'out' }],})
// Per-node override (без изменения схемы):editor.setNodeGlyph(nodeId, { icon: 'star', side: 'right' })editor.setNodeGlyph(nodeId, null) // сбросить overridePer-node глиф-override сериализуется в xenolith.v1.
События
editor.on(event, handler) возвращает Unsubscribe. События мостятся с command bus, поэтому срабатывают и на undo/redo — UI, привязанный к ним, остаётся синхронным без дополнительной обвязки вокруг мутаций.
const off = editor.on('node:added', ({ id, node }) => {})editor.on('node:removed', ({ id }) => {})editor.on('node:moved', ({ id, position }) => {})editor.on('edge:added', ({ id, edge }) => {})editor.on('edge:removed', ({ id }) => {})editor.on('selection:changed', ({ ids }) => {})editor.on('widget:changed', ({ nodeId, key, value }) => {})editor.on('viewport:changed', ({ x, y, scale }) => {})editor.on('dive:changed', ({ defId, depth, path }) => {}) // вход/выход в темплейтoff()Анимированные рёбра
Включает бегущую marching-ants пульсацию вдоль ребра. Сериализуется в xenolith.v1.
editor.setEdgeAnimated(edgeId, true)editor.setEdgeAnimated(edgeId, false)Использовать умеренно — анимированные рёбра ломают render-on-demand всё время, пока находятся на экране.
Вариативные пины
setNodePins заменяет список пинов ноды. Используется для нод, у которых количество пинов зависит от состояния — Sequence (exec out 1..N), MakeArray (data in 1..N) и подобных. Плагины обычно вызывают это из обработчика widget:changed.
editor.setNodePins(nodeId, [ { kind: 'exec', direction: 'in', label: 'in' }, { kind: 'exec', direction: 'out', label: 'Then 1' }, { kind: 'exec', direction: 'out', label: 'Then 2' },])Рёбра, подключённые к удалённым пинам, обрезаются автоматически.
Комментарии
Комментарий — это подписанный прямоугольник позади нод; пространственная группировка, а не контейнер данных. Перемещайте его, тяните за угол для ресайза, двойной клик по шапке — переименование.
const id = editor.createComment({ x: 100, y: 100, width: 400, height: 300, title: 'IO', color: '#36c' })editor.setCommentTitle(id, 'Inputs')editor.setCommentColor(id, '#f93')editor.editComment(id) // начать inline-редактирование (оверлей input)editor.removeComment(id)Шорткаты: Tab → Comment, либо двойной клик по пустому месту и выбор Comment в палитре.
Макросы (группы)
Макрос упаковывает N выделенных нод в одну сворачиваемую обёртку с прокси-пинами на границе. Инлайн — без общего определения, без распространения изменений.
const macroId = editor.createMacroFromSelection()const macroId = editor.createMacroFromSelection([id1, id2, id3], 'Backup')editor.collapseMacro(macroId)editor.expandMacro(macroId)editor.ungroupMacro(macroId) // разобрать; участники возвращаются в корневой графШорткат: Cmd+G группирует текущее выделение.
Live-темплейты
Темплейт — это саб-граф с сохранённым определением. От одного определения можно создать множество инстансов; правки определения (через dive-in) распространяются на все инстансы. Граничные ноды $templateInput / $templateOutput внутри определения образуют интерфейс пинов — каждая держит один пин, переименуйте её, чтобы задать label пина.
const instanceId = editor.createTemplateFromSelection()const instanceId = editor.createTemplateFromSelection([id1, id2], 'My Template')
editor.diveInto(instanceId) // войти в определение (хлебные крошки трекают путь)editor.diveOut() // обратно в родительский графeditor.renameTemplate(defId, 'New name')
// Разорвать общую связь, получить редактируемый макрос:editor.unpackTemplateInstance(instanceId)
// Конвертация в обе стороны:editor.convertTemplateInstanceToMacro(instanceId)editor.convertMacroToTemplate(macroId) // вложенные макросы переносятся в определениеПалитра показывает каждое определение как вставляемую ноду. Определение не может вставить само себя или любого предка в dive-стеке (защита от рекурсии).
Шорткаты: Cmd+Shift+G делает темплейт из выделения; двойной клик по инстансу — dive-in.
Виртуализация и LOD
Редактор виртуализирует ноды вне вьюпорта и автоматически деградирует дальние ноды до представлений с меньшей детализацией. Дефолты настроены на ~30k+ нод; переопределяйте только для необычно маленьких графов или кастомных рендереров.
editor.setVirtualization({ enabled: true, threshold: 300, overscan: 200 })LOD переключается при уменьшении зума: full → sprite-baked → flat-batch.
Headless рантайм
Выполнение делегируется плагинам — сам редактор не содержит execution. Рантайм (например @xenolithengine/graph-plugin-runtime, Blueprint VM, в работе) ставится плагином и использует API делегирования рантайма выше.
import { runtime } from '@xenolithengine/graph-plugin-runtime' // будущий пакетeditor.use(runtime({ /* опции */ }))editor.startLoop() // запускает тики — рантайм решает, что выполнятьeditor.step() // один кадр