fix: 修复工作流节点交互与旧浏览器兼容
- 修复 Tinyflow Vue wrapper 可选 boolean 默认关闭节点拖拽和连线的问题 - 为 Tinyflow 增加 structuredClone 兼容与 Chrome 90 构建目标 - 增加节点交互归一化与 Tab 空值防御
This commit is contained in:
@@ -74,7 +74,11 @@ export function useTabbar() {
|
|||||||
|
|
||||||
// 点击tab,跳转路由
|
// 点击tab,跳转路由
|
||||||
const handleClick = (key: string) => {
|
const handleClick = (key: string) => {
|
||||||
const { fullPath, path } = tabbarStore.getTabByKey(key);
|
const tab = tabbarStore.getTabByKey(key);
|
||||||
|
if (!tab) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { fullPath, path } = tab;
|
||||||
router.push(fullPath || path);
|
router.push(fullPath || path);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import type {useSvelteFlow} from '@xyflow/svelte';
|
|||||||
import {componentName} from './consts';
|
import {componentName} from './consts';
|
||||||
import {store} from './store/stores.svelte';
|
import {store} from './store/stores.svelte';
|
||||||
import type {TinyflowData, TinyflowOptions, TinyflowTheme} from './types';
|
import type {TinyflowData, TinyflowOptions, TinyflowTheme} from './types';
|
||||||
|
import {installTinyflowBrowserCompat} from './utils/compat';
|
||||||
|
import {createTinyflowNodeNormalizer} from './utils/nodeInteraction';
|
||||||
|
|
||||||
|
installTinyflowBrowserCompat();
|
||||||
|
|
||||||
type FlowInstance = ReturnType<typeof useSvelteFlow>;
|
type FlowInstance = ReturnType<typeof useSvelteFlow>;
|
||||||
|
|
||||||
@@ -102,6 +106,7 @@ export class Tinyflow {
|
|||||||
|
|
||||||
const currentViewport = flow.getViewport();
|
const currentViewport = flow.getViewport();
|
||||||
const currentNodes = flow.getNodes();
|
const currentNodes = flow.getNodes();
|
||||||
|
const normalizeNode = createTinyflowNodeNormalizer(this.options);
|
||||||
const currentNodePositions = new Map(
|
const currentNodePositions = new Map(
|
||||||
currentNodes.map((node) => [node.id, node.position]),
|
currentNodes.map((node) => [node.id, node.position]),
|
||||||
);
|
);
|
||||||
@@ -109,11 +114,12 @@ export class Tinyflow {
|
|||||||
options?.preserveViewport === true
|
options?.preserveViewport === true
|
||||||
? (data.nodes || currentNodes).map((node) => {
|
? (data.nodes || currentNodes).map((node) => {
|
||||||
const currentPosition = currentNodePositions.get(node.id);
|
const currentPosition = currentNodePositions.get(node.id);
|
||||||
return currentPosition
|
const nextNode = currentPosition
|
||||||
? { ...node, position: { ...currentPosition } }
|
? { ...node, position: { ...currentPosition } }
|
||||||
: node;
|
: node;
|
||||||
|
return normalizeNode(nextNode);
|
||||||
})
|
})
|
||||||
: data.nodes || currentNodes;
|
: (data.nodes || currentNodes).map((node) => normalizeNode(node));
|
||||||
store.setNodes(nextNodes);
|
store.setNodes(nextNodes);
|
||||||
store.setEdges(data.edges || flow.getEdges());
|
store.setEdges(data.edges || flow.getEdges());
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
import {store} from '#store/stores.svelte';
|
import {store} from '#store/stores.svelte';
|
||||||
import type {TinyflowData, TinyflowOptions} from '#types';
|
import type {TinyflowData, TinyflowOptions} from '#types';
|
||||||
import {setContext} from 'svelte';
|
import {setContext} from 'svelte';
|
||||||
|
import {createTinyflowNodeNormalizer} from '../utils/nodeInteraction';
|
||||||
|
|
||||||
const props = $props<{
|
const props = $props<{
|
||||||
options: TinyflowOptions,
|
options: TinyflowOptions,
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
}) as TinyflowOptions;
|
}) as TinyflowOptions;
|
||||||
const data = parseData(getOptions().data);
|
const data = parseData(getOptions().data);
|
||||||
const initialViewport = data?.viewport || null;
|
const initialViewport = data?.viewport || null;
|
||||||
|
store.setNodeNormalizer(createTinyflowNodeNormalizer(getOptions()));
|
||||||
store.init(
|
store.init(
|
||||||
data?.nodes || [],
|
data?.nodes || [],
|
||||||
data?.edges || [],
|
data?.edges || [],
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
<div style={rest.style} class="tf-collapse {rest.class}">
|
<div style={rest.style} class="tf-collapse {rest.class}">
|
||||||
{#each items as item, index}
|
{#each items as item, index}
|
||||||
<div class="tf-collapse-item">
|
<div class="tf-collapse-item">
|
||||||
<div class="tf-collapse-item-title" role="button" tabindex={index}
|
<div class="tf-collapse-item-title tf-node-drag-handle" role="button" tabindex={index}
|
||||||
onclick={() => handlerOnChange(item)}
|
onclick={() => handlerOnChange(item)}
|
||||||
onkeydown={(event) => {
|
onkeydown={(event) => {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
@@ -112,6 +112,14 @@
|
|||||||
background: var(--tf-bg-hover);
|
background: var(--tf-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tf-node-drag-handle {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.svelte-flow__node.dragging) .tf-node-drag-handle {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
.tf-collapse-item-title-help::after {
|
.tf-collapse-item-title-help::after {
|
||||||
content: attr(data-help);
|
content: attr(data-help);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { installTinyflowBrowserCompat } from './utils/compat';
|
||||||
|
|
||||||
|
installTinyflowBrowserCompat();
|
||||||
|
|
||||||
export * from './types';
|
export * from './types';
|
||||||
export * from './Tinyflow';
|
export * from './Tinyflow';
|
||||||
export * from './components/TinyflowComponent.svelte';
|
export * from './components/TinyflowComponent.svelte';
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { type Edge, type Node, type Viewport } from '@xyflow/svelte';
|
import { type Edge, type Node, type Viewport } from '@xyflow/svelte';
|
||||||
|
import type { TinyflowNodeNormalizer } from '../utils/nodeInteraction';
|
||||||
|
|
||||||
const DEFAULT_VIEWPORT: Viewport = { x: 250, y: 100, zoom: 1 };
|
const DEFAULT_VIEWPORT: Viewport = { x: 250, y: 100, zoom: 1 };
|
||||||
|
|
||||||
@@ -6,20 +7,27 @@ const createStore = () => {
|
|||||||
let nodesInternal = $state.raw([] as Node[]);
|
let nodesInternal = $state.raw([] as Node[]);
|
||||||
let edgesInternal = $state.raw([] as Edge[]);
|
let edgesInternal = $state.raw([] as Edge[]);
|
||||||
let viewport = $state.raw({ ...DEFAULT_VIEWPORT } as Viewport);
|
let viewport = $state.raw({ ...DEFAULT_VIEWPORT } as Viewport);
|
||||||
|
let normalizeNode: TinyflowNodeNormalizer = (node) => node;
|
||||||
|
|
||||||
|
const normalizeNodes = (nodes: Node[]) => nodes.map(normalizeNode);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// nodes: nodesInternal,
|
// nodes: nodesInternal,
|
||||||
// edges: edgesInternal,
|
// edges: edgesInternal,
|
||||||
// viewport,
|
// viewport,
|
||||||
|
setNodeNormalizer: (normalizer: TinyflowNodeNormalizer) => {
|
||||||
|
normalizeNode = normalizer;
|
||||||
|
nodesInternal = normalizeNodes(nodesInternal);
|
||||||
|
},
|
||||||
init: (nodes: Node[], edges: Edge[], nextViewport?: Viewport | null) => {
|
init: (nodes: Node[], edges: Edge[], nextViewport?: Viewport | null) => {
|
||||||
nodesInternal = nodes;
|
nodesInternal = normalizeNodes(nodes);
|
||||||
edgesInternal = edges;
|
edgesInternal = edges;
|
||||||
viewport = nextViewport ? { ...nextViewport } : { ...DEFAULT_VIEWPORT };
|
viewport = nextViewport ? { ...nextViewport } : { ...DEFAULT_VIEWPORT };
|
||||||
},
|
},
|
||||||
|
|
||||||
getNodes: () => nodesInternal,
|
getNodes: () => nodesInternal,
|
||||||
setNodes: (nodes: Node[]) => {
|
setNodes: (nodes: Node[]) => {
|
||||||
nodesInternal = nodes;
|
nodesInternal = normalizeNodes(nodes);
|
||||||
},
|
},
|
||||||
getEdges: () => edgesInternal,
|
getEdges: () => edgesInternal,
|
||||||
setEdges: (edges: Edge[]) => {
|
setEdges: (edges: Edge[]) => {
|
||||||
@@ -32,18 +40,18 @@ const createStore = () => {
|
|||||||
|
|
||||||
getNode: (id: string) => nodesInternal.find((node) => node.id === id),
|
getNode: (id: string) => nodesInternal.find((node) => node.id === id),
|
||||||
addNode: (node: Node) => {
|
addNode: (node: Node) => {
|
||||||
nodesInternal = [...nodesInternal, node];
|
nodesInternal = [...nodesInternal, normalizeNode(node)];
|
||||||
},
|
},
|
||||||
removeNode: (id: string) => {
|
removeNode: (id: string) => {
|
||||||
nodesInternal = nodesInternal.filter((node) => node.id !== id);
|
nodesInternal = nodesInternal.filter((node) => node.id !== id);
|
||||||
},
|
},
|
||||||
updateNode: (id: string, node: Node) => {
|
updateNode: (id: string, node: Node) => {
|
||||||
nodesInternal = nodesInternal.map((n) =>
|
nodesInternal = nodesInternal.map((n) =>
|
||||||
n.id === id ? { ...n, ...node } : n,
|
n.id === id ? normalizeNode({ ...n, ...node }) : n,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
updateNodes: (update: (nodes: Node[]) => Node[]) => {
|
updateNodes: (update: (nodes: Node[]) => Node[]) => {
|
||||||
nodesInternal = update(nodesInternal);
|
nodesInternal = normalizeNodes(update(nodesInternal));
|
||||||
},
|
},
|
||||||
updateNodeData: (id: string, data: Node['data']) => {
|
updateNodeData: (id: string, data: Node['data']) => {
|
||||||
nodesInternal = nodesInternal.map((n) =>
|
nodesInternal = nodesInternal.map((n) =>
|
||||||
|
|||||||
22
easyflow-ui-admin/packages/tinyflow-ui/src/utils/compat.ts
Normal file
22
easyflow-ui-admin/packages/tinyflow-ui/src/utils/compat.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
type StructuredCloneFallback = <T>(value: T) => T;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs browser compatibility shims required by Tinyflow.
|
||||||
|
*/
|
||||||
|
export function installTinyflowBrowserCompat() {
|
||||||
|
if (
|
||||||
|
typeof globalThis.structuredClone === 'function' ||
|
||||||
|
typeof JSON === 'undefined'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback: StructuredCloneFallback = (value) =>
|
||||||
|
value == null ? value : JSON.parse(JSON.stringify(value));
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'structuredClone', {
|
||||||
|
configurable: true,
|
||||||
|
value: fallback,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { Node } from '@xyflow/svelte';
|
||||||
|
import type { CustomNode, TinyflowOptions } from '../types';
|
||||||
|
|
||||||
|
export const DEFAULT_NODE_DRAG_HANDLE = '.tf-node-drag-handle';
|
||||||
|
|
||||||
|
export type TinyflowNodeNormalizer = (node: Node) => Node;
|
||||||
|
|
||||||
|
function shouldUseDefaultDragHandle(
|
||||||
|
node: Node,
|
||||||
|
customNodes?: Record<string, CustomNode>,
|
||||||
|
) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(node, 'dragHandle')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const customNode = node.type ? customNodes?.[node.type] : undefined;
|
||||||
|
return customNode?.presentation !== 'plain';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTinyflowNodeNormalizer(
|
||||||
|
options?: TinyflowOptions,
|
||||||
|
): TinyflowNodeNormalizer {
|
||||||
|
const readonly = options?.readonly === true;
|
||||||
|
const nodesDraggable = options?.nodesDraggable ?? !readonly;
|
||||||
|
const nodesConnectable = options?.nodesConnectable ?? !readonly;
|
||||||
|
const elementsSelectable = options?.elementsSelectable ?? !readonly;
|
||||||
|
|
||||||
|
return (node) => {
|
||||||
|
const nextNode = { ...node };
|
||||||
|
|
||||||
|
if (nodesDraggable) {
|
||||||
|
if (nextNode.draggable === false) {
|
||||||
|
delete nextNode.draggable;
|
||||||
|
}
|
||||||
|
if (shouldUseDefaultDragHandle(nextNode, options?.customNodes)) {
|
||||||
|
nextNode.dragHandle = DEFAULT_NODE_DRAG_HANDLE;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nextNode.draggable = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodesConnectable) {
|
||||||
|
if (nextNode.connectable === false) {
|
||||||
|
delete nextNode.connectable;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nextNode.connectable = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elementsSelectable) {
|
||||||
|
if (nextNode.selectable === false) {
|
||||||
|
delete nextNode.selectable;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nextNode.selectable = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextNode;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ export default defineConfig({
|
|||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
minify: true,
|
minify: true,
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
|
target: 'chrome90',
|
||||||
lib: {
|
lib: {
|
||||||
entry: resolve(__dirname, 'src/index.ts'),
|
entry: resolve(__dirname, 'src/index.ts'),
|
||||||
cssFileName: 'index',
|
cssFileName: 'index',
|
||||||
|
|||||||
@@ -5,9 +5,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {Tinyflow as TinyflowNative, TinyflowOptions} from '@tinyflow-ai/ui';
|
import {Tinyflow as TinyflowNative, TinyflowOptions} from '@tinyflow-ai/ui';
|
||||||
import '@tinyflow-ai/ui/dist/index.css';
|
import '@tinyflow-ai/ui/dist/index.css';
|
||||||
import {nextTick, onMounted, onUnmounted, ref, useAttrs, watch} from 'vue';
|
import {
|
||||||
|
getCurrentInstance,
|
||||||
|
nextTick,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
ref,
|
||||||
|
useAttrs,
|
||||||
|
watch,
|
||||||
|
} from 'vue';
|
||||||
|
|
||||||
type TinyflowDataOption = Exclude<TinyflowOptions['data'], string | undefined>;
|
type TinyflowDataOption = Exclude<TinyflowOptions['data'], string | undefined>;
|
||||||
|
type StructuredCloneFn = <T>(value: T) => T;
|
||||||
|
|
||||||
const props = defineProps<
|
const props = defineProps<
|
||||||
{
|
{
|
||||||
@@ -18,9 +27,26 @@ const props = defineProps<
|
|||||||
|
|
||||||
const divRef = ref<HTMLDivElement | null>(null);
|
const divRef = ref<HTMLDivElement | null>(null);
|
||||||
const attrs = useAttrs();
|
const attrs = useAttrs();
|
||||||
|
const instance = getCurrentInstance();
|
||||||
let tinyflow: TinyflowNative | null = null;
|
let tinyflow: TinyflowNative | null = null;
|
||||||
let mountedDataReady = false;
|
let mountedDataReady = false;
|
||||||
let lastAppliedDataSignature = '';
|
let lastAppliedDataSignature = '';
|
||||||
|
const optionalBooleanOptionKeys = new Set([
|
||||||
|
'readonly',
|
||||||
|
'hideBottomDock',
|
||||||
|
'hideEdgePanel',
|
||||||
|
'hideMiniMap',
|
||||||
|
'hideNodeHandles',
|
||||||
|
'hideNodeToolbar',
|
||||||
|
'hideNodePicker',
|
||||||
|
'hideNodeSetting',
|
||||||
|
'hideEdgeMarkers',
|
||||||
|
'edgeAnimated',
|
||||||
|
'nodesDraggable',
|
||||||
|
'nodesConnectable',
|
||||||
|
'elementsSelectable',
|
||||||
|
'dropEnabled',
|
||||||
|
]);
|
||||||
|
|
||||||
function normalizeOptionKey(key: string) {
|
function normalizeOptionKey(key: string) {
|
||||||
return key.replace(/-([a-z])/g, (_match: string, letter: string) =>
|
return key.replace(/-([a-z])/g, (_match: string, letter: string) =>
|
||||||
@@ -37,21 +63,49 @@ function normalizeOptions(source: Record<string, unknown>) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getProvidedPropKeys() {
|
||||||
|
return new Set(
|
||||||
|
Object.keys(instance?.vnode.props || {}).map((key) => normalizeOptionKey(key)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProps(source: Record<string, unknown>) {
|
||||||
|
const providedKeys = getProvidedPropKeys();
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(source).filter(([key, value]) => {
|
||||||
|
if (!optionalBooleanOptionKeys.has(key)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return value !== false || providedKeys.has(key);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 安全深拷贝工具函数
|
// 安全深拷贝工具函数
|
||||||
function safeDeepClone<T>(obj: T): T {
|
function safeDeepClone<T>(obj: T): T {
|
||||||
if (obj === null || typeof obj !== 'object') return obj;
|
if (obj === null || typeof obj !== 'object') return obj;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return structuredClone(obj);
|
const clone = (globalThis as { structuredClone?: StructuredCloneFn })
|
||||||
|
.structuredClone;
|
||||||
|
if (clone) {
|
||||||
|
return clone(obj);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to JSON cloning for browsers without native structuredClone.
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(JSON.stringify(obj));
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(JSON.stringify(obj));
|
|
||||||
} catch {
|
|
||||||
console.warn(
|
console.warn(
|
||||||
'Failed to clone object, returning original (may cause issues)',
|
'Failed to clone object, returning original (may cause issues)',
|
||||||
obj,
|
obj,
|
||||||
);
|
);
|
||||||
return obj;
|
return obj;
|
||||||
|
} catch {
|
||||||
|
return obj;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,7 +138,7 @@ onMounted(() => {
|
|||||||
// 净化 props.data,避免响应式对象或函数污染
|
// 净化 props.data,避免响应式对象或函数污染
|
||||||
const cleanedProps = {
|
const cleanedProps = {
|
||||||
...normalizeOptions(attrs),
|
...normalizeOptions(attrs),
|
||||||
...props,
|
...normalizeProps(props),
|
||||||
} as any;
|
} as any;
|
||||||
if ('data' in cleanedProps && cleanedProps.data != null) {
|
if ('data' in cleanedProps && cleanedProps.data != null) {
|
||||||
cleanedProps.data = cloneDataIfChanged(cleanedProps.data);
|
cleanedProps.data = cloneDataIfChanged(cleanedProps.data);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export default defineConfig({
|
|||||||
minify: true,
|
minify: true,
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
cssCodeSplit: true,
|
cssCodeSplit: true,
|
||||||
|
target: 'chrome90',
|
||||||
lib: {
|
lib: {
|
||||||
entry: resolve(__dirname, 'src/index.ts'),
|
entry: resolve(__dirname, 'src/index.ts'),
|
||||||
cssFileName: 'index',
|
cssFileName: 'index',
|
||||||
|
|||||||
Reference in New Issue
Block a user