diff --git a/src/baseSQLWorker.ts b/src/baseSQLWorker.ts index 4fe0166c..cda6941c 100644 --- a/src/baseSQLWorker.ts +++ b/src/baseSQLWorker.ts @@ -3,6 +3,7 @@ import { worker } from './fillers/monaco-editor-core'; import { Suggestions, ParseError, EntityContext } from 'dt-sql-parser'; import { Position } from './fillers/monaco-editor-core'; import { SemanticContext } from 'dt-sql-parser/dist/parser/common/types'; +import type { SerializedTreeNode } from './languageService'; export interface ICreateData { languageId: string; @@ -24,14 +25,43 @@ export abstract class BaseSQLWorker { return Promise.resolve([]); } - async parserTreeToString(code: string): Promise { - if (code) { - const parser = this.parser.createParser(code); - const parseTree = parser.program(); - const result = parseTree.toStringTree(parser); - return Promise.resolve(result); + async getSerializedParseTree(code: string): Promise { + if (!code) return Promise.resolve(null); + + const parser = this.parser.createParser(code); + const parseTree = parser.program(); + const ruleNames = parser.ruleNames; + const tokenTypeMap = parser.getTokenTypeMap(); + const tokenNameMap = new Map(); + + for (const [name, tokenType] of tokenTypeMap.entries()) { + tokenNameMap.set(tokenType, name); + } + + function serializeNode(node: any): SerializedTreeNode | null { + if (!node) return null; + + const isRuleNode = !node.symbol; + + const serializedNode: SerializedTreeNode = { + ruleName: isRuleNode ? ruleNames[node.ruleIndex] : node.constructor.name, + text: isRuleNode + ? '' + : tokenNameMap.get(node.symbol.tokenSource?.type) + ': ' + node.symbol.text, + children: [] + }; + + for (let i = 0; i < node.getChildCount(); i++) { + const child = node.getChild(i); + if (child) { + serializedNode.children.push(serializeNode(child)!); + } + } + + return serializedNode; } - return Promise.resolve(''); + + return Promise.resolve(serializeNode(parseTree)); } async doCompletion(code: string, position: Position): Promise { diff --git a/src/languageService.ts b/src/languageService.ts index d5ac2233..20342e3e 100644 --- a/src/languageService.ts +++ b/src/languageService.ts @@ -7,6 +7,12 @@ import { WorkerManager } from './workerManager'; import { BaseSQLWorker } from './baseSQLWorker'; import { Position, Uri, editor } from './fillers/monaco-editor-core'; +export interface SerializedTreeNode { + ruleName: string; + text?: string; + children: SerializedTreeNode[]; +} + export class LanguageService { private workerClients: Map> = new Map(); @@ -20,13 +26,13 @@ export class LanguageService { }); } - public parserTreeToString(language: string, model: editor.IReadOnlyModel | string) { + public getSerializedParseTree(language: string, model: editor.IReadOnlyModel | string) { const text = typeof model === 'string' ? model : model.getValue(); const uri = typeof model === 'string' ? void 0 : model.uri; const clientWorker = this.getClientWorker(language, uri as Uri); return clientWorker.then((worker) => { - return worker.parserTreeToString(text); + return worker.getSerializedParseTree(text); }); } diff --git a/website/package.json b/website/package.json index e68e0892..a03f01a8 100644 --- a/website/package.json +++ b/website/package.json @@ -23,10 +23,14 @@ "react-dom": "^18.2.0", "reflect-metadata": "^0.1.13", "tsyringe": "^4.8.0", + "html-to-image": "^1.11.13", "vscode-oniguruma": "^2.0.1", - "vscode-textmate": "^9.2.0" + "vscode-textmate": "^9.2.0", + "@xyflow/react": "^12.4.2", + "dagre": "^0.8.5" }, "devDependencies": { + "@types/dagre": "^0.7.52", "@types/node": "^20.2.5", "@types/react": "^18.0.37", "@types/react-dom": "^18.0.11", diff --git a/website/pnpm-lock.yaml b/website/pnpm-lock.yaml index 9dd1ce68..b2b4c59b 100644 --- a/website/pnpm-lock.yaml +++ b/website/pnpm-lock.yaml @@ -17,12 +17,21 @@ importers: '@vscode/codicons': specifier: ^0.0.41 version: 0.0.41 + '@xyflow/react': + specifier: ^12.4.2 + version: 12.9.2(@types/react@18.3.3)(immer@10.1.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) classnames: specifier: ^2.5.1 version: 2.5.1 + dagre: + specifier: ^0.8.5 + version: 0.8.5 esbuild: specifier: ^0.24.2 version: 0.24.2 + html-to-image: + specifier: ^1.11.13 + version: 1.11.13 idb-keyval: specifier: ^6.2.1 version: 6.2.1 @@ -54,6 +63,9 @@ importers: specifier: ^9.2.0 version: 9.2.0 devDependencies: + '@types/dagre': + specifier: ^0.7.52 + version: 0.7.53 '@types/node': specifier: ^20.2.5 version: 20.14.14 @@ -716,6 +728,27 @@ packages: '@types/babel__traverse@7.20.6': resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==} + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/dagre@0.7.53': + resolution: {integrity: sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ==} + '@types/js-cookie@2.2.7': resolution: {integrity: sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA==} @@ -819,6 +852,15 @@ packages: '@xobotyi/scrollbar-width@1.9.5': resolution: {integrity: sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==} + '@xyflow/react@12.9.2': + resolution: {integrity: sha512-Xr+LFcysHCCoc5KRHaw+FwbqbWYxp9tWtk1mshNcqy25OAPuaKzXSdqIMNOA82TIXF/gFKo0Wgpa6PU7wUUVqw==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + + '@xyflow/system@0.0.72': + resolution: {integrity: sha512-WBI5Aau0fXTXwxHPzceLNS6QdXggSWnGjDtj/gG669crApN8+SCmEtkBth1m7r6pStNo/5fI9McEi7Dk0ymCLA==} + JSONStream@1.3.5: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} hasBin: true @@ -989,6 +1031,9 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + classnames@2.5.1: resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} @@ -1142,6 +1187,47 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + dagre@0.8.5: + resolution: {integrity: sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==} + dargs@7.0.0: resolution: {integrity: sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg==} engines: {node: '>=8'} @@ -1512,6 +1598,9 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + graphlib@2.1.8: + resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==} + handlebars@4.7.8: resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} engines: {node: '>=0.4.7'} @@ -1547,6 +1636,9 @@ packages: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} + html-to-image@1.11.13: + resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==} + http-errors@2.0.0: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} @@ -2881,6 +2973,21 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -3394,6 +3501,29 @@ snapshots: dependencies: '@babel/types': 7.25.2 + '@types/d3-color@3.1.3': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/dagre@0.7.53': {} + '@types/js-cookie@2.2.7': {} '@types/json-schema@7.0.15': {} @@ -3522,6 +3652,29 @@ snapshots: '@xobotyi/scrollbar-width@1.9.5': {} + '@xyflow/react@12.9.2(@types/react@18.3.3)(immer@10.1.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@xyflow/system': 0.0.72 + classcat: 5.0.5 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + zustand: 4.5.7(@types/react@18.3.3)(immer@10.1.3)(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - immer + + '@xyflow/system@0.0.72': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + JSONStream@1.3.5: dependencies: jsonparse: 1.3.1 @@ -3682,6 +3835,8 @@ snapshots: dependencies: readdirp: 4.1.2 + classcat@5.0.5: {} + classnames@2.5.1: {} cliui@7.0.4: @@ -3885,6 +4040,47 @@ snapshots: csstype@3.1.3: {} + d3-color@3.1.0: {} + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-ease@3.0.1: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-selection@3.0.0: {} + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + dagre@0.8.5: + dependencies: + graphlib: 2.1.8 + lodash: 4.17.21 + dargs@7.0.0: {} dateformat@3.0.3: {} @@ -4312,6 +4508,10 @@ snapshots: graphemer@1.4.0: {} + graphlib@2.1.8: + dependencies: + lodash: 4.17.21 + handlebars@4.7.8: dependencies: minimist: 1.2.8 @@ -4343,6 +4543,8 @@ snapshots: dependencies: lru-cache: 6.0.0 + html-to-image@1.11.13: {} + http-errors@2.0.0: dependencies: depd: 2.0.0 @@ -5586,3 +5788,11 @@ snapshots: yargs-parser: 20.2.9 yocto-queue@0.1.0: {} + + zustand@4.5.7(@types/react@18.3.3)(immer@10.1.3)(react@18.3.1): + dependencies: + use-sync-external-store: 1.4.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + immer: 10.1.3 + react: 18.3.1 diff --git a/website/src/components/treeVisualizerPanel/index.tsx b/website/src/components/treeVisualizerPanel/index.tsx new file mode 100644 index 00000000..e7f2f6b1 --- /dev/null +++ b/website/src/components/treeVisualizerPanel/index.tsx @@ -0,0 +1,399 @@ +import { memo, useCallback, useEffect, useState, useTransition } from 'react'; +import { + ReactFlow, + Node, + Edge, + useNodesState, + useEdgesState, + Position, + ConnectionMode, + Background, + BackgroundVariant, + Controls, + ReactFlowProvider, + useReactFlow, + getNodesBounds, + getViewportForBounds, + ControlButton +} from '@xyflow/react'; +import dagre from 'dagre'; +import { toPng } from 'html-to-image'; +import { SerializedTreeNode } from 'monaco-sql-languages/esm/languageService'; + +import '@xyflow/react/dist/style.css'; + +interface TreeVisualizerPanelProps { + parseTree: SerializedTreeNode; +} + +enum NodeDisplayType { + TerminalNode = 'TerminalNode', + ErrorNode = 'ErrorNode', + RuleNode = 'RuleNode' +} + +interface NodeData { + label: string; + displayType: NodeDisplayType; + [key: string]: string; +} + +interface NodeStyleProps { + displayType: NodeDisplayType; + label: string; + isSelected?: boolean; + isChild?: boolean; + hasSelection?: boolean; +} + +// 计算文本宽度的辅助函数 +const calculateTextWidth = (text: string): number => { + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (!context) return 80; + + context.font = '14px Monaco, monospace'; + const metrics = context.measureText(text); + // 添加内边距和一些缓冲空间 + return Math.max(80, Math.ceil(metrics.width + 20)); +}; + +// 自定义节点样式 +const getNodeStyle = ({ + displayType, + label, + isSelected = false, + isChild = false, + hasSelection = false +}: NodeStyleProps) => { + const width = calculateTextWidth(label); + + return { + padding: '8px 0px', + borderRadius: '8px', + backgroundColor: + displayType === NodeDisplayType.TerminalNode + ? 'rgba(22, 163, 74, 0.8)' + : displayType === NodeDisplayType.ErrorNode + ? 'rgba(220, 38, 38, 0.8)' + : 'rgba(27, 126, 191, 0.8)', + color: '#ffffff', + fontSize: '12px', + width: width, + textAlign: 'center' as const, + transition: 'all 0.2s ease', + opacity: hasSelection && !isSelected && !isChild ? 0.3 : 1 + }; +}; + +const edgeStyle = { + stroke: 'rgb(14, 99, 156)', + strokeWidth: 2 +}; + +// 设置布局方向为从上到下 +const getLayoutedElements = >( + nodes: Node[], + edges: Edge[], + direction = 'TB' +) => { + // 每次都创建新的 dagre 图实例 + const dagreGraph = new dagre.graphlib.Graph(); + dagreGraph.setDefaultEdgeLabel(() => ({})); + + // 设置布局参数 + dagreGraph.setGraph({ + rankdir: direction, + nodesep: 50, // 同一行节点之间的间距 + ranksep: 50, // 不同行之间的间距 + edgesep: 10, // 边之间的间距 + marginx: 20, // 水平边距 + marginy: 20, // 垂直边距 + acyclicer: 'greedy', // 处理循环的算法 + ranker: 'network-simplex' // 布局算法 + }); + + nodes.forEach((node) => { + const label = node.data.label as string; + const width = calculateTextWidth(label); + dagreGraph.setNode(node.id, { width, height: 40 }); + }); + + edges.forEach((edge) => { + dagreGraph.setEdge(edge.source, edge.target); + }); + + // 计算布局 + dagre.layout(dagreGraph); + + // 应用计算后的位置 + const layoutedNodes = nodes.map((node) => { + const nodeWithPosition = dagreGraph.node(node.id); + return { + ...node, + position: { + x: nodeWithPosition.x - nodeWithPosition.width / 2, + y: nodeWithPosition.y - nodeWithPosition.height / 2 + } + }; + }); + + return { nodes: layoutedNodes, edges }; +}; + +const downloadImage = (dataUrl: string) => { + const a = document.createElement('a'); + a.setAttribute('download', `parse-tree-${new Date().toLocaleString()}.png`); + a.setAttribute('href', dataUrl); + a.click(); +}; + +const DownloadButton = () => { + const { getNodes } = useReactFlow(); + + const onClick = () => { + const nodes = getNodes(); + if (nodes.length === 0) return; + + const nodesBounds = getNodesBounds(nodes); + + const padding = 30; + const imageWidth = nodesBounds.width + padding; + const imageHeight = nodesBounds.height + padding; + + // 计算用于导出的视口变换 + const viewport = getViewportForBounds(nodesBounds, imageWidth, imageHeight, 0.1, 2, 0.1); + + const viewportElement = document.querySelector('.react-flow__viewport') as HTMLElement; + if (!viewportElement) return; + + toPng(viewportElement, { + backgroundColor: '#ffffff', + width: imageWidth, + height: imageHeight, + style: { + width: `${imageWidth}px`, + height: `${imageHeight}px`, + transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})` + } + }) + .then((dataUrl: string) => { + downloadImage(dataUrl); + }) + .catch((error: Error) => { + console.error('Download image failed:', error); + }); + }; + + return ( + + + + + + + + ); +}; + +const TreeVisualizerContent = ({ parseTree }: TreeVisualizerPanelProps) => { + const [nodes, setNodes, onNodesChange] = useNodesState>([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [_, startTransition] = useTransition(); + + // 获取节点的所有子节点ID + const getChildNodeIds = (nodeId: string | null): string[] => { + if (!nodeId) return []; + const childIds: string[] = []; + const queue = [nodeId]; + + while (queue.length > 0) { + const currentId = queue.shift()!; + edges.forEach((edge) => { + if (edge.source === currentId) { + childIds.push(edge.target); + queue.push(edge.target); + } + }); + } + + return childIds; + }; + + const handleNodeClick = (_event: React.MouseEvent, node: Node) => { + setSelectedNodeId(node.id); + }; + + const handlePaneClick = () => { + setSelectedNodeId(null); + }; + + const convertTreeToElements = useCallback((tree: SerializedTreeNode) => { + const newNodes: Node[] = []; + const newEdges: Edge[] = []; + let nodeId = 0; + let rootNodeId: string | null = null; + + const processNode = (node: SerializedTreeNode, parentId?: string): string => { + const currentId = `node-${nodeId++}`; + + if (rootNodeId === null) { + rootNodeId = currentId; + } + + const nodeDisplayType = [ + NodeDisplayType.TerminalNode, + NodeDisplayType.ErrorNode + ]?.includes(node.ruleName as any) + ? node.ruleName + : NodeDisplayType.RuleNode; + + const label = node.text ? node.text : node.ruleName; + + newNodes.push({ + id: currentId, + type: 'default', + data: { + label, + displayType: nodeDisplayType as NodeDisplayType + }, + position: { x: 0, y: 0 }, + style: getNodeStyle({ + displayType: nodeDisplayType as NodeDisplayType, + label, + isSelected: false, + isChild: false, + hasSelection: false + }), + sourcePosition: Position.Bottom, + targetPosition: Position.Top, + draggable: false + }); + + if (parentId) { + newEdges.push({ + id: `edge-${parentId}-${currentId}`, + source: parentId, + target: currentId, + type: 'smoothstep', + animated: true, + style: edgeStyle + }); + } + + node.children?.forEach((child) => { + processNode(child, currentId); + }); + + return currentId; + }; + + processNode(tree); + return { nodes: newNodes, edges: newEdges, rootNodeId }; + }, []); + + useEffect(() => { + if (!parseTree) { + setEdges([]); + setNodes([]); + return; + } + + const elements = convertTreeToElements(parseTree); + const layoutedElements = getLayoutedElements(elements.nodes, elements.edges); + + startTransition(() => { + setNodes(layoutedElements.nodes); + setEdges(layoutedElements.edges); + setSelectedNodeId(null); + }); + }, [parseTree]); + + useEffect(() => { + const childIds = getChildNodeIds(selectedNodeId); + + setNodes((nodes) => + nodes.map((node) => ({ + ...node, + style: getNodeStyle({ + displayType: node.data.displayType, + label: node.data.label, + isSelected: node.id === selectedNodeId, + isChild: childIds.includes(node.id), + hasSelection: selectedNodeId !== null // 只有当有选中节点时才降低其他节点亮度 + }) + })) + ); + }, [selectedNodeId]); + + return ( +
+ + + + + + +
+ ); +}; + +const TreeVisualizerPanel = memo((props: TreeVisualizerPanelProps) => { + return ( + + + + ); +}); + +export default TreeVisualizerPanel; diff --git a/website/src/consts/index.ts b/website/src/consts/index.ts index d8c5252d..1cb3fe23 100644 --- a/website/src/consts/index.ts +++ b/website/src/consts/index.ts @@ -14,6 +14,8 @@ export const SOURCE_FILE = 'activity.source.file'; export const SOURCE_OUTLINE = 'activity.source.outline'; +export const PARSE_TREE = 'panel.item.visualizer'; + export const QUICK_GITHUB_HREF = [ { href: 'https://github.com/DTStack/dt-sql-parser', diff --git a/website/src/extensions/main/index.tsx b/website/src/extensions/main/index.tsx index b29636f2..9dbea27d 100644 --- a/website/src/extensions/main/index.tsx +++ b/website/src/extensions/main/index.tsx @@ -19,7 +19,8 @@ import { ACTIVITY_FOLDER, ACTIVITY_SQL, ACTIVITY_API, - SQL_LANGUAGES + SQL_LANGUAGES, + PARSE_TREE } from '@/consts'; import QuickGithub from '@/workbench/quickGithub'; import SourceSpace from '@/workbench/sourceSpace'; @@ -32,6 +33,7 @@ import { ProblemsPaneView } from '@/workbench/problems'; import ProblemStore from '@/workbench/problems/clients/problemStore'; import { ProblemsService } from '@/workbench/problems/services'; import { ProblemsController } from '@/workbench/problems/controllers'; +import TreeVisualizerPanel from '@/components/treeVisualizerPanel'; const problemsService = new ProblemsService(); @@ -332,6 +334,17 @@ export const mainExt: IExtension = { } }); + // 添加解析树可视化 + molecule.panel.add({ + id: PARSE_TREE, + name: '语法解析树', + sortIndex: 3, + data: null, + render: (panelItem) => { + return ; + } + }); + molecule.activityBar.setCurrent(ACTIVITY_FOLDER); molecule.sidebar.setCurrent(ACTIVITY_FOLDER); @@ -343,7 +356,8 @@ export const mainExt: IExtension = { if (fileData?.model) { monaco.editor.setModelLanguage(fileData.model, language); - analyzeProblems({ fileData, molecule, tab }); + analyzeProblems({ fileData, molecule, tab, languageService }); + updateParseTree(molecule, languageService); } activeExplore(tab, molecule); }); @@ -368,7 +382,8 @@ export const mainExt: IExtension = { molecule.editor.onContextMenuClick((item, tabId, groupId) => { switch (item.id) { case 'parse': { - parseToAST(molecule, languageService); + updateParseTree(molecule, languageService); + molecule.panel.setCurrent(PARSE_TREE); break; } default: @@ -388,18 +403,20 @@ export const mainExt: IExtension = { const tab = molecule.editor.getCurrentTab(); const groups = molecule.editor.getGroups(); const fileData = groups[0]?.data?.find((item) => item.id === tab?.id); - analyzeProblems({ fileData, molecule, tab }); + analyzeProblems({ fileData, molecule, tab, languageService }); + debounceUpdateParseTree(molecule, languageService); }); } }; const analyzeProblems = debounce((info: any) => { - const { fileData, molecule, tab } = info || {}; + const { fileData, molecule, tab, languageService } = info || {}; const { value: sql, language } = fileData || {}; + // todo: 一定要 active Tab 才能获取到 language if (!language) return; - const languageService = new LanguageService(); - languageService.valid(language.toLocaleLowerCase(), fileData.model).then((res) => { + + languageService.valid(language.toLocaleLowerCase(), sql).then((res: ParseError[]) => { const problems = convertMsgToProblemItem(tab, sql, res); molecule.panel.update({ @@ -465,21 +482,21 @@ const activeExplore = (tab: Partial, molecule: IMoleculeContext) => { } }; -const parseToAST = (molecule: IMoleculeContext, languageService: LanguageService) => { - const sql = molecule.editor.getCurrentGroup()?.editorInstance?.getValue(); - - const curActiveTab = molecule.editor.getCurrentTab(); - const lang = curActiveTab?.language?.toLocaleLowerCase(); - if (lang && sql) { - languageService.parserTreeToString(lang, sql).then((res) => { - const pre = res?.replace(/(\(|\))/g, '$1\n'); - const format = new lips.Formatter(pre); - const formatted = format.format({ - indent: 2, - offset: 2 - }); - molecule.panel.setCurrent(molecule.builtin.getConstants().PANEL_ITEM_OUTPUT); - molecule.output.setState({ value: formatted }); +const updateParseTree = (molecule: IMoleculeContext, languageService: LanguageService) => { + const parseTreePanel = molecule.panel.get(PARSE_TREE); + const group = molecule.editor.getGroups()[0]; + const activeTab = group?.data?.find((item) => item.id === group.activeTab); + const language = activeTab?.language?.toLocaleLowerCase(); + + if (!parseTreePanel || !language || !activeTab) return; + const sql = activeTab.model?.getValue() || ''; + + languageService.getSerializedParseTree(language, sql).then((tree) => { + molecule.panel.update({ + id: PARSE_TREE, + data: tree }); - } + }); }; + +const debounceUpdateParseTree = debounce(updateParseTree, 400); diff --git a/website/src/utils/tool.ts b/website/src/utils/tool.ts index 68b5e3c7..469e7942 100644 --- a/website/src/utils/tool.ts +++ b/website/src/utils/tool.ts @@ -2,11 +2,11 @@ export function randomId() { return Math.round(Math.random() * 1000); } -export function debounce unknown>( +export function debounce any>( func: T, timeout: number, immediate?: boolean -): (...args: Parameters) => unknown { +): (...args: Parameters) => ReturnType { let timer: NodeJS.Timeout | null = null; return (...args) => { if (timer) {