Нодовый редактор SDK
Нодовый редактор доступен в двух пространствах.
sdk.project.nodeEditor— холст проекта. Он существует в единственном экземпляре: ядро создаёт его вместе с проектом и вы не открываете его самостоятельно.sdk.nodeEditor— собственные нодовые редакторы расширения. Их создаёт код расширения, они открываются в виде вкладок в любой панели и уничтожаются также кодом.
Интерфейс графов один и тот же: всё, что описано ниже про чтение, изменение, порты и сообщения, одинаково работает и на sdk.project.nodeEditor, и на дескрипторе из sdk.nodeEditor.create.
Собственные редакторы: sdk.nodeEditor
create(id: string, options?: CreateNodeEditorOptions): Promise<NodeEditorHandle>
get(id: string): Promise<NodeEditorHandle | undefined>
list(): Promise<NodeEditorInfo[]>
list возвращает NodeEditorInfo — { id: NodeEditorId; displayName: string; visible: boolean }, где visible показывает, открыт ли холст хотя бы в одной вкладке.
id — локальный идентификатор в пределах расширения: 1–64 символа из [a-zA-Z0-9._-]. Глобальный идентификатор редактора — <extensionId>.<id>, именно он лежит в handle.id и из него выводятся идентификаторы команд редактора. get принимает и локальный, и глобальный идентификаторы, но видит только редакторы вызывающего расширения — как и list.
Опции create:
| Опция | По умолчанию | Что делает |
| --- | --- | --- |
| displayName | глобальный id | Заголовок вкладки и категория команд редактора. |
| icon | — | Имя иконки lucide для вкладки. |
| inspector | true | Открывать инспектор ноды по двойному клику. detailed устарел, но продолжает работать. |
| statusbar | false | Регистрировать запись в статусбаре со счётчиками нод и рёбер. |
| workspace | — | Рабочая область ('project', 'team', 'extensionWorkshop', 'home'), в которой доступны команды и статусбар редактора. Не задан — доступны во всех. |
| when | — | Дополнительное when-выражение поверх рабочей области. |
NodeEditorHandle
Дескриптор реализует весь контракт нодового редактора из разделов ниже — те же события, чтение и мутации — плюс:
| Элемент | Описание |
| --- | --- |
| id | Глобальный id редактора. |
| show(options?) | Открыть холст вкладкой: { panel?, activate?, pinned?, title? }. Без panel — project:panel.browser. Повторный вызов активирует уже открытую вкладку. |
| getSnapshot() | Снимок графа { nodes, edges }. |
| setSnapshot(snapshot) | Восстановить граф из снимка. |
| undo() / redo() | Promise<boolean> — получилось ли откатить/повторить. Стек истории у редактора свой, проектный холст не затрагивается. |
| clearHistory() | Очистить стек истории редактора. |
| onDidDispose | Редактор уничтожен. |
| dispose() | Закрыть вкладки, снять команды, запись статусбара и детальный вид. |
Где открывается
panel может быть любым PanelId из реестра. Холст не привязан к рабочей области проекта. Панели всех рабочих областей регистрируются при запуске IDE, поэтому вкладку можно открыть заранее — пользователь увидит её, переключившись на нужную рабочую область. Наборы id экспортируются из @fragmenta/extension: ProjectPanelIds, TeamPanelIds, WorkshopExtensionPanelIds, HomePanelIds. Также подойдёт и id панели, добавленной другим расширением.
await graph.show({ panel: TeamPanelIds.Right, title: 'Flow (team)' })
await graph.show({ panel: WorkshopExtensionPanelIds.Bottom })
Один и тот же редактор можно открыть сразу в нескольких панелях — граф у всех вкладок общий.
const graph = await sdk.nodeEditor.create('graph', {
displayName: 'Flow',
icon: 'Workflow',
statusbar: true,
})
const nodeId = await graph.createNodeFromAsset(context.extension.id, 'my-node')
await graph.show({ panel: sdk.project.panel.bottom.panelId })
context.subscriptions.push(graph)
Ядро не сохраняет граф SDK-редактора: после перезагрузки IDE ни редактор, ни его содержимое не восстанавливаются. Сохранение реализовано на расширении, через getSnapshot и setSnapshot.
При деактивации или удалении расширения все его редакторы уничтожаются автоматически, вызывать dispose вручную не обязательно.
Ошибки
| Код | Когда |
| --- | --- |
| NODE_EDITOR_INVALID_ID | Пустой id, длиннее 64 символов или символы вне [a-zA-Z0-9._-]. |
| NODE_EDITOR_ALREADY_EXISTS | create с id, уже занятым этим расширением. |
| NODE_EDITOR_DISPOSED | Вызов на уничтоженном дескрипторе. |
| NODE_EDITOR_NOT_FOUND | Операция по id, которого нет у этого расширения. |
| PANEL_NOT_FOUND | show с идентификатором панели, которой нет в реестре. |
Методы не отклоняют Promise при ошибках графа. Если ничего не найдено, чтения возвращают undefined, null или []. Мутации успешно завершаются, даже если изменение отклонено , например из-за неизвестного id или проверки capabilities, при этом ошибка только пишется в лог. Чтобы проверить, применилось ли изменение, перечитайте граф.
События
| Событие | Payload | Когда срабатывает |
| --- | --- | --- |
| onNodeAdded | NodeId | Добавлена одна нода, например через createNodeFromAsset. |
| onNodesAdded | readonly NodeId[] | Ноды добавлены одной пачкой, например при загрузке проекта. |
| onNodeRemoved | NodeId | Нода удалена — по разу на каждую, включая удалённых вместе с ней потомков. |
| onNodeUpdated | NodeId | Что-либо в ноде изменилось — см. ниже. |
| onNodeDataUpdated | NodeId | data ноды заменена (срабатывает и onNodeUpdated). |
| onNodePositionChanged | NodeId | Нода перемещена через moveNode или перетащена пользователем. |
| onNodeParentChanged | NodeId | У ноды сменился родитель при группировке, разгруппировке или удалении её группы. |
| onEdgeAdded / onEdgesAdded | EdgeId / readonly EdgeId[] | Добавлена одна связь/ связи добавлены одной пачкой. |
| onEdgeRemoved | EdgeId | Связь удалена, в том числе связи удалённой ноды. |
| onEdgeUpdated | EdgeId | Изменились подпись, цвет, анимация или данные у связи. |
| onEdgeDataUpdated | EdgeId | data у связи заменена (срабатывает и onEdgeUpdated). |
| onCleared | — | Весь граф очищен. |
| onSelectionChanged | — | Изменилось выделение; прочитать его — getSelectedNodeIds / getSelectedEdgeIds. |
| onNavigationChanged | NavigationState | Пользователь открыл вложенный граф или вернулся наверх. |
| onOpenInspector | { nodeId: NodeId; node: NodeSnapshot } | Пользователь кликнул по ноде, и открылся её инспектор. onOpenDetail устарел, но продолжает работать. |
| onNodeDoubleClick | { nodeId: NodeId; node: NodeSnapshot } | Пользователь дважды кликнул по ноде. |
| onNodeMessage | NodeMessage | UI ноды отправил сообщение — см. «Обмен сообщениями с нодами» ниже. |
События срабатывают на любое изменение — от пользователя, от вашего расширения или от другого расширения.
onNodeUpdatedсрабатывает, когда меняются имя, цвет, данные, порты, поляupdateNodeили состояние выполнения ноды, при вызовеsetPortValueи когдаsetNodeOutputsдействительно меняет значение.- Загрузка проекта пересобирает граф целиком. Сначала срабатывает
onEdgeRemovedдля каждой существующей связи, затемonCleared, после чего приходят пакетыonNodesAddedиonEdgesAdded. Отдельных событийonNodeRemoved/onNodeAddedдля каждой ноды нет. Если вы держите зеркало графа, обрабатывайтеonClearedи пакетные события. updateEdgeTypeзаменяет связь: подписчики получаютonEdgeRemoved, затемonEdgeAddedс тем же id, а неonEdgeUpdated.
Чтение графа
getNode(nodeId: NodeId): Promise<NodeSnapshot | undefined>
getNodes(): Promise<NodeSnapshot[]>
getNormalizedNodes(): Promise<NodeSnapshot[]>
getNodesByAssetId(assetId: AssetId): Promise<NodeSnapshot[]>
getEdge(edgeId: EdgeId): Promise<EdgeSnapshot | undefined>
getEdges(): Promise<EdgeSnapshot[]>
getNormalizedEdges(): Promise<EdgeSnapshot[]>
getEdgesBetween(sourceNodeId: NodeId, targetNodeId: NodeId): Promise<EdgeSnapshot[]>
getNodesByAssetIdсравнивает полный asset id изsource.assetIdноды —node:<id расширения>.<type>, напримерnode:acme.timers.TimerNode. Короткие id здесь не разрешаются.getEdgesBetweenучитывает направление: возвращает только связи, идущие изsourceNodeIdвtargetNodeId. Для обоих направлений вызовите дважды.
Normalized-варианты разворачивают группы. Именно эту форму обычно потребляют orchestrators при сборке DSL.
getNormalizedNodesвыбрасывает групповые ноды; нода внутри группы получаетparentIdближайшего не-group предка или остаётся без родителя. Ноды перечисляются в глубину, начиная с верхнего уровня; ноды, чейparentIdуказывает на несуществующую ноду, не попадают в результат.getNormalizedEdgesпереписывает связи, подключённые к прокси-портам группы, так, чтобы они указывали на внутреннюю ноду и порт, которые этот прокси представляет. Связи, которые так разрешить нельзя, выбрасываются; остальные возвращаются как есть.
Иерархия и выделение:
getParent(nodeId: NodeId): Promise<NodeSnapshot | null> // null для нод верхнего уровня
getChildren(nodeId: NodeId): Promise<NodeSnapshot[]> // только прямые дети
getAncestors(nodeId: NodeId): Promise<NodeSnapshot[]> // сначала родитель, дальше до верхнего уровня
getDescendants(nodeId: NodeId): Promise<NodeSnapshot[]> // в ширину: дети, затем внуки, …
getRoots(): Promise<NodeSnapshot[]> // ноды без родителя
getSelectedNodeIds(): Promise<NodeId[]>
getSelectedEdgeIds(): Promise<EdgeId[]>
Навигация и используемые расширения:
getNavigationState(): Promise<NavigationState>
// { currentParentId, path, depth, breadcrumbs, isAtRoot }
getUsedNodeExtensions(): Promise<readonly ExtensionIdentity[]>
// из каких расширений пришли ноды на холсте — по одной записи на расширение
NavigationState и BreadcrumbItem
interface NavigationState {
readonly currentParentId: NodeId | null // нода, чей вложенный граф открыт; null на верхнем уровне
readonly path: readonly NodeId[] // открытые ноды, начиная с внешней
readonly depth: number // path.length
readonly breadcrumbs: readonly BreadcrumbItem[]
readonly isAtRoot: boolean // currentParentId === null
}
interface BreadcrumbItem {
readonly nodeId: NodeId | null // null у первого элемента — верхнего уровня с именем 'Root'
readonly displayName: string
readonly icon?: { name: string; color?: string }
readonly color?: string // цвет из appearance ноды
readonly depth: number // 0 для 'Root', дальше 1, 2, …
}
ExtensionIdentity
interface ExtensionIdentity {
readonly id: ExtensionId // '<publisher>.<name>'
readonly uuid: ExtensionUuid
readonly versionId: ExtensionVersionId
readonly version: string
}
Создание и изменение
createNodeFromAsset(assetId: string, position?: Position, parentId?: NodeId, options?: CreateNodeOptions): Promise<NodeId | undefined>
moveNode(nodeId: NodeId, position: Position, options?: NodeEditorHistoryOptions): Promise<void>
updateNode(nodeId: NodeId, params: UpdateNodeOptions, options?: NodeEditorHistoryOptions): Promise<void>
updateNodeDisplayName(nodeId: NodeId, displayName: string, options?: NodeEditorHistoryOptions): Promise<void>
updateNodeColor(nodeId: NodeId, color: string | undefined, options?: NodeEditorHistoryOptions): Promise<void>
updateNodeData(nodeId: NodeId, data: Dictionary, options?: NodeEditorHistoryOptions): Promise<void>
removeNode(nodeId: NodeId, options?: RemoveNodeOptions): Promise<void>
updateEdgeLabel(edgeId: EdgeId, label: string | undefined, options?: NodeEditorHistoryOptions): Promise<void>
updateEdgeColor(edgeId: EdgeId, color: string | undefined, options?: NodeEditorHistoryOptions): Promise<void>
updateEdgeAnimated(edgeId: EdgeId, animated: boolean | undefined, options?: NodeEditorHistoryOptions): Promise<void>
updateEdgeType(edgeId: EdgeId, assetId: AssetId, options?: NodeEditorHistoryOptions): Promise<void>
updateEdgeData(edgeId: EdgeId, data: Dictionary, options?: NodeEditorHistoryOptions): Promise<void>
removeEdge(edgeId: EdgeId, options?: NodeEditorHistoryOptions): Promise<void>
createNodeFromAssetсоздаёт ноду из набора asset и резолвится её id илиundefined, если набор не найден или ноду не удалось добавить.assetIdпроверяется в таком порядке: полный набор id (node:acme.timers.TimerNode), квалифицированный тип (acme.timers.TimerNode), затем тип из вашего расширения (TimerNode).positionпо умолчанию{ x: 0, y: 0 };parentIdпомещает ноду внутрь другой ноды и должен быть id существующей ноды. СрабатываетonNodeAdded.- История. Методы с
NodeEditorHistoryOptionsпо умолчанию записывают изменение в undo-историю (history: true); передайте{ history: false }, чтобы применить его без шага отмены. Значения портов, outputs, параметры и состояние выполнения в историю не попадают никогда. updateNodeData/updateEdgeDataзаменяютdataцеликом — без слияния. Если меняете один ключ, разверните текущее значение сами.updateNodeColor,updateEdgeColor,updateEdgeLabel,updateEdgeAnimated— передайтеundefined, чтобы сбросить значение.updateNodeDisplayName(иupdateNodeсdisplayName) помечает имя как переопределённое:overrides.displayNameстановитсяtrue.updateNodeменяет несколько полей за один шаг отмены. Затрагиваются только переданные поля —undefinedзначит «оставить как есть», поэтому сброситьparentId,appearanceилиnavigationчерез него нельзя.data,appearanceиportsзаменяют текущее значение (portsзаменяет весь список портов);capabilitiesсливается с текущими capabilities.removeNodeудаляет ноду и подключённые к ней связи. Удаление обычной ноды удаляет и всех её потомков; удаление группы сохраняет наследников и переносит их к родителю группы. Нода сcapabilities.deletable: falseне удаляется, если не передатьforce: true.moveNodeне двигает ноду, у которойcapabilities.draggableравноfalse. СрабатываетonNodePositionChanged.updateEdgeTypeпереключает связи на другой набор связей;assetIdдолжен быть полным asset id edge —edge:<id расширения>.<type>. Связь пересоздаётся с тем же id и концами:label,animatedиdataберутся из нового набора, ранее заданный цвет сбрасывается.
const node = await sdk.project.nodeEditor.getNode(nodeId)
await sdk.project.nodeEditor.updateNodeData(nodeId, { ...node?.data, retries: 3 })
UpdateNodeOptions
interface UpdateNodeOptions {
readonly position?: Position // { x: number; y: number }
readonly displayName?: string // также выставляет overrides.displayName
readonly icon?: { name: string; color?: string }
readonly ports?: PortSnapshot[] // заменяет весь список портов
readonly parentId?: NodeId
readonly data?: Dictionary // заменяет data
readonly appearance?: { color?: string } // заменяет appearance
readonly navigation?: boolean | { readonly notation?: ExtensionId }
readonly capabilities?: Partial<{ // сливается с текущими capabilities
draggable: boolean
selectable: boolean
connectable: boolean
deletable: boolean
}>
}
NodeEditorHistoryOptions, RemoveNodeOptions, CreateNodeOptions
interface NodeEditorHistoryOptions {
readonly history?: boolean // по умолчанию true — записать изменение в undo-историю
}
interface RemoveNodeOptions extends NodeEditorHistoryOptions {
readonly force?: boolean // по умолчанию false — удалить, даже если capabilities.deletable равно false
}
interface CreateNodeOptions {
readonly skipHistory?: boolean // сейчас ни на что не влияет
}
Рёбра
canConnectEdge(sourceNodeId, sourcePortKey, targetNodeId, targetPortKey): Promise<boolean>
connectEdge(sourceNodeId, sourcePortKey, targetNodeId, targetPortKey): Promise<EdgeId | undefined>
createEdge(sourceNodeId, sourcePortKey, targetNodeId, targetPortKey): Promise<EdgeId | undefined>
createEdgeFromAsset(assetId, sourceNodeId, sourcePortKey, targetNodeId, targetPortKey): Promise<EdgeId | undefined>
Концы ребра задаются ключом порта (key из определения ноды), а не его id. canConnectEdge проверяет connectionRules портов, не меняя граф; connectEdge проверяет и соединяет; createEdge соединяет без проверки; createEdgeFromAsset берёт тип ребра из asset.
if (await sdk.project.nodeEditor.canConnectEdge(sourceId, 'out', targetId, 'in')) {
const edgeId = await sdk.project.nodeEditor.connectEdge(sourceId, 'out', targetId, 'in')
}
Порты и состояние выполнения
setPortValue(nodeId: NodeId, portKey: string, value: unknown): Promise<void>
getPortValue<T = unknown>(nodeId: NodeId, portKey: string): Promise<T | undefined>
setParameterValue(nodeId: NodeId, key: string, value: unknown): Promise<void>
setParameterTemplate(nodeId: NodeId, key: string, template: string | undefined): Promise<void>
setPortTemplate(nodeId: NodeId, portKey: string, template: string | undefined): Promise<void>
setNodeOutputs(nodeId: NodeId, data: Record<string, any>): Promise<void>
setNodesOutputs(updates: readonly { nodeId: NodeId; data: Record<string, any> }[]): Promise<void>
setNodeInputsResolved(nodeId: NodeId, data: SdkNodeInputsResolved): Promise<void>
setNodeExecutionState(nodeId: NodeId, state: NodeState['execution']): Promise<void>
setNodesExecutionStates(updates: readonly { nodeId: NodeId; state: NodeState['execution'] }[]): Promise<void>
getNodeExecutionState(nodeId: NodeId): Promise<NodeState['execution'] | undefined>
- Порты адресуются по
key— ключу из определения ноды, — а не по id порта. setPortValue/getPortValueработают с любым data-портом, входным или выходным; где лежит значение — в разделе Слоты значения. Неизвестные ноды, неизвестные ключи и signal-порты игнорируются;getPortValueдля них резолвится вundefined.setNodeOutputsзадаёт по ключу значения нескольких выходных data-портов; ключи, не соответствующие выходным data-портам, пропускаются.setNodesOutputsделает то же для многих нод за один вызов.setParameterValueработает только сclass-нодами и существующими ключами параметров. Если определение ноды объявляетeffectдля этого значения параметра, порты ноды переключаются на порты этогоeffect.
Слоты значения
Data port и параметр class-node хранят до четырёх значений сразу, и они не затирают друг друга:
| Слот | Кто пишет | Уезжает в файл проекта |
| --- | --- | --- |
| defaultValue | автор ноды, в схеме данных | нет — приходит из схемы данных каждый раз |
| value | человек, в inspector | да |
| template | человек, в режиме выражения | да |
| resolved | прогон | нет |
Чтение — одно правило: resolved → value → defaultValue, первое, что нашлось. getPortValue возвращает именно его.
setPortValueиsetParameterValueпишутvalueи очищаютresolved— значение, введённое руками, не будет перекрыто старым результатом прогона.setNodeOutputs/setNodesOutputsпишутresolvedвыходных ports: прогон сообщает результат, не трогая авторский дефолт и не делая проект изменённым.setParameterTemplateиsetPortTemplateпишутtemplate— то самое выражение, которое набрал бы человек.undefinedубирает выражение и возвращает литерал.setPortTemplateадресует входной data port по ключу.setNodeInputsResolvedделает то же для входов — чем обернулось выражение, стоявшее в поле:
await sdk.project.nodeEditor.setNodeInputsResolved(nodeId, {
parameters: { subject: 'Заказов: 2' },
ports: { to: 'ivanov@example.com' },
})
Редактор показывает это под полем строкой на последнем прогоне: …, подставляя маску, если выражение читает секрет проекта. Ключ, отправленный как undefined, записывается со статусом missing: поле скажет, что выражение не разрешилось, вместо пустого значения.
Применяются только существующие ключи: неизвестный параметр, signal port или port не того направления просто игнорируются.
Состояние выполнения — union, различаемый по status; именно его редактор отображает на ноде. Под собственным именем тип не экспортируется — используйте NodeState['execution'].
| status | Дополнительные поля |
| --- | --- |
| 'idle' | — (состояние каждой новой ноды) |
| 'pending' | queuePosition?: number |
| 'running' | progress?: number (0..1), startedAt: number (epoch, мс) |
| 'success' | result?: unknown, duration: number (мс) |
| 'error' | error: BaseError, duration: number (мс) |
| 'cancelled' | reason?: string |
- Установка состояния, равного текущему, ничего не делает; любое другое изменение вызывает
onNodeUpdated. Неизвестные идентификаторы ноды пропускаются. - Состояние выполнения не входит в
NodeSnapshot, не сохраняется с проектом и не записывается в undo-историю.getNodeExecutionStateрезолвится в{ status: 'idle' }для новой ноды и вundefined, только если нода не существует.
await sdk.project.nodeEditor.setNodeExecutionState(nodeId, {
status: 'running',
startedAt: Date.now(),
})
NodeState['execution']
type ExecutionState =
| { readonly status: 'idle' }
| { readonly status: 'pending'; readonly queuePosition?: number }
| { readonly status: 'running'; readonly progress?: number; readonly startedAt: number } // progress 0..1, startedAt — epoch в мс
| { readonly status: 'success'; readonly result?: unknown; readonly duration: number } // duration в мс
| { readonly status: 'error'; readonly error: BaseError; readonly duration: number } // duration в мс
| { readonly status: 'cancelled'; readonly reason?: string }
interface BaseError extends Error { // из @fragmenta/err
readonly code: string
readonly category: 'VALIDATION' | 'BUSINESS' | 'INFRASTRUCTURE' | 'SECURITY' | 'NOT_FOUND' | 'SYSTEM'
readonly severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'
readonly context: Dictionary
readonly timestamp: Date
readonly cause?: Error
}
Обмен сообщениями с нодами
Ноды с собственным UI (ui.path в определении node) могут обмениваться сообщениями с вашим расширением:
onNodeMessage: Event<NodeMessage> // из UI ноды
postNodeMessage(nodeId: NodeId, event: string, payload?: unknown): Promise<void> // в UI ноды
NodeMessage: { nodeId: NodeId; assetId?: AssetId; event: string; payload?: unknown } — assetId — полный asset id ноды, к которой относится сообщение.
onNodeMessageполучает сообщения от всех UI нод на холсте, включая ноды из других расширений. Фильтруйте поassetIdперед обработкой.postNodeMessageдоходит только до UI этой ноды и только до её обработчиков этогоevent.assetIdподставляется автоматически.
const TIMER_ASSET_ID: string = 'node:acme.timers.TimerNode'
context.subscriptions.push(
sdk.project.nodeEditor.onNodeMessage(async (message) => {
if (message.assetId !== TIMER_ASSET_ID) return
if (message.event === 'timer.start') {
await sdk.project.nodeEditor.postNodeMessage(message.nodeId, 'timer.tick', { value: 0 })
}
}),
)
Граф целиком
Кроме поштучных мутаций есть массовые — ими удобно загружать граф из своего формата и синхронизировать его.
getGraph(): Promise<NodeEditorSnapshot>
setGraph(snapshot): Promise<void>
clearGraph(): Promise<void>
setNodes(nodes): Promise<void>
setEdges(edges): Promise<void>
addNodes(nodes, options?): Promise<NodeId[]>
addEdges(edges, options?): Promise<EdgeId[]>
removeNodes(nodeIds, options?): Promise<void>
removeEdges(edgeIds, options?): Promise<void>
NodeEditorSnapshot — это { nodes: NodeSnapshot[]; edges: EdgeSnapshot[] }, тот же формат, что отдаёт getGraph. Round-trip getGraph → setGraph восстанавливает ноды, рёбра, порты и их значения.
Замена (setGraph, setNodes, setEdges, clearGraph) переписывает граф целиком и не пишется в историю. setNodeEditorSnapshot(snapshot) — прежнее имя setGraph, оставлено для совместимости; на дескрипторе то же самое доступно как setSnapshot / getSnapshot. setNodes сохраняет те рёбра, у которых оба конца остались в новом наборе нод; setEdges не трогает ноды.
Добавление и удаление (addNodes, addEdges, removeNodes, removeEdges) по умолчанию пишется в историю одной записью на вызов: один undo откатывает всю пачку. Передайте { history: false }, чтобы не трогать стек. removeNodes дополнительно принимает force?: boolean.
const graph = await sdk.project.nodeEditor.getGraph()
// перенести граф в свой холст и дополнить
await myEditor.setGraph(graph)
await myEditor.addNodes([{ ...nodeSnapshot, id: 'extra' }])
// одним undo вернуть обе добавленные ноды
await myEditor.undo()
Ноды и рёбра, которые не удалось создать из снимка, пропускаются с предупреждением в логе — остальные применяются.
Снимки
Снимки — это копии состояния графа, которые можно только прочитать: их изменение не меняет граф. Сужайте область поиска снимков нод по kind с помощью enum NodeKind из @fragmenta/extension; отдельные варианты по имени не экспортируются.
NodeSnapshot—id,type,kind('base' | 'group' | 'class'),viewType?,source?,metadata,overrides?,position,dimensions?,ports,capabilities,parentId?,menu?,data?,appearance?,navigation?. Class-ноды добавляютparametersиeffects?; group-ноды —childIdsиproxyPortMappings.EdgeSnapshot—id,type,kind('base'),viewType?,source?,metadata,sourceEndpoint/targetEndpoint({ nodeId, portId }),label?,animated?,data?.PortSnapshot—DataPortSnapshot | SignalPortSnapshot:id,key,kind('base'),nodeId,type('signal' | 'data'),direction('input' | 'output'),connectedEdges,displayName?,description?,required?,data?,connectionRules?,dynamic?; data-порты добавляютdataTypeиdefaultValue?.
NodeSnapshot
type NodeSnapshot = BaseNodeSnapshot | GroupNodeSnapshot | ClassNodeSnapshot
interface CommonNodeFields {
readonly id: NodeId
readonly type: NodeType // '<id расширения>.<type>', например 'acme.timers.TimerNode'
readonly kind: NodeKind // 'base' | 'group' | 'class'
readonly viewType?: NodeType // тип компонента, который отрисовывает ноду
readonly source?: {
readonly assetId?: AssetId // полный asset id, например 'node:acme.timers.TimerNode'
readonly extension?: ExtensionIdentity // расширение, из которого получена нода
}
readonly metadata: {
displayName: string
description?: string
icon?: { name: string; color?: string }
}
readonly overrides?: { displayName?: boolean } // true, если имя меняли на холсте или через SDK
readonly position: Position // { x: number; y: number }
readonly dimensions?: { readonly width: number; readonly height: number }
readonly ports: PortSnapshot[]
readonly capabilities: { // каждое по умолчанию true
readonly draggable: boolean
readonly selectable: boolean
readonly connectable: boolean
readonly deletable: boolean
}
readonly parentId?: NodeId // отсутствует у нод верхнего уровня
readonly menu?: { // меню ноды, если его объявляет определение
readonly id: string
readonly display?: 'auto' | 'menu' | 'renderer'
readonly items: readonly MenuItem[]
}
readonly data?: Dictionary
readonly appearance?: { color?: string }
readonly navigation?: boolean | { readonly notation?: ExtensionId } // нода открывается как вложенный граф; `notation` ограничивает это указанной нотацией
}
interface BaseNodeSnapshot extends CommonNodeFields {
readonly kind: NodeKind.Base
}
interface GroupNodeSnapshot extends CommonNodeFields {
readonly kind: NodeKind.Group
readonly childIds: readonly NodeId[]
readonly proxyPortMappings: readonly {
readonly proxyPortId: PortId // порт на group-ноде
readonly originalPortId: PortId // внутренний порт, который он представляет…
readonly originalNodeId: NodeId // …и внутренняя нода, которой принадлежит этот порт
}[]
}
interface ClassNodeSnapshot extends CommonNodeFields {
readonly kind: NodeKind.Class
readonly parameters: Record<string, ClassParameter>
readonly effects?: {
parameters?: Record<string /* ключ параметра */, Record<string /* String(value) */, { ports: PortSnapshot[] }>>
}
}
ClassParameter и NodeControl
interface ClassParameter {
readonly key: string
readonly displayName: string
readonly description?: string
readonly control: NodeControl
readonly defaultValue?: any
value?: any // текущее значение — задаётся через setParameterValue
readonly parameters?: Record<string, ClassParameter> // вложенные параметры
}
type NodeControl =
| { kind: 'text' }
| { kind: 'number'; min?: number; max?: number; step?: number }
| { kind: 'boolean' }
| { kind: 'enum'; options: { value: string; label: string }[] }
| { kind: 'object'; required?: boolean }
| { kind: 'array' }
| { kind: 'date' }
| { kind: 'file'; accept?: string; multiple?: boolean; maxSize?: number }
EdgeSnapshot
interface EdgeSnapshot {
readonly id: EdgeId
readonly type: EdgeType // 'base' для простой связи, иначе '<id расширения>.<type>'
readonly kind: 'base'
readonly viewType?: EdgeType
readonly source?: {
readonly assetId?: AssetId // полный asset id edge, например 'edge:acme.flows.DataEdge'
readonly extension?: ExtensionIdentity
}
readonly metadata: { readonly displayName: string; readonly icon?: { name: string; color?: string } }
readonly sourceEndpoint: { readonly nodeId: NodeId; readonly portId: PortId } // выходной порт
readonly targetEndpoint: { readonly nodeId: NodeId; readonly portId: PortId } // входной порт
readonly label?: string
readonly animated?: boolean
readonly data?: Dictionary
}
PortSnapshot
type PortSnapshot = DataPortSnapshot | SignalPortSnapshot
interface CommonPortFields {
readonly id: PortId
readonly key: string // ключ из определения ноды
readonly kind: 'base'
readonly nodeId: NodeId
readonly type: PortType // 'signal' | 'data'
readonly direction: PortDirection // 'input' | 'output'
readonly connectedEdges: readonly EdgeId[]
readonly displayName?: string
readonly description?: string
readonly required?: boolean
readonly data?: Dictionary
readonly connectionRules?: { // заполняется значениями по умолчанию ниже
requireSameType?: boolean // по умолчанию true
requireSameDataType?: boolean // по умолчанию false
allowMultipleOutputs?: boolean // входной порт принимает больше одной связи; по умолчанию: data false, signal true
allowMultipleInputs?: boolean // выходной порт передаёт данные больше, чем одной связи; по умолчанию: data true, signal false
}
readonly dynamic?: boolean // true для портов, добавленных в рантайме, а не объявленных в определении
}
interface SignalPortSnapshot extends CommonPortFields {
readonly type: PortType.Signal
}
interface DataPortSnapshot extends CommonPortFields {
readonly type: PortType.Data
readonly dataType: 'number' | 'string' | 'boolean' | 'array' | 'object' | 'date' | 'any' | 'file'
defaultValue?: any // текущее значение — сюда пишут setPortValue и setNodeOutputs
}