perf: 优化智能体与工作流幕布渲染性能
- 分阶段加载智能体配置并按需缓存 MCP 工具 - 合并画布状态更新与节点尺寸监听,启用大图可视区域渲染和静态连线 - 隔离 Tinyflow Store 实例并补充数据同步与回归测试
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type {useSvelteFlow} from '@xyflow/svelte';
|
||||
import {componentName} from './consts';
|
||||
import {store} from './store/stores.svelte';
|
||||
import type {TinyflowStore} from './store/stores.svelte';
|
||||
import type {TinyflowData, TinyflowOptions, TinyflowTheme} from './types';
|
||||
import {installTinyflowBrowserCompat} from './utils/compat';
|
||||
import {createTinyflowNodeNormalizer} from './utils/nodeInteraction';
|
||||
@@ -13,9 +13,13 @@ export class Tinyflow {
|
||||
private options!: TinyflowOptions;
|
||||
private rootEl!: Element;
|
||||
private svelteFlowInstance!: FlowInstance;
|
||||
private store!: TinyflowStore;
|
||||
private tinyflowEl!: HTMLElement & {
|
||||
options: TinyflowOptions;
|
||||
onInit: (svelteFlowInstance: FlowInstance) => void;
|
||||
onInit: (
|
||||
svelteFlowInstance: FlowInstance,
|
||||
store: TinyflowStore,
|
||||
) => void;
|
||||
};
|
||||
|
||||
constructor(options: TinyflowOptions) {
|
||||
@@ -72,7 +76,10 @@ export class Tinyflow {
|
||||
private _createTinyflowElement() {
|
||||
const tinyflowEl = document.createElement(componentName) as HTMLElement & {
|
||||
options: TinyflowOptions;
|
||||
onInit: (svelteFlowInstance: FlowInstance) => void;
|
||||
onInit: (
|
||||
svelteFlowInstance: FlowInstance,
|
||||
store: TinyflowStore,
|
||||
) => void;
|
||||
};
|
||||
tinyflowEl.style.display = 'block';
|
||||
tinyflowEl.style.width = '100%';
|
||||
@@ -80,8 +87,12 @@ export class Tinyflow {
|
||||
this._applyThemeClass(tinyflowEl, this.options.theme);
|
||||
|
||||
tinyflowEl.options = this.options;
|
||||
tinyflowEl.onInit = (svelteFlowInstance: FlowInstance) => {
|
||||
tinyflowEl.onInit = (
|
||||
svelteFlowInstance: FlowInstance,
|
||||
store: TinyflowStore,
|
||||
) => {
|
||||
this.svelteFlowInstance = svelteFlowInstance;
|
||||
this.store = store;
|
||||
};
|
||||
return tinyflowEl;
|
||||
}
|
||||
@@ -104,29 +115,29 @@ export class Tinyflow {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentViewport = flow.getViewport();
|
||||
const currentNodes = flow.getNodes();
|
||||
const normalizeNode = createTinyflowNodeNormalizer(this.options);
|
||||
const currentNodePositions = new Map(
|
||||
currentNodes.map((node) => [node.id, node.position]),
|
||||
);
|
||||
const nextNodes =
|
||||
options?.preserveViewport === true
|
||||
? (data.nodes || currentNodes).map((node) => {
|
||||
const currentPosition = currentNodePositions.get(node.id);
|
||||
const nextNode = currentPosition
|
||||
? { ...node, position: { ...currentPosition } }
|
||||
: node;
|
||||
return normalizeNode(nextNode);
|
||||
})
|
||||
: (data.nodes || currentNodes).map((node) => normalizeNode(node));
|
||||
store.setNodes(nextNodes);
|
||||
store.setEdges(data.edges || flow.getEdges());
|
||||
if (data.nodes) {
|
||||
const currentNodePositions =
|
||||
options?.preserveViewport === true
|
||||
? new Map(
|
||||
flow.getNodes().map((node) => [node.id, node.position] as const),
|
||||
)
|
||||
: null;
|
||||
const nextNodes = data.nodes.map((node) => {
|
||||
const currentPosition = currentNodePositions?.get(node.id);
|
||||
const nextNode = currentPosition
|
||||
? { ...node, position: { ...currentPosition } }
|
||||
: node;
|
||||
return normalizeNode(nextNode);
|
||||
});
|
||||
this.store.setNodes(nextNodes);
|
||||
}
|
||||
if (data.edges) {
|
||||
this.store.setEdges(data.edges);
|
||||
}
|
||||
|
||||
if (data.viewport && options?.preserveViewport !== true) {
|
||||
flow.setViewport(data.viewport, { duration: 0 });
|
||||
} else {
|
||||
flow.setViewport(currentViewport, { duration: 0 });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -10,15 +10,22 @@
|
||||
<script lang="ts">
|
||||
import {SvelteFlowProvider, type useSvelteFlow} from '@xyflow/svelte';
|
||||
import TinyflowCore from './TinyflowCore.svelte';
|
||||
import {store} from '#store/stores.svelte';
|
||||
import {
|
||||
createTinyflowStoreContext,
|
||||
type TinyflowStore,
|
||||
} from '#store/stores.svelte';
|
||||
import type {TinyflowData, TinyflowOptions} from '#types';
|
||||
import {setContext} from 'svelte';
|
||||
import {createTinyflowNodeNormalizer} from '../utils/nodeInteraction';
|
||||
|
||||
const props = $props<{
|
||||
options: TinyflowOptions,
|
||||
onInit: (svelteFlow: ReturnType<typeof useSvelteFlow>) => void,
|
||||
onInit: (
|
||||
svelteFlow: ReturnType<typeof useSvelteFlow>,
|
||||
store: TinyflowStore,
|
||||
) => void,
|
||||
}>();
|
||||
const store = createTinyflowStoreContext();
|
||||
|
||||
const parseData = (source: TinyflowOptions['data']) => {
|
||||
let nextData = source;
|
||||
@@ -61,5 +68,8 @@
|
||||
|
||||
|
||||
<SvelteFlowProvider>
|
||||
<TinyflowCore onInit={props.onInit} />
|
||||
<TinyflowCore
|
||||
onInit={(svelteFlow: ReturnType<typeof useSvelteFlow>) =>
|
||||
props.onInit(svelteFlow, store)}
|
||||
/>
|
||||
</SvelteFlowProvider>
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
type NodeTypes,
|
||||
Panel,
|
||||
SvelteFlow,
|
||||
useSvelteFlow
|
||||
useSvelteFlow,
|
||||
useUpdateNodeInternals
|
||||
} from '@xyflow/svelte';
|
||||
import '@xyflow/svelte/dist/style.css';
|
||||
import '../styles/index.ts';
|
||||
import {store} from '#store/stores.svelte';
|
||||
import {useTinyflowStore} from '#store/stores.svelte';
|
||||
import {nodeTypes} from './nodes';
|
||||
import Toolbar from './Toolbar.svelte';
|
||||
import NodePicker from './core/NodePicker.svelte';
|
||||
@@ -34,7 +35,7 @@
|
||||
import {useGetNodesFromSource} from '#components/utils/useGetNodesFromSource.svelte';
|
||||
import {useGetNodeRelativePosition} from '#components/utils/useGetNodeRelativePosition.svelte';
|
||||
import {useCopyPasteHandler} from '#components/utils/useCopyPasteHandler.svelte';
|
||||
import {onDestroy, onMount} from 'svelte';
|
||||
import {onDestroy, onMount, setContext} from 'svelte';
|
||||
import {isInEditableElement} from '#components/utils/isInEditableElement';
|
||||
import {getAvailableNodes, type NodePaletteItem} from './utils/nodePalette';
|
||||
import {
|
||||
@@ -42,9 +43,69 @@
|
||||
ensureStartNodeParameters,
|
||||
START_NODE_TYPE,
|
||||
} from '../utils/workflowNodeFields';
|
||||
import type {TinyflowData, TinyflowDataCommitReason} from '../types';
|
||||
import {
|
||||
TINYFLOW_NODE_SIZE_OBSERVER,
|
||||
type TinyflowNodeSizeObserver,
|
||||
} from './utils/nodeSizeObserver';
|
||||
|
||||
const { onInit }: { onInit: any; [key: string]: any } = $props();
|
||||
const store = useTinyflowStore();
|
||||
const svelteFlow = useSvelteFlow();
|
||||
const updateNodeInternals = useUpdateNodeInternals();
|
||||
const nodeSizeIds = new WeakMap<Element, string>();
|
||||
const pendingNodeSizeIds = new Set<string>();
|
||||
let nodeSizeObserver: ResizeObserver | null = null;
|
||||
let nodeSizeFrame = 0;
|
||||
|
||||
function flushNodeSizeUpdates() {
|
||||
nodeSizeFrame = 0;
|
||||
if (!flowRootEl?.isConnected || pendingNodeSizeIds.size === 0) {
|
||||
pendingNodeSizeIds.clear();
|
||||
return;
|
||||
}
|
||||
const nodeIds = Array.from(pendingNodeSizeIds);
|
||||
pendingNodeSizeIds.clear();
|
||||
updateNodeInternals(nodeIds);
|
||||
}
|
||||
|
||||
function scheduleNodeSizeUpdate(nodeId: string) {
|
||||
if (!nodeId) {
|
||||
return;
|
||||
}
|
||||
pendingNodeSizeIds.add(nodeId);
|
||||
if (!nodeSizeFrame) {
|
||||
nodeSizeFrame = requestAnimationFrame(flushNodeSizeUpdates);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureNodeSizeObserver() {
|
||||
if (nodeSizeObserver || typeof ResizeObserver === 'undefined') {
|
||||
return nodeSizeObserver;
|
||||
}
|
||||
nodeSizeObserver = new ResizeObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
const nodeId = nodeSizeIds.get(entry.target);
|
||||
if (nodeId && entry.target.isConnected) {
|
||||
scheduleNodeSizeUpdate(nodeId);
|
||||
}
|
||||
});
|
||||
});
|
||||
return nodeSizeObserver;
|
||||
}
|
||||
|
||||
const sharedNodeSizeObserver: TinyflowNodeSizeObserver = {
|
||||
observe(nodeId, element) {
|
||||
nodeSizeIds.set(element, nodeId);
|
||||
scheduleNodeSizeUpdate(nodeId);
|
||||
ensureNodeSizeObserver()?.observe(element);
|
||||
},
|
||||
unobserve(element) {
|
||||
nodeSizeObserver?.unobserve(element);
|
||||
nodeSizeIds.delete(element);
|
||||
}
|
||||
};
|
||||
setContext(TINYFLOW_NODE_SIZE_OBSERVER, sharedNodeSizeObserver);
|
||||
|
||||
let showEdgePanel = $state(false);
|
||||
let currentEdge = $state<Edge | null>(null);
|
||||
@@ -71,6 +132,7 @@
|
||||
const nodesDraggable = options.nodesDraggable ?? !readonly;
|
||||
const nodesConnectable = options.nodesConnectable ?? !readonly;
|
||||
const elementsSelectable = options.elementsSelectable ?? !readonly;
|
||||
const onlyRenderVisibleElements = options.onlyRenderVisibleElements === true;
|
||||
const dropEnabled = options.dropEnabled ?? !readonly;
|
||||
const connectionEnabled = nodesConnectable && !readonly;
|
||||
const availableNodes = getAvailableNodes(options);
|
||||
@@ -327,8 +389,11 @@
|
||||
$effect(() => {
|
||||
const vp = store.getViewport();
|
||||
if (vp) {
|
||||
currentZoomPercent = Math.round(vp.zoom * 100);
|
||||
zoomSelectValue = String(currentZoomPercent);
|
||||
const nextZoomPercent = Math.round(vp.zoom * 100);
|
||||
if (currentZoomPercent !== nextZoomPercent) {
|
||||
currentZoomPercent = nextZoomPercent;
|
||||
zoomSelectValue = String(nextZoomPercent);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -680,6 +745,9 @@
|
||||
const { copyHandler, pasteHandler } = useCopyPasteHandler();
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!flowRootEl?.isConnected) {
|
||||
return;
|
||||
}
|
||||
if (nodePickerVisible && e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeNodePicker();
|
||||
@@ -709,6 +777,9 @@
|
||||
};
|
||||
|
||||
const handleGlobalPaste = async (event: ClipboardEvent) => {
|
||||
if (!flowRootEl?.isConnected) {
|
||||
return;
|
||||
}
|
||||
// 只在“非输入态”下处理流程图粘贴
|
||||
if (isInEditableElement()) {
|
||||
return;
|
||||
@@ -718,7 +789,7 @@
|
||||
};
|
||||
|
||||
function handleGlobalPointerDown(event: PointerEvent) {
|
||||
if (readonly) {
|
||||
if (readonly || !flowRootEl?.isConnected) {
|
||||
return;
|
||||
}
|
||||
if (!nodePickerVisible || !inlineNodePickerEl) {
|
||||
@@ -764,15 +835,155 @@
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedNodeTypes = {
|
||||
...nodeTypes,
|
||||
...customNodeTypes
|
||||
} as NodeTypes;
|
||||
const defaultEdgeOptions = {
|
||||
type: 'flow',
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
width: 20,
|
||||
height: 20
|
||||
}
|
||||
};
|
||||
|
||||
function handleEdgeClick(event: { edge: Edge }) {
|
||||
if (readonly || hideEdgePanel) {
|
||||
return;
|
||||
}
|
||||
showEdgePanel = true;
|
||||
currentEdge = event.edge;
|
||||
}
|
||||
|
||||
function handleCanvasClick(event: MouseEvent) {
|
||||
if (readonly || hideEdgePanel) {
|
||||
return;
|
||||
}
|
||||
const element = event.target as HTMLElement;
|
||||
if (element.classList.contains('svelte-flow__edge-interaction')
|
||||
|| element.classList.contains('panel-content')
|
||||
|| element.closest('.panel-content')) {
|
||||
return;
|
||||
}
|
||||
showEdgePanel = false;
|
||||
currentEdge = null;
|
||||
}
|
||||
|
||||
const onDataChange = options.onDataChange;
|
||||
$effect(() => {
|
||||
onDataChange?.({
|
||||
const onDataCommit = options.onDataCommit;
|
||||
let dataChangeFrame = 0;
|
||||
let pendingDataChange: TinyflowData | null = null;
|
||||
let dataCommitFrame = 0;
|
||||
let pendingDataCommit: TinyflowData | null = null;
|
||||
let pendingDataCommitReason: TinyflowDataCommitReason = 'change';
|
||||
let nodeDragging = false;
|
||||
let viewportMoving = false;
|
||||
|
||||
function currentData(): TinyflowData {
|
||||
return {
|
||||
nodes: store.getNodes(),
|
||||
edges: store.getEdges(),
|
||||
viewport: store.getViewport()
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function interactionActive() {
|
||||
return nodeDragging || viewportMoving;
|
||||
}
|
||||
|
||||
function flushDataChange() {
|
||||
dataChangeFrame = 0;
|
||||
const data = pendingDataChange;
|
||||
pendingDataChange = null;
|
||||
if (data) {
|
||||
onDataChange?.(data);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleDataChange(data: TinyflowData) {
|
||||
pendingDataChange = data;
|
||||
if (!dataChangeFrame) {
|
||||
dataChangeFrame = requestAnimationFrame(flushDataChange);
|
||||
}
|
||||
}
|
||||
|
||||
function flushDataCommit() {
|
||||
dataCommitFrame = 0;
|
||||
if (interactionActive()) {
|
||||
return;
|
||||
}
|
||||
const data = pendingDataCommit;
|
||||
const reason = pendingDataCommitReason;
|
||||
pendingDataCommit = null;
|
||||
pendingDataCommitReason = 'change';
|
||||
if (data) {
|
||||
onDataCommit?.(data, reason);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleDataCommit(
|
||||
data: TinyflowData,
|
||||
reason: TinyflowDataCommitReason = 'change'
|
||||
) {
|
||||
pendingDataCommit = data;
|
||||
if (reason !== 'change' || pendingDataCommitReason === 'change') {
|
||||
pendingDataCommitReason = reason;
|
||||
}
|
||||
if (!interactionActive() && !dataCommitFrame) {
|
||||
dataCommitFrame = requestAnimationFrame(flushDataCommit);
|
||||
}
|
||||
}
|
||||
|
||||
function handleNodeDragStart() {
|
||||
nodeDragging = true;
|
||||
}
|
||||
|
||||
function handleNodeDragStop() {
|
||||
nodeDragging = false;
|
||||
scheduleDataCommit(currentData(), 'node-drag-stop');
|
||||
}
|
||||
|
||||
function handleViewportMoveStart() {
|
||||
viewportMoving = true;
|
||||
}
|
||||
|
||||
function handleViewportMoveEnd() {
|
||||
viewportMoving = false;
|
||||
scheduleDataCommit(currentData(), 'viewport-move-end');
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const data = currentData();
|
||||
if (onDataChange) {
|
||||
scheduleDataChange(data);
|
||||
}
|
||||
if (onDataCommit) {
|
||||
scheduleDataCommit(data);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (dataChangeFrame) {
|
||||
cancelAnimationFrame(dataChangeFrame);
|
||||
dataChangeFrame = 0;
|
||||
}
|
||||
if (dataCommitFrame) {
|
||||
cancelAnimationFrame(dataCommitFrame);
|
||||
dataCommitFrame = 0;
|
||||
}
|
||||
flushDataChange();
|
||||
nodeDragging = false;
|
||||
viewportMoving = false;
|
||||
flushDataCommit();
|
||||
if (nodeSizeFrame) {
|
||||
cancelAnimationFrame(nodeSizeFrame);
|
||||
nodeSizeFrame = 0;
|
||||
}
|
||||
pendingNodeSizeIds.clear();
|
||||
nodeSizeObserver?.disconnect();
|
||||
nodeSizeObserver = null;
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
@@ -782,7 +993,7 @@
|
||||
style="position: relative; height: 100%; width: 100%;overflow: hidden"
|
||||
bind:this={flowRootEl}
|
||||
>
|
||||
<SvelteFlow nodeTypes={{ ...nodeTypes, ...customNodeTypes}}
|
||||
<SvelteFlow nodeTypes={resolvedNodeTypes}
|
||||
edgeTypes={customEdgeTypes}
|
||||
bind:nodes={store.getNodes, store.setNodes}
|
||||
bind:edges={store.getEdges, store.setEdges}
|
||||
@@ -790,47 +1001,27 @@
|
||||
nodesDraggable={nodesDraggable && !canvasLocked}
|
||||
nodesConnectable={nodesConnectable && !canvasLocked}
|
||||
elementsSelectable={elementsSelectable && !canvasLocked}
|
||||
{onlyRenderVisibleElements}
|
||||
panOnDrag={readonly ? true : !canvasLocked}
|
||||
zoomOnScroll={readonly ? true : !canvasLocked}
|
||||
zoomOnDoubleClick={readonly ? true : !canvasLocked}
|
||||
ondrop={dropEnabled ? onDrop : undefined}
|
||||
ondragover={dropEnabled ? onDragOver : undefined}
|
||||
onnodedragstart={onDataCommit ? handleNodeDragStart : undefined}
|
||||
onnodedragstop={onDataCommit ? handleNodeDragStop : undefined}
|
||||
onmovestart={onDataCommit ? handleViewportMoveStart : undefined}
|
||||
onmoveend={onDataCommit ? handleViewportMoveEnd : undefined}
|
||||
isValidConnection={isValidConnection}
|
||||
onconnectend={connectionEnabled ? onconnectend : undefined}
|
||||
onconnectstart={connectionEnabled ? onconnectstart : undefined}
|
||||
onconnect={connectionEnabled ? onconnect : undefined}
|
||||
connectionRadius={50}
|
||||
connectionLineComponent={FlowConnectionLine}
|
||||
onedgeclick={(e) => {
|
||||
if (readonly || hideEdgePanel) {
|
||||
return;
|
||||
}
|
||||
showEdgePanel = true;
|
||||
currentEdge = e.edge;
|
||||
}}
|
||||
onedgeclick={handleEdgeClick}
|
||||
onbeforeconnect={(edge: any) => normalizeEdgeBeforeConnect(edge)}
|
||||
ondelete={readonly ? undefined : onDelete}
|
||||
onclick={(e) => {
|
||||
if (readonly || hideEdgePanel) {
|
||||
return;
|
||||
}
|
||||
const el = e.target as HTMLElement;
|
||||
if (el.classList.contains("svelte-flow__edge-interaction")
|
||||
|| el.classList.contains('panel-content')
|
||||
|| el.closest('.panel-content')){
|
||||
return
|
||||
}
|
||||
showEdgePanel = false;
|
||||
currentEdge = null;
|
||||
}}
|
||||
defaultEdgeOptions={{
|
||||
type: 'flow',
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
width: 20,
|
||||
height: 20
|
||||
}
|
||||
}}
|
||||
onclick={handleCanvasClick}
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
>
|
||||
<Background />
|
||||
{#if !hideMiniMap}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import {useNodesData, useSvelteFlow} from '@xyflow/svelte';
|
||||
import {contentTypes, startFormTypes} from '#consts';
|
||||
import type {Parameter} from '#types';
|
||||
import {store} from '#store/stores.svelte';
|
||||
import {useTinyflowStore} from '#store/stores.svelte';
|
||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||
import {
|
||||
renameStartFieldReferencesInNodes,
|
||||
@@ -18,6 +18,7 @@
|
||||
parameter: Parameter,
|
||||
index: number
|
||||
} = $props();
|
||||
const store = useTinyflowStore();
|
||||
|
||||
|
||||
let currentNodeId = getCurrentNodeId();
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import {getOptions} from '../utils/NodeUtils';
|
||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||
import type {TinyflowNodeData} from '#types';
|
||||
import {useTinyflowNodeSizeObserver} from '../utils/nodeSizeObserver';
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -82,36 +83,23 @@
|
||||
};
|
||||
let currentNodeId = getCurrentNodeId();
|
||||
let wrapperElement: HTMLDivElement | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let resizeFrame = 0;
|
||||
|
||||
const scheduleUpdateNodeInternals = () => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
if (resizeFrame) {
|
||||
cancelAnimationFrame(resizeFrame);
|
||||
}
|
||||
resizeFrame = requestAnimationFrame(() => {
|
||||
updateNodeInternals(id);
|
||||
});
|
||||
};
|
||||
const nodeSizeObserver = useTinyflowNodeSizeObserver();
|
||||
|
||||
onMount(() => {
|
||||
scheduleUpdateNodeInternals();
|
||||
if (typeof ResizeObserver !== 'undefined' && wrapperElement) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
scheduleUpdateNodeInternals();
|
||||
});
|
||||
resizeObserver.observe(wrapperElement);
|
||||
if (!wrapperElement) {
|
||||
return;
|
||||
}
|
||||
if (nodeSizeObserver) {
|
||||
nodeSizeObserver.observe(id, wrapperElement);
|
||||
} else {
|
||||
updateNodeInternals(id);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (resizeFrame) {
|
||||
cancelAnimationFrame(resizeFrame);
|
||||
if (wrapperElement) {
|
||||
nodeSizeObserver?.unobserve(wrapperElement);
|
||||
}
|
||||
resizeObserver?.disconnect();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
@@ -16,10 +16,11 @@
|
||||
import {genShortId} from '../utils/IdGen';
|
||||
import {deepEqual} from '../utils/deepEqual';
|
||||
import {useRefOptions} from '#components/utils/useRefOptions.svelte';
|
||||
import {store} from '#store/stores.svelte';
|
||||
import {useTinyflowStore} from '#store/stores.svelte';
|
||||
import type {Parameter, SelectItem} from '#types';
|
||||
|
||||
const SHOW_EXPRESSION_ENTRY = false;
|
||||
const store = useTinyflowStore();
|
||||
|
||||
type ConditionOperator =
|
||||
| 'eq'
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import OutputDefList from '../core/OutputDefList.svelte';
|
||||
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
||||
import type {TinyflowNodeData} from '#types';
|
||||
import {onMount} from 'svelte';
|
||||
import {onDestroy, onMount} from 'svelte';
|
||||
|
||||
const props = $props<{
|
||||
data: TinyflowNodeData,
|
||||
@@ -73,6 +73,10 @@
|
||||
customNode.render?.(externalElement, buildNode(), flowInstance);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
customNode.onDestroy?.(externalElement);
|
||||
});
|
||||
|
||||
let container = $state<HTMLElement | null>(null);
|
||||
$effect(() => {
|
||||
// 注意:由于 $effect 的 state 自动追踪问题,需要 data.expand 方在 if 里的最前面
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { getContext } from 'svelte';
|
||||
|
||||
export const TINYFLOW_NODE_SIZE_OBSERVER = Symbol(
|
||||
'tinyflow_node_size_observer',
|
||||
);
|
||||
|
||||
export type TinyflowNodeSizeObserver = {
|
||||
observe: (nodeId: string, element: Element) => void;
|
||||
unobserve: (element: Element) => void;
|
||||
};
|
||||
|
||||
export function useTinyflowNodeSizeObserver() {
|
||||
return getContext<TinyflowNodeSizeObserver | undefined>(
|
||||
TINYFLOW_NODE_SIZE_OBSERVER,
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { genShortId } from './IdGen';
|
||||
import { store } from '#store/stores.svelte';
|
||||
import { useTinyflowStore } from '#store/stores.svelte';
|
||||
|
||||
export const useCopyNode = () => {
|
||||
const store = useTinyflowStore();
|
||||
const copyNode = (id: string) => {
|
||||
const node = store.getNode(id);
|
||||
if (node) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { store } from '#store/stores.svelte';
|
||||
import { useTinyflowStore } from '#store/stores.svelte';
|
||||
import { genShortId } from '#components/utils/IdGen';
|
||||
import { type Edge, type Node, useSvelteFlow } from '@xyflow/svelte';
|
||||
|
||||
@@ -119,6 +119,7 @@ function rewriteRefsInData(obj: any, idMap: Map<string, string>): any {
|
||||
* 复制粘贴处理器 Hook
|
||||
*/
|
||||
export const useCopyPasteHandler = () => {
|
||||
const store = useTinyflowStore();
|
||||
const svelteFlow = useSvelteFlow();
|
||||
|
||||
const copyHandler = async (event: ClipboardEvent | KeyboardEvent) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { store } from '#store/stores.svelte';
|
||||
import { useTinyflowStore } from '#store/stores.svelte';
|
||||
|
||||
export const useDeleteEdge = () => {
|
||||
const store = useTinyflowStore();
|
||||
const deleteEdge = (id: string) => {
|
||||
store.removeEdge(id);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { store } from '#store/stores.svelte';
|
||||
import { useTinyflowStore } from '#store/stores.svelte';
|
||||
|
||||
export const useDeleteNode = () => {
|
||||
const store = useTinyflowStore();
|
||||
const deleteNode = (id: string) => {
|
||||
store.removeNode(id);
|
||||
store.updateEdges((edges) =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { store } from '../../store/stores.svelte';
|
||||
import { useTinyflowStore } from '../../store/stores.svelte';
|
||||
|
||||
export const useEnsureParentInNodesBefore = () => {
|
||||
const store = useTinyflowStore();
|
||||
const ensureParentInNodesBefore = (
|
||||
parentNodeId: string,
|
||||
childNodeId: string,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { store } from '#store/stores.svelte';
|
||||
import { useTinyflowStore } from '#store/stores.svelte';
|
||||
|
||||
export const useGetEdgesBySource = () => {
|
||||
const store = useTinyflowStore();
|
||||
const getEdgesBySource = (target: string) => {
|
||||
const edges = store.getEdges();
|
||||
return edges.filter((edge) => edge.source === target);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { store } from '#store/stores.svelte';
|
||||
import { useTinyflowStore } from '#store/stores.svelte';
|
||||
|
||||
export const useGetEdgesByTarget = () => {
|
||||
const store = useTinyflowStore();
|
||||
const getEdgesByTarget = (target: string) => {
|
||||
const edges = store.getEdges();
|
||||
return edges.filter((edge) => edge.target === target);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { store } from '#store/stores.svelte';
|
||||
import { useTinyflowStore } from '#store/stores.svelte';
|
||||
|
||||
export const useGetNode = () => {
|
||||
const store = useTinyflowStore();
|
||||
const getNode = (id: string) => {
|
||||
return store.getNode(id);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { store } from '#store/stores.svelte';
|
||||
import { useTinyflowStore } from '#store/stores.svelte';
|
||||
|
||||
export const useGetNodeRelativePosition = () => {
|
||||
const store = useTinyflowStore();
|
||||
const getNodeRelativePosition = (parentNodeId: string) => {
|
||||
let node = store.getNode(parentNodeId);
|
||||
const position = { x: 0, y: 0 };
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { store } from '#store/stores.svelte';
|
||||
import { useTinyflowStore } from '#store/stores.svelte';
|
||||
import type { Edge, Node } from '@xyflow/svelte';
|
||||
|
||||
export const useGetNodesFromSource = () => {
|
||||
const store = useTinyflowStore();
|
||||
const getEdgesBySource = (target: string, edges: Edge[]) => {
|
||||
return edges.filter(
|
||||
// 排除循环节点的子节点,否则在多层循环嵌套时不正确
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { store } from '#store/stores.svelte';
|
||||
import { useTinyflowStore } from '#store/stores.svelte';
|
||||
|
||||
export const useUpdateEdgeData = () => {
|
||||
const store = useTinyflowStore();
|
||||
const updateEdgeData = (
|
||||
id: string,
|
||||
dataUpdate: any,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { type Edge, type Node, type Viewport } from '@xyflow/svelte';
|
||||
import { getContext, setContext } from 'svelte';
|
||||
import type { TinyflowNodeNormalizer } from '../utils/nodeInteraction';
|
||||
|
||||
const DEFAULT_VIEWPORT: Viewport = { x: 250, y: 100, zoom: 1 };
|
||||
const TINYFLOW_STORE_CONTEXT = Symbol('tinyflow_store');
|
||||
|
||||
const createStore = () => {
|
||||
export const createStore = () => {
|
||||
let nodesInternal = $state.raw([] as Node[]);
|
||||
let edgesInternal = $state.raw([] as Edge[]);
|
||||
let viewport = $state.raw({ ...DEFAULT_VIEWPORT } as Viewport);
|
||||
@@ -86,4 +88,18 @@ const createStore = () => {
|
||||
};
|
||||
};
|
||||
|
||||
export const store = createStore();
|
||||
export type TinyflowStore = ReturnType<typeof createStore>;
|
||||
|
||||
export const createTinyflowStoreContext = () => {
|
||||
const store = createStore();
|
||||
setContext(TINYFLOW_STORE_CONTEXT, store);
|
||||
return store;
|
||||
};
|
||||
|
||||
export const useTinyflowStore = () => {
|
||||
const store = getContext<TinyflowStore | undefined>(TINYFLOW_STORE_CONTEXT);
|
||||
if (!store) {
|
||||
throw new Error('Tinyflow store context is not initialized');
|
||||
}
|
||||
return store;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import './components/TinyflowComponent.svelte';
|
||||
import { Tinyflow } from './Tinyflow';
|
||||
|
||||
const waitForRender = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
describe('tinyflow store isolation', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('keeps simultaneous canvas data isolated', async () => {
|
||||
const firstContainer = document.createElement('div');
|
||||
const secondContainer = document.createElement('div');
|
||||
document.body.append(firstContainer, secondContainer);
|
||||
|
||||
const first = new Tinyflow({
|
||||
element: firstContainer,
|
||||
data: {
|
||||
nodes: [
|
||||
{ id: 'first-source', position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: 'first-target', position: { x: 300, y: 0 }, data: {} },
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'first-edge',
|
||||
source: 'first-source',
|
||||
target: 'first-target',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const second = new Tinyflow({
|
||||
element: secondContainer,
|
||||
data: {
|
||||
nodes: [
|
||||
{ id: 'second-source', position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: 'second-target', position: { x: 300, y: 0 }, data: {} },
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'second-edge',
|
||||
source: 'second-source',
|
||||
target: 'second-target',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await waitForRender();
|
||||
|
||||
expect(first.getData()?.nodes.map((node) => node.id)).toEqual([
|
||||
'first-source',
|
||||
'first-target',
|
||||
]);
|
||||
expect(second.getData()?.nodes.map((node) => node.id)).toEqual([
|
||||
'second-source',
|
||||
'second-target',
|
||||
]);
|
||||
|
||||
first.updateData({
|
||||
nodes: [
|
||||
{ id: 'first-updated', position: { x: 100, y: 100 }, data: {} },
|
||||
],
|
||||
edges: [],
|
||||
});
|
||||
await waitForRender();
|
||||
|
||||
expect(first.getData()?.nodes.map((node) => node.id)).toEqual([
|
||||
'first-updated',
|
||||
]);
|
||||
expect(first.getData()?.edges).toEqual([]);
|
||||
expect(second.getData()?.nodes.map((node) => node.id)).toEqual([
|
||||
'second-source',
|
||||
'second-target',
|
||||
]);
|
||||
expect(second.getData()?.edges.map((edge) => edge.id)).toEqual([
|
||||
'second-edge',
|
||||
]);
|
||||
|
||||
first.destroy();
|
||||
second.destroy();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,10 @@ import type {Node, useSvelteFlow} from '@xyflow/svelte';
|
||||
export type TinyflowData = Partial<
|
||||
ReturnType<ReturnType<typeof useSvelteFlow>['toObject']>
|
||||
>;
|
||||
export type TinyflowDataCommitReason =
|
||||
| 'change'
|
||||
| 'node-drag-stop'
|
||||
| 'viewport-move-end';
|
||||
export type TinyflowNodeData = Record<string, any>;
|
||||
export type TinyflowTheme = 'light' | 'dark';
|
||||
|
||||
@@ -94,6 +98,7 @@ export type CustomNode = {
|
||||
flowInstance: ReturnType<typeof useSvelteFlow>,
|
||||
) => void;
|
||||
onUpdate?: (parent: HTMLElement, node: Node) => void;
|
||||
onDestroy?: (parent: HTMLElement) => void;
|
||||
forms?: CustomNodeForm[];
|
||||
};
|
||||
|
||||
@@ -115,6 +120,7 @@ export type TinyflowOptions = {
|
||||
nodesDraggable?: boolean;
|
||||
nodesConnectable?: boolean;
|
||||
elementsSelectable?: boolean;
|
||||
onlyRenderVisibleElements?: boolean;
|
||||
dropEnabled?: boolean;
|
||||
provider?: {
|
||||
llm?: () => SelectItem[] | Promise<SelectItem[]>;
|
||||
@@ -128,6 +134,10 @@ export type TinyflowOptions = {
|
||||
onRunTest?: () => void | Promise<void>;
|
||||
hiddenNodes?: string[] | (() => string[]);
|
||||
onDataChange?: (data: TinyflowData) => void;
|
||||
onDataCommit?: (
|
||||
data: TinyflowData,
|
||||
reason: TinyflowDataCommitReason,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export type Parameter = {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Node } from '@xyflow/svelte';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createTinyflowNodeNormalizer,
|
||||
DEFAULT_NODE_DRAG_HANDLE,
|
||||
} from './nodeInteraction';
|
||||
|
||||
function createNode(patch: Partial<Node> = {}): Node {
|
||||
return {
|
||||
data: {},
|
||||
id: 'node-1',
|
||||
position: { x: 0, y: 0 },
|
||||
type: 'customNode',
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe('createTinyflowNodeNormalizer', () => {
|
||||
it('节点已经规范化时保留引用', () => {
|
||||
const normalizer = createTinyflowNodeNormalizer();
|
||||
const node = createNode({ dragHandle: DEFAULT_NODE_DRAG_HANDLE });
|
||||
|
||||
expect(normalizer(node)).toBe(node);
|
||||
});
|
||||
|
||||
it('需要补齐交互属性时采用写时复制且不修改输入', () => {
|
||||
const normalizer = createTinyflowNodeNormalizer();
|
||||
const node = createNode();
|
||||
|
||||
const normalized = normalizer(node);
|
||||
|
||||
expect(normalized).not.toBe(node);
|
||||
expect(normalized.dragHandle).toBe(DEFAULT_NODE_DRAG_HANDLE);
|
||||
expect(node.dragHandle).toBeUndefined();
|
||||
});
|
||||
|
||||
it('只读节点已经包含限制属性时保留引用', () => {
|
||||
const normalizer = createTinyflowNodeNormalizer({ readonly: true } as any);
|
||||
const node = createNode({
|
||||
connectable: false,
|
||||
draggable: false,
|
||||
selectable: false,
|
||||
});
|
||||
|
||||
expect(normalizer(node)).toBe(node);
|
||||
});
|
||||
});
|
||||
@@ -25,33 +25,45 @@ export function createTinyflowNodeNormalizer(
|
||||
const elementsSelectable = options?.elementsSelectable ?? !readonly;
|
||||
|
||||
return (node) => {
|
||||
const nextNode = { ...node };
|
||||
let nextNode = node;
|
||||
const mutableNode = () => {
|
||||
if (nextNode === node) {
|
||||
nextNode = { ...node };
|
||||
}
|
||||
return nextNode;
|
||||
};
|
||||
|
||||
if (nodesDraggable) {
|
||||
if (nextNode.draggable === false) {
|
||||
delete nextNode.draggable;
|
||||
delete mutableNode().draggable;
|
||||
}
|
||||
if (shouldUseDefaultDragHandle(nextNode, options?.customNodes)) {
|
||||
nextNode.dragHandle = DEFAULT_NODE_DRAG_HANDLE;
|
||||
mutableNode().dragHandle = DEFAULT_NODE_DRAG_HANDLE;
|
||||
}
|
||||
} else {
|
||||
nextNode.draggable = false;
|
||||
if (nextNode.draggable !== false) {
|
||||
mutableNode().draggable = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (nodesConnectable) {
|
||||
if (nextNode.connectable === false) {
|
||||
delete nextNode.connectable;
|
||||
delete mutableNode().connectable;
|
||||
}
|
||||
} else {
|
||||
nextNode.connectable = false;
|
||||
if (nextNode.connectable !== false) {
|
||||
mutableNode().connectable = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (elementsSelectable) {
|
||||
if (nextNode.selectable === false) {
|
||||
delete nextNode.selectable;
|
||||
delete mutableNode().selectable;
|
||||
}
|
||||
} else {
|
||||
nextNode.selectable = false;
|
||||
if (nextNode.selectable !== false) {
|
||||
mutableNode().selectable = false;
|
||||
}
|
||||
}
|
||||
|
||||
return nextNode;
|
||||
|
||||
Reference in New Issue
Block a user