Перейти к содержимому

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.v1
const 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() // вернуться к единичному виду
ОпцияПо умолчаниюЗначение
padding64Экранный отступ со всех сторон
maxZoom1Не приближать сильнее (чтобы маленький граф не занял весь экран)
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) // кламп, undoable
editor.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))
typesTypeRegistry для кастомных типов пинов (id/color/shape/compatibleWith)
iconsРеестр глифов — встроенный набор Feather + register(name, svg)
commandBusОбъединять мутации в одну транзакцию (использовать осторожно)
graphRead-only вид графа
appPIXI 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 включает авто-каст между родственными типами (например intfloat).

Кастомная валидация

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) // сбросить override

Per-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)

Шорткаты: TabComment, либо двойной клик по пустому месту и выбор 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() // один кадр