diff --git a/apps/basic/config/config.ts b/apps/basic/config/config.ts index 34d0bc66..db346117 100644 --- a/apps/basic/config/config.ts +++ b/apps/basic/config/config.ts @@ -10,4 +10,5 @@ export default defineConfig({ title: 'XFlow Basic', favicons: ['/favicon.ico'], mfsu: false, + esbuildMinifyIIFE: true, }); diff --git a/apps/basic/config/routes.ts b/apps/basic/config/routes.ts index 326f654e..4602cdb0 100644 --- a/apps/basic/config/routes.ts +++ b/apps/basic/config/routes.ts @@ -3,4 +3,5 @@ export const routes = [ { path: '/basic', component: '@/pages/basic' }, { path: '/dnd', component: '@/pages/dnd' }, { path: '/dag', component: '@/pages/dag' }, + { path: '/diff', component: '@/pages/diff' }, ]; diff --git a/apps/basic/package.json b/apps/basic/package.json index a06cbbbc..2fba4a42 100644 --- a/apps/basic/package.json +++ b/apps/basic/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "@antv/xflow": "workspace:*", + "@antv/xflow-diff": "workspace:*", "highlight.js": "^10.7.3", "umi": "^4.0.64" }, diff --git a/apps/basic/src/pages/diff/index.tsx b/apps/basic/src/pages/diff/index.tsx new file mode 100644 index 00000000..5f21b3ab --- /dev/null +++ b/apps/basic/src/pages/diff/index.tsx @@ -0,0 +1,216 @@ +/* eslint-disable */ +import { DiffGraph } from '@antv/xflow-diff'; + +// 变更前数据 +const originalData = { + nodes: [ + { + id: '1', + shape: 'rect', + x: 100, + y: 100, + width: 100, + height: 40, + data: { + a: 'test', + }, + attrs: { + body: { + stroke: '#8f8f8f', + strokeWidth: 1, + fill: '#fff', + rx: 6, + ry: 6, + }, + }, + label: 'svg', + }, + { + id: '2', + shape: 'rect', + x: 50, + y: 200, + width: 100, + height: 40, + label: 'dog', + attrs: { + body: { + stroke: '#8f8f8f', + strokeWidth: 1, + fill: '#fff', + rx: 6, + ry: 6, + }, + }, + data: { + animal: { + name: 'dog', + age: 1, + }, + }, + }, + { + id: '3', + shape: 'rect', + x: 180, + y: 200, + width: 100, + height: 40, + label: 'cat', + attrs: { + body: { + stroke: '#8f8f8f', + strokeWidth: 1, + fill: '#fff', + rx: 6, + ry: 6, + }, + }, + data: { + animal: { + name: 'cat', + age: 1, + }, + }, + }, + ], + edges: [ + { + id: 'edge-1', + source: '1', + target: '2', + attrs: { + line: { + stroke: '#8f8f8f', + strokeWidth: 1, + }, + }, + }, + { + id: 'edge-2', + source: '1', + target: '3', + data: { + a: 'test', + }, + attrs: { + line: { + stroke: '#8f8f8f', + strokeWidth: 1, + }, + }, + }, + ], +}; + +// 变更后数据 +const currentData = { + nodes: [ + { + id: '1', + shape: 'rect', + x: 100, + y: 100, + width: 100, + height: 40, + data: { + a: 'test1', + }, + attrs: { + body: { + stroke: '#8f8f8f', + strokeWidth: 1, + fill: '#fff', + rx: 6, + ry: 6, + }, + }, + label: 'svg', + }, + { + id: '3', + shape: 'rect', + x: 180, + y: 200, + width: 100, + height: 40, + label: 'cat', + attrs: { + body: { + stroke: '#8f8f8f', + strokeWidth: 1, + fill: '#fff', + rx: 6, + ry: 6, + }, + }, + data: { + animal: { + name: 'cat', + age: 1, + }, + }, + }, + { + id: '4', + shape: 'rect', + x: 250, + y: 300, + width: 100, + height: 40, + label: 'fish', + attrs: { + body: { + stroke: '#8f8f8f', + strokeWidth: 1, + fill: '#fff', + rx: 6, + ry: 6, + }, + }, + data: { + animal: { + name: 'fish', + age: 1, + }, + }, + }, + ], + edges: [ + { + id: 'edge-3', + source: '3', + target: '4', + attrs: { + line: { + stroke: '#8f8f8f', + strokeWidth: 1, + }, + }, + }, + { + id: 'edge-2', + source: '1', + target: '3', + data: { + a: 'test1', + }, + attrs: { + line: { + stroke: '#8f8f8f', + strokeWidth: 1, + }, + }, + }, + ], +}; + +const Page = () => { + return ( +
+ +
+ ); +}; + +export default Page; diff --git a/packages/diff/README.en-US.md b/packages/diff/README.en-US.md new file mode 100644 index 00000000..4b15a59e --- /dev/null +++ b/packages/diff/README.en-US.md @@ -0,0 +1,3 @@ +English (US) | [简体中文](README.zh-Hans.md) + +# Diff diff --git a/packages/diff/README.md b/packages/diff/README.md new file mode 100644 index 00000000..3ee31410 --- /dev/null +++ b/packages/diff/README.md @@ -0,0 +1,3 @@ +[English (US)](README.md) | 简体中文 + +# Diff diff --git a/packages/diff/jest.config.js b/packages/diff/jest.config.js new file mode 100644 index 00000000..7ab1f219 --- /dev/null +++ b/packages/diff/jest.config.js @@ -0,0 +1 @@ +module.exports = require('@antv/testing/config/react'); diff --git a/packages/diff/package.json b/packages/diff/package.json new file mode 100644 index 00000000..66a80538 --- /dev/null +++ b/packages/diff/package.json @@ -0,0 +1,51 @@ +{ + "name": "@antv/xflow-diff", + "version": "1.0.0", + "description": "", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "types": "dist/typing/index.d.ts", + "private": true, + "files": [ + "dist", + "src" + ], + "keywords": [ + "xflow", + "x6", + "antv" + ], + "scripts": { + "setup": "tsup src/index.ts", + "build": "tsup src/index.ts", + "dev": "tsup src/index.ts --watch", + "lint:js": "eslint 'src/**/*.{js,jsx,ts,tsx}'", + "lint:css": "stylelint --allow-empty-input 'src/**/*.{css,less}'", + "lint:format": "prettier --check *.md *.json 'src/**/*.{js,jsx,ts,tsx,css,less,md,json}'", + "lint:typing": "tsc --noEmit", + "test": "jest --coverage" + }, + "dependencies": { + "@antv/xflow": "workspace:^" + }, + "devDependencies": { + "@antv/config-tsconfig": "workspace:^", + "@antv/config-tsup": "workspace:^", + "@antv/testing": "workspace:^", + "@types/react": "^18.0.28" + }, + "peerDependencies": { + "react": "^18.0.0" + }, + "bugs": { + "url": "https://github.com/antvis/XFlow/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/antvis/XFlow.git", + "directory": "packages/diff" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/diff/project.json b/packages/diff/project.json new file mode 100644 index 00000000..2be93a90 --- /dev/null +++ b/packages/diff/project.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://cdn.jsdelivr.net/npm/nx@latest/schemas/project-schema.json", + "targets": { + "lint": {}, + "ci": {} + } +} diff --git a/packages/diff/src/components/DiffGraph/index.tsx b/packages/diff/src/components/DiffGraph/index.tsx new file mode 100644 index 00000000..bd0df8ba --- /dev/null +++ b/packages/diff/src/components/DiffGraph/index.tsx @@ -0,0 +1,120 @@ +import type { EdgeOptions, NodeOptions, useGraphInstance } from '@antv/xflow'; +import { XFlow, XFlowGraph } from '@antv/xflow'; +import type { FC } from 'react'; +import { useEffect, useState } from 'react'; + +import type { DiffGraphOptions } from '@/types'; +import { compare, syncGraph } from '@/util'; + +import '../../styles/index.less'; +import Tool from './tool'; + +const DiffGraph: FC = (props) => { + const { + originalData, // 变更前数据 + currentData, // 变更后数据 + addColor = '#50a14f', // 新增节点的颜色 + addExtAttr, + delColor = '#ff7875', // 删除节点的颜色 + delExtAttr, + changeColor = '#ffc069', // 变更节点的颜色 + changeExtAttr, + graphOptions, + } = props; + + const [originalDataWithDiffInfo, setOriginalDataWithDiffInfo] = useState<{ + nodes: NodeOptions[]; + edges: EdgeOptions[]; + }>({ nodes: [], edges: [] }); + const [currentDataWithDiffInfo, setCurrentDataWithDiffInfo] = useState<{ + nodes: NodeOptions[]; + edges: EdgeOptions[]; + }>({ nodes: [], edges: [] }); + const [status, setStatus] = useState<'init' | 'computing' | 'done'>('init'); + const [graphs, setGraphs] = useState[]>([]); + + useEffect(() => { + // 获取 diff 信息,注入 attr + setStatus('computing'); + const { + originalDataWithDiffInfo: originalDataWithDiffInfoRe, + currentDataWithDiffInfo: currentDataWithDiffInfoRe, + } = compare( + originalData, + currentData, + addColor, + delColor, + changeColor, + addExtAttr, + delExtAttr, + changeExtAttr, + ); + + setOriginalDataWithDiffInfo(originalDataWithDiffInfoRe); + setCurrentDataWithDiffInfo(currentDataWithDiffInfoRe); + setStatus('done'); + }, []); // eslint-disable-line + + useEffect(() => { + // 关联双图的缩放和移动 + if (graphs.length === 2) { + syncGraph(graphs[0], graphs[1]); + } + }, [graphs]); + + const addGraph = (graph: ReturnType) => { + setGraphs((pre) => { + return [...pre, graph]; + }); + }; + + return ( +
+ {/* 左图 */} + {/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */} + {/* @ts-ignore */} + + {status === 'done' && ( + + )} + + + + {/* 右图 */} + {/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */} + {/* @ts-ignore */} + + {status === 'done' && ( + + )} + + +
+ ); +}; + +export { DiffGraph }; diff --git a/packages/diff/src/components/DiffGraph/tool.tsx b/packages/diff/src/components/DiffGraph/tool.tsx new file mode 100644 index 00000000..ca3d6791 --- /dev/null +++ b/packages/diff/src/components/DiffGraph/tool.tsx @@ -0,0 +1,28 @@ +import { EdgeOptions, NodeOptions, useGraphInstance, useGraphStore } from '@antv/xflow'; +import { FC, useEffect } from 'react'; + +interface ToolOptions { + data: { + nodes: NodeOptions[]; + edges: EdgeOptions[]; + }; + addGraph: (graph: ReturnType) => void; +} + +const Tool: FC = (props) => { + const { data, addGraph } = props; + const initData = useGraphStore((state) => state.initData); + const graphIns = useGraphInstance(); + + useEffect(() => { + // 初始化数据 + initData(data); + + // 上报 graph 实例 + addGraph(graphIns); + }, []); // eslint-disable-line + + return null; +}; + +export default Tool; diff --git a/packages/diff/src/components/index.ts b/packages/diff/src/components/index.ts new file mode 100644 index 00000000..5ed4b795 --- /dev/null +++ b/packages/diff/src/components/index.ts @@ -0,0 +1 @@ +export * from './DiffGraph'; diff --git a/packages/diff/src/index.ts b/packages/diff/src/index.ts new file mode 100644 index 00000000..195d95f5 --- /dev/null +++ b/packages/diff/src/index.ts @@ -0,0 +1,2 @@ +export * from './components'; +export * from './types'; diff --git a/packages/diff/src/styles/index.less b/packages/diff/src/styles/index.less new file mode 100644 index 00000000..ec7e20bb --- /dev/null +++ b/packages/diff/src/styles/index.less @@ -0,0 +1,14 @@ +.xflow-diff { + position: relative; + display: flex; + height: 100%; + + &::after { + position: absolute; + left: 50%; + width: 1px; + height: 100%; + background-color: #333; + content: ''; + } +} diff --git a/packages/diff/src/types/index.ts b/packages/diff/src/types/index.ts new file mode 100644 index 00000000..d55c1c81 --- /dev/null +++ b/packages/diff/src/types/index.ts @@ -0,0 +1,30 @@ +import type { EdgeOptions, GraphOptions, NodeOptions } from '@antv/xflow'; + +export interface DiffGraphOptions { + originalData: GraphData; + currentData: GraphData; + addColor?: ''; + addExtAttr?: object; + delColor?: ''; + delExtAttr?: object; + changeColor?: ''; + changeExtAttr?: object; + graphOptions?: GraphOptions; +} + +export interface GraphData { + nodes: NodeOptions[]; + edges: EdgeOptions[]; +} + +export interface NodeOptionsWithDiffInfo extends NodeOptions { + diffType?: 'DEL' | 'ADD' | 'CHG' | 'NONE'; +} +export interface EdgeOptionsWithDiffInfo extends EdgeOptions { + diffType?: 'DEL' | 'ADD' | 'CHG' | 'NONE'; +} + +export interface GraphDataWithDiffInfo { + nodes: NodeOptionsWithDiffInfo[]; + edges: EdgeOptionsWithDiffInfo[]; +} diff --git a/packages/diff/src/util/index.ts b/packages/diff/src/util/index.ts new file mode 100644 index 00000000..711a06de --- /dev/null +++ b/packages/diff/src/util/index.ts @@ -0,0 +1,199 @@ +import { useGraphInstance } from '@antv/xflow'; +import { GraphData, GraphDataWithDiffInfo } from '..'; + +export const compare: ( + originalData: GraphData, + currentData: GraphData, + addColor: string, + delColor: string, + changeColor: string, + addExtAttr?: object, + delExtAttr?: object, + changeExtAttr?: object, +) => { + originalDataWithDiffInfo: GraphDataWithDiffInfo; + currentDataWithDiffInfo: GraphDataWithDiffInfo; +} = ( + originalData, + currentData, + addColor, + delColor, + changeColor, + addExtAttr = {}, + delExtAttr = {}, + changeExtAttr = {}, +) => { + const originalDataWithDiffInfo = { + nodes: [...originalData.nodes], + edges: [...originalData.edges], + }; + const currentDataWithDiffInfo = { + nodes: [...currentData.nodes], + edges: [...currentData.edges], + }; + + // 寻找 currentData 中 originalData 没有的数据,即为新增的 + const originalIds = originalData.nodes + .map((node) => node.id) + .concat(originalData.edges.map((edge) => edge.id)); + for (let i = 0; i < currentData.nodes.length; i++) { + if (!originalIds.includes(currentData.nodes[i].id)) { + currentDataWithDiffInfo.nodes[i].diffType = 'ADD'; + currentDataWithDiffInfo.nodes[i].attrs = { + ...currentDataWithDiffInfo.nodes[i].attrs, + body: { + ...currentDataWithDiffInfo.nodes[i].attrs?.body, + fill: addColor, + }, + ...addExtAttr, + }; + } + } + for (let i = 0; i < currentData.edges.length; i++) { + if (!originalIds.includes(currentData.edges[i].id)) { + currentDataWithDiffInfo.edges[i].diffType = 'ADD'; + currentDataWithDiffInfo.edges[i].attrs = { + ...currentDataWithDiffInfo.edges[i].attrs, + line: { + ...currentDataWithDiffInfo.edges[i].attrs?.line, + stroke: addColor, + }, + ...addExtAttr, + }; + } + } + + // 寻找 originalData 中 currentData 没有的数据,即为新增的 + const currentIds = currentData.nodes + .map((node) => node.id) + .concat(currentData.edges.map((edge) => edge.id)); + for (let i = 0; i < originalData.nodes.length; i++) { + if (!currentIds.includes(originalData.nodes[i].id)) { + originalDataWithDiffInfo.nodes[i].diffType = 'DEL'; + originalDataWithDiffInfo.nodes[i].attrs = { + ...originalDataWithDiffInfo.nodes[i].attrs, + body: { + ...originalDataWithDiffInfo.nodes[i].attrs?.body, + fill: delColor, + }, + ...delExtAttr, + }; + } + } + for (let i = 0; i < originalData.edges.length; i++) { + if (!currentIds.includes(originalData.edges[i].id)) { + originalDataWithDiffInfo.edges[i].diffType = 'DEL'; + originalDataWithDiffInfo.edges[i].attrs = { + ...originalDataWithDiffInfo.edges[i].attrs, + line: { + ...originalDataWithDiffInfo.edges[i].attrs?.line, + stroke: delColor, + }, + ...delExtAttr, + }; + } + } + + // 寻找 originalData 中和 currentData 中同 id 的节点或边中的 data 不一样的节点和边 + for (let i = 0; i < originalData.nodes.length; i++) { + for (let j = 0; j < currentData.nodes.length; j++) { + if ( + originalData.nodes[i].id === currentData.nodes[j].id && + JSON.stringify(originalData.nodes[i].data) !== + JSON.stringify(currentData.nodes[j].data) + ) { + originalDataWithDiffInfo.nodes[i].diffType = 'CHG'; + originalDataWithDiffInfo.nodes[i].attrs = { + ...originalDataWithDiffInfo.nodes[i].attrs, + body: { + ...originalDataWithDiffInfo.nodes[i].attrs?.body, + fill: changeColor, + }, + ...changeExtAttr, + }; + currentDataWithDiffInfo.nodes[j].diffType = 'CHG'; + currentDataWithDiffInfo.nodes[i].attrs = { + ...currentDataWithDiffInfo.nodes[i].attrs, + body: { + ...currentDataWithDiffInfo.nodes[i].attrs?.body, + fill: changeColor, + }, + ...changeExtAttr, + }; + } + } + } + for (let i = 0; i < originalData.edges.length; i++) { + for (let j = 0; j < currentData.edges.length; j++) { + if ( + originalData.edges[i].id === currentData.edges[j].id && + JSON.stringify(originalData.edges[i].data) !== + JSON.stringify(currentData.edges[j].data) + ) { + originalDataWithDiffInfo.edges[i].diffType = 'CHG'; + originalDataWithDiffInfo.edges[i].attrs = { + ...originalDataWithDiffInfo.edges[i].attrs, + line: { + ...originalDataWithDiffInfo.edges[i].attrs?.line, + stroke: changeColor, + }, + ...changeExtAttr, + }; + currentDataWithDiffInfo.edges[j].diffType = 'CHG'; + currentDataWithDiffInfo.edges[i].attrs = { + ...currentDataWithDiffInfo.edges[i].attrs, + line: { + ...currentDataWithDiffInfo.edges[i].attrs?.line, + stroke: changeColor, + }, + ...changeExtAttr, + }; + } + } + } + + return { originalDataWithDiffInfo, currentDataWithDiffInfo }; +}; + +// 同步两图的缩放和移动 +export const syncGraph = ( + graph1: ReturnType, + graph2: ReturnType, +) => { + if (!graph1 || !graph2) { + return; + } + + let isSyncing = false; + graph1.on('scale', ({ sx }) => { + if (!isSyncing) { + isSyncing = true; + graph2.zoomTo(sx); + isSyncing = false; + } + }); + + graph1.on('translate', ({ tx, ty }) => { + if (!isSyncing) { + isSyncing = true; + graph2.translate(tx, ty); + isSyncing = false; + } + }); + + graph2.on('scale', ({ sx }) => { + if (!isSyncing) { + isSyncing = true; + graph1.zoomTo(sx); + isSyncing = false; + } + }); + + graph2.on('translate', ({ tx, ty }) => { + if (!isSyncing) { + isSyncing = true; + graph1.translate(tx, ty); + isSyncing = false; + } + }); +}; diff --git a/packages/diff/tsconfig.json b/packages/diff/tsconfig.json new file mode 100644 index 00000000..66ef49d0 --- /dev/null +++ b/packages/diff/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@antv/config-tsconfig/mana.json", + "compilerOptions": { + "declarationDir": "./dist/typing", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "esModuleInterop": true, + "jsx": "react-jsx" + }, + "include": ["./src/**/*"] +} diff --git a/packages/diff/tsup.config.js b/packages/diff/tsup.config.js new file mode 100644 index 00000000..70e61b95 --- /dev/null +++ b/packages/diff/tsup.config.js @@ -0,0 +1 @@ +module.exports = require('@antv/config-tsup'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23883261..bdcd3129 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,6 +63,9 @@ importers: '@antv/xflow': specifier: workspace:* version: link:../../packages/core + '@antv/xflow-diff': + specifier: workspace:* + version: link:../../packages/diff highlight.js: specifier: ^10.7.3 version: 10.7.3 @@ -162,6 +165,28 @@ importers: specifier: ^5.0.0 version: 5.0.0(react-dom@18.2.0)(react@18.0.0) + packages/diff: + dependencies: + '@antv/xflow': + specifier: workspace:^ + version: link:../core + react: + specifier: ^18.0.0 + version: 18.2.0 + devDependencies: + '@antv/config-tsconfig': + specifier: workspace:^ + version: link:../../tooling/tsconfig + '@antv/config-tsup': + specifier: workspace:^ + version: link:../../tooling/tsup + '@antv/testing': + specifier: workspace:^ + version: link:../../tooling/jest + '@types/react': + specifier: ^18.0.28 + version: 18.2.28 + tooling/eslint: dependencies: '@typescript-eslint/eslint-plugin': @@ -3876,7 +3901,6 @@ packages: '@types/prop-types': 15.7.8 '@types/scheduler': 0.16.4 csstype: 3.1.2 - dev: false /@types/scheduler@0.16.4: resolution: {integrity: sha512-2L9ifAGl7wmXwP4v3pN4p2FLhD0O1qsJpvKmNin5VA8+UvNVb447UDaAEV6UdrkA+m/Xs58U1RFps44x6TFsVQ==} @@ -9866,6 +9890,7 @@ packages: /pify@4.0.1: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + requiresBuild: true dev: false /pino-abstract-transport@0.5.0: @@ -10503,6 +10528,7 @@ packages: /prr@1.0.1: resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} + requiresBuild: true dev: false optional: true @@ -11577,6 +11603,7 @@ packages: /sax@1.3.0: resolution: {integrity: sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==} + requiresBuild: true dev: false optional: true