feat: replace codemirror and image viewr, opt for simplier approach

This commit is contained in:
2026-03-12 23:07:51 +01:00
parent b71d32da5e
commit 4dca9c6478
19 changed files with 464 additions and 709 deletions

View File

@@ -8,8 +8,6 @@
"name": "reactflow-shadcn-app",
"version": "0.1.0",
"dependencies": {
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/lang-markdown": "^6.5.0",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
@@ -25,7 +23,7 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3",
"@uiw/react-codemirror": "^4.25.7",
"@types/prismjs": "^1.26.6",
"@wireweave/core": "^2.6.0",
"@xyflow/react": "^12.10.1",
"class-variance-authority": "^0.7.1",
@@ -35,10 +33,12 @@
"marked": "^17.0.4",
"next-themes": "^0.4.6",
"nunjucks": "^3.2.4",
"prism-react-renderer": "^2.4.1",
"prismjs": "^1.30.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-router-dom": "^6.28.0",
"react-zoom-pan-pinch": "^3.7.0",
"react-simple-code-editor": "^0.14.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7"
@@ -507,15 +507,6 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -564,159 +555,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz",
"integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.2",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.2.tgz",
"integrity": "sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.11",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-markdown": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz",
"integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.3.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.2",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.2.tgz",
"integrity": "sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.5",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz",
"integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.35.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz",
"integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.5.4",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz",
"integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/theme-one-dark": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.39.16",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.16.tgz",
"integrity": "sha512-m6S22fFpKtOWhq8HuhzsI1WzUP/hB9THbDj0Tl5KX4gbO6Y91hwBl7Yky33NdvB6IffuRFiBxf1R8kJMyXmA4Q==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@dotenvx/dotenvx": {
"version": "1.53.0",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.53.0.tgz",
@@ -1499,79 +1337,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lezer/common": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.1.tgz",
"integrity": "sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.8",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz",
"integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/markdown": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.3.tgz",
"integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.27.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz",
@@ -3430,6 +3195,12 @@
"undici-types": "~7.18.0"
}
},
"node_modules/@types/prismjs": {
"version": "1.26.6",
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz",
"integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==",
"license": "MIT"
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -3472,59 +3243,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@uiw/codemirror-extensions-basic-setup": {
"version": "4.25.7",
"resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.7.tgz",
"integrity": "sha512-tPV/AGjF4yM22D5mnyH7EuYBkWO05wF5Y4x3lmQJo6LuHmhjh0RQsVDjqeIgNOkXT3UO9OdkL4dzxw465/JZVg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@codemirror/autocomplete": ">=6.0.0",
"@codemirror/commands": ">=6.0.0",
"@codemirror/language": ">=6.0.0",
"@codemirror/lint": ">=6.0.0",
"@codemirror/search": ">=6.0.0",
"@codemirror/state": ">=6.0.0",
"@codemirror/view": ">=6.0.0"
}
},
"node_modules/@uiw/react-codemirror": {
"version": "4.25.7",
"resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.7.tgz",
"integrity": "sha512-s/EbEe0dFANWEgfLbfdIrrOGv0R7M1XhkKG3ShroBeH6uP9pVNQy81YHOLRCSVcytTp9zAWRNfXR/+XxZTvV7w==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.6",
"@codemirror/commands": "^6.1.0",
"@codemirror/state": "^6.1.1",
"@codemirror/theme-one-dark": "^6.0.0",
"@uiw/codemirror-extensions-basic-setup": "4.25.7",
"codemirror": "^6.0.0"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.11.0",
"@codemirror/state": ">=6.0.0",
"@codemirror/theme-one-dark": ">=6.0.0",
"@codemirror/view": ">=6.0.0",
"codemirror": ">=6.0.0",
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz",
@@ -4204,21 +3922,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -4345,12 +4048,6 @@
}
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -6854,6 +6551,28 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/prism-react-renderer": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz",
"integrity": "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==",
"license": "MIT",
"dependencies": {
"@types/prismjs": "^1.26.0",
"clsx": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.0.0"
}
},
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/prompts": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@@ -7068,6 +6787,16 @@
"react-dom": ">=16.8"
}
},
"node_modules/react-simple-code-editor": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/react-simple-code-editor/-/react-simple-code-editor-0.14.1.tgz",
"integrity": "sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@@ -7090,20 +6819,6 @@
}
}
},
"node_modules/react-zoom-pan-pinch": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.7.0.tgz",
"integrity": "sha512-UmReVZ0TxlKzxSbYiAj+LeGRW8s8LraAFTXRAxzMYnNRgGPsxCudwZKVkjvGmjtx7SW/hZamt69NUmGf4xrkXA==",
"license": "MIT",
"engines": {
"node": ">=8",
"npm": ">=5"
},
"peerDependencies": {
"react": "*",
"react-dom": "*"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -7727,12 +7442,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/sucrase": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
@@ -8296,12 +8005,6 @@
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",

View File

@@ -8,8 +8,6 @@
"preview": "vite preview"
},
"dependencies": {
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/lang-markdown": "^6.5.0",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
@@ -25,7 +23,7 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3",
"@uiw/react-codemirror": "^4.25.7",
"@types/prismjs": "^1.26.6",
"@wireweave/core": "^2.6.0",
"@xyflow/react": "^12.10.1",
"class-variance-authority": "^0.7.1",
@@ -35,10 +33,12 @@
"marked": "^17.0.4",
"next-themes": "^0.4.6",
"nunjucks": "^3.2.4",
"prism-react-renderer": "^2.4.1",
"prismjs": "^1.30.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-router-dom": "^6.28.0",
"react-zoom-pan-pinch": "^3.7.0",
"react-simple-code-editor": "^0.14.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7"

View File

@@ -0,0 +1,102 @@
/**
* Shared code editor with Nunjucks support by default.
* Uses react-simple-code-editor + Prism + prism-react-renderer; highlights base language + {{ }}, {% %}, {# #}.
* Wherever this editor is used, it provides the same features (syntax highlighting + Nunjucks).
* The parent (node) provides the expected base language for highlighting.
*/
import React, { useCallback, useRef } from 'react'
import Editor from 'react-simple-code-editor'
import { highlight, type HighlightLanguage } from '@/lib/syntaxHighlight'
export type CodeEditorProps = {
value: string
onValueChange: (value: string) => void
/** Base language for syntax highlighting (Nunjucks is always applied on top). */
language: HighlightLanguage
/** Stable id for the underlying textarea (for insert-at-cursor). */
textareaId?: string
readOnly?: boolean
placeholder?: string
padding?: number
tabSize?: number
insertSpaces?: boolean
ignoreTabKey?: boolean
style?: React.CSSProperties
className?: string
textareaClassName?: string
preClassName?: string
/** Minimum height so the container doesn't collapse before resize. */
minHeight?: number
}
const defaultStyle: React.CSSProperties = {
fontFamily: 'ui-monospace, monospace',
fontSize: 12,
lineHeight: 1.5,
overflow: 'auto',
}
export function CodeEditor({
value,
onValueChange,
language,
textareaId,
readOnly = false,
padding = 8,
tabSize = 2,
insertSpaces = true,
ignoreTabKey = false,
style,
className,
textareaClassName,
preClassName,
minHeight = 120,
}: CodeEditorProps) {
const containerRef = useRef<HTMLDivElement | null>(null)
const highlightCode = useCallback(
(code: string) => highlight(code, language),
[language]
)
const handleValueChange = useCallback(
(newValue: string) => {
if (readOnly) {
onValueChange(newValue)
return
}
const ta =
(textareaId ? document.getElementById(textareaId) : containerRef.current?.querySelector('textarea')) as HTMLTextAreaElement | null
const start = ta?.selectionStart ?? newValue.length
const end = ta?.selectionEnd ?? newValue.length
onValueChange(newValue)
requestAnimationFrame(() => {
const el = (textareaId
? document.getElementById(textareaId)
: containerRef.current?.querySelector('textarea')) as HTMLTextAreaElement | null
if (el) el.setSelectionRange(start, end)
})
},
[onValueChange, readOnly, textareaId]
)
return (
<div ref={containerRef} className="code-editor" style={{ minHeight: 0 }}>
<Editor
value={value}
onValueChange={handleValueChange}
highlight={highlightCode}
tabSize={tabSize}
insertSpaces={insertSpaces}
ignoreTabKey={ignoreTabKey}
padding={padding}
readOnly={readOnly}
textareaId={textareaId}
style={{ ...defaultStyle, minHeight, ...style }}
className={className}
textareaClassName={textareaClassName}
preClassName={preClassName}
/>
</div>
)
}

View File

@@ -63,9 +63,7 @@ function BorderLoadingIndicator({
container.firstElementChild instanceof HTMLElement
? container.firstElementChild
: container
const cw = (target as HTMLElement).offsetWidth
const ch = (target as HTMLElement).offsetHeight
if (cw > 0 && ch > 0) setMeasured({ w: cw, h: ch })
// Use ResizeObserver only to avoid forced synchronous layout (offsetWidth/offsetHeight).
const unObserve = observeResize(target, ({ width: w, height: h }) => {
if (w > 0 && h > 0) setMeasured({ w, h })
})

View File

@@ -1,19 +1,14 @@
import React, { useCallback, useContext, useMemo, useRef } from 'react'
import { autocompletion } from '@codemirror/autocomplete'
import CodeMirror from '@uiw/react-codemirror'
import { javascript } from '@codemirror/lang-javascript'
import { markdown } from '@codemirror/lang-markdown'
import React, { useCallback, useContext, useId, useMemo } from 'react'
import {
AbstractNodeProps,
createAbstractNodeComponent,
getConnectedNodesByType,
useAbstractNode,
} from '@/lib/graph/abstractNode'
import { useCodeMirrorInsert, type CodeMirrorEditorRef } from '@/hooks/useCodeMirrorInsert'
import { CodeEditor } from '@/components/editor/CodeEditor'
import { useSimpleEditorInsert } from '@/hooks/useSimpleEditorInsert'
import { useResizeHeight } from '@/hooks/useResizeHeight'
import { nunjucksCompletionSource } from '@/lib/nunjucksAutocomplete'
import { plantumlLanguage } from '@/lib/plantumlLanguage'
import { useTheme } from '@/lib/themeContext'
import { type HighlightLanguage } from '@/lib/syntaxHighlight'
import {
getConfigTypes,
getConfigContent,
@@ -59,14 +54,13 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
const configTypeId = getConfigTypeId(data ?? {})
const configType = getConfigType(configTypeId)
const content = getConfigContent(data ?? {})
const { theme } = useTheme()
const { nodes, sourceIds, updateData } = useAbstractNode<ConfigNodeData>(id, data ?? {})
const editorRef = useRef<CodeMirrorEditorRef | null>(null)
const editorId = useId()
const onChange = useCallback(
(val: string) => updateData({ content: val, configType: configTypeId }),
[updateData, configTypeId]
)
const insertAt = useCodeMirrorInsert(editorRef, content, onChange)
const insertAt = useSimpleEditorInsert(editorId, content, onChange)
const connectedConfigNodes = useMemo(
() => getConnectedNodesByType(nodes, sourceIds, 'config'),
@@ -146,21 +140,8 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
[connectedConfigNodes],
)
const dataIds = useMemo(() => connectedDataNodes.map((n: any) => n.id), [connectedDataNodes])
const extensions = useMemo(() => {
const lang =
configTypeId === 'wireframe'
? javascript()
: configType.language === 'plantuml'
? plantumlLanguage.extension
: markdown()
return [
lang,
autocompletion({
override: [nunjucksCompletionSource(variableIds, configTitles, functionIds, dataIds)],
activateOnTyping: true,
}),
]
}, [configTypeId, configType.language, variableIds, functionIds, configTitles, dataIds])
const highlightLang: HighlightLanguage =
configTypeId === 'wireframe' ? 'javascript' : configType.language === 'plantuml' ? 'plantuml' : 'markdown'
const [editorHeight, editorContainerRef] = useResizeHeight(180)
const insertBlocksContent = useMemo(() => {
@@ -326,16 +307,17 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
/>
</div>
<div ref={editorContainerRef as React.RefObject<HTMLDivElement>} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
<CodeMirror
ref={editorRef}
<div ref={editorContainerRef as React.RefObject<HTMLDivElement>} className="min-h-0 flex-1 w-full nodrag nopan overflow-auto border-t border-input" style={{ minHeight: editorHeight }}>
<CodeEditor
textareaId={editorId}
value={content}
height={`${editorHeight}px`}
theme={theme}
extensions={extensions}
onChange={onChange}
basicSetup={{ lineNumbers: true, foldGutter: false }}
className="text-sm [&_.cm-editor]:outline-none [&_.cm-editor]:cursor-text [&_.cm-gutters]:border-0"
onValueChange={onChange}
language={highlightLang}
minHeight={editorHeight}
style={{ fontSize: 14 }}
textareaClassName="text-sm outline-none border-0 resize-none nodrag nopan"
preClassName="text-sm nodrag nopan"
className="min-h-0 w-full"
/>
</div>
</BaseNodeContent>

View File

@@ -1,15 +1,13 @@
import React, { useCallback, useContext, useMemo, useRef } from 'react'
import CodeMirror from '@uiw/react-codemirror'
import { javascript } from '@codemirror/lang-javascript'
import React, { useCallback, useContext, useId, useMemo } from 'react'
import {
AbstractNodeProps,
createAbstractNodeComponent,
getConnectedNodesByType,
useAbstractNode,
} from '@/lib/graph/abstractNode'
import { useCodeMirrorInsert, type CodeMirrorEditorRef } from '@/hooks/useCodeMirrorInsert'
import { CodeEditor } from '@/components/editor/CodeEditor'
import { useSimpleEditorInsert } from '@/hooks/useSimpleEditorInsert'
import { useResizeHeight } from '@/hooks/useResizeHeight'
import { useTheme } from '@/lib/themeContext'
import {
BaseNode,
BaseNodeContent,
@@ -30,19 +28,20 @@ export type FunctionNodeData = { body?: string }
type Props = AbstractNodeProps<FunctionNodeData>
const LANGUAGE: HighlightLanguage = 'javascript'
function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
const flowUIContext = useContext(FlowUIContext)
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
const supportsFullscreen = getNodeType('function')?.supportsFullscreen
const bodyValue = data?.body ?? ''
const { theme } = useTheme()
const { nodes, sourceIds, updateData } = useAbstractNode<FunctionNodeData>(id, data ?? {})
const editorRef = useRef<CodeMirrorEditorRef | null>(null)
const editorId = useId()
const onChange = useCallback(
(val: string) => updateData({ body: val }),
[updateData]
)
const insertAt = useCodeMirrorInsert(editorRef, bodyValue, onChange)
const insertAt = useSimpleEditorInsert(editorId, bodyValue, onChange)
const connectedVariableNodes = useMemo(
() => getConnectedNodesByType(nodes, sourceIds, 'variable'),
@@ -68,7 +67,6 @@ function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
[insertAt]
)
const extensions = useMemo(() => [javascript()], [])
const [editorHeight, editorContainerRef] = useResizeHeight(120)
const dimensions =
width != null && height != null && width > 0 && height > 0
@@ -120,16 +118,17 @@ function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
}
/>
</div>
<div ref={editorContainerRef as React.RefObject<HTMLDivElement>} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
<CodeMirror
ref={editorRef}
<div ref={editorContainerRef as React.RefObject<HTMLDivElement>} className="min-h-0 flex-1 w-full nodrag nopan overflow-auto border-t border-input" style={{ minHeight: editorHeight }}>
<CodeEditor
textareaId={editorId}
value={bodyValue}
height={`${editorHeight}px`}
theme={theme}
extensions={extensions}
onChange={onChange}
basicSetup={{ lineNumbers: true, foldGutter: false }}
className="text-xs [&_.cm-editor]:outline-none [&_.cm-editor]:cursor-text [&_.cm-gutters]:border-0"
onValueChange={onChange}
language="javascript"
minHeight={editorHeight}
style={{ fontSize: 12 }}
textareaClassName="text-xs outline-none border-0 resize-none nodrag nopan"
preClassName="text-xs nodrag nopan"
className="min-h-0 w-full"
/>
</div>
</BaseNodeContent>

View File

@@ -1,6 +1,4 @@
import React from 'react'
import { ZoomIn, ZoomOut, RotateCcw } from 'lucide-react'
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'
import type { RenderingNodeState } from '../useRenderingNodeState'
export type ImageOutputViewProps = {
@@ -13,8 +11,6 @@ export type ImageOutputViewProps = {
export function ImageOutputView({
state,
selected,
viewportFocused,
onViewportFocus,
onViewportBlur,
}: ImageOutputViewProps) {
@@ -25,59 +21,10 @@ export function ImageOutputView({
onFocus={onViewportFocus}
onBlur={onViewportBlur}
>
<TransformWrapper
initialScale={1}
initialPositionX={0}
initialPositionY={0}
minScale={0.2}
maxScale={4}
centerOnInit={false}
panning={{ disabled: !selected && !viewportFocused }}
wheel={{ disabled: !selected && !viewportFocused }}
doubleClick={{ disabled: !selected && !viewportFocused }}
>
{({ zoomIn, zoomOut, resetTransform }) => (
<>
<div className="react-flow__controls absolute bottom-2 left-2 z-10 nodrag nopan">
<button
type="button"
onClick={() => zoomIn()}
className="react-flow__controls-button"
title="Zoom in"
>
<ZoomIn className="size-3 max-w-[12px] max-h-[12px]" />
</button>
<button
type="button"
onClick={() => zoomOut()}
className="react-flow__controls-button"
title="Zoom out"
>
<ZoomOut className="size-3 max-w-[12px] max-h-[12px]" />
</button>
<button
type="button"
onClick={() => resetTransform()}
className="react-flow__controls-button"
title="Reset view (fit all)"
>
<RotateCcw className="size-3 max-w-[12px] max-h-[12px]" />
</button>
</div>
<div className="absolute inset-0 nodrag nopan overflow-hidden [&_.react-transform-component]:!w-full [&_.react-transform-component]:!h-full [&_.react-transform-wrapper]:!w-full [&_.react-transform-wrapper]:!h-full">
<TransformComponent
wrapperClass="!w-full !h-full"
contentClass="nodrag nopan !w-full !h-full !block !min-h-0"
>
<div
className="rendering-diagram absolute inset-0 w-full h-full min-w-0 min-h-0 nodrag nopan"
dangerouslySetInnerHTML={{ __html: state.displayContent ?? '' }}
/>
</TransformComponent>
</div>
</>
)}
</TransformWrapper>
<div
className="absolute inset-0 nodrag nopan overflow-auto"
dangerouslySetInnerHTML={{ __html: state.displayContent ?? '' }}
/>
</div>
)
}

View File

@@ -1,10 +1,8 @@
import React, { useMemo } from 'react'
import CodeMirror from '@uiw/react-codemirror'
import { javascript } from '@codemirror/lang-javascript'
import { markdown } from '@codemirror/lang-markdown'
import { Copy } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { plantumlLanguage } from '@/lib/plantumlLanguage'
import { CodeEditor } from '@/components/editor/CodeEditor'
import { type HighlightLanguage } from '@/lib/syntaxHighlight'
import type { RenderingNodeState } from '../useRenderingNodeState'
import { toast } from 'sonner'
@@ -15,21 +13,19 @@ export type RawOutputViewProps = {
theme: 'light' | 'dark'
}
export function RawOutputView({ state, height, containerRef, theme }: RawOutputViewProps) {
const extensions = useMemo(() => {
const lang =
state.rawLanguage === 'wireframe'
? javascript()
: state.rawLanguage === 'plantuml'
? plantumlLanguage.extension
: markdown()
return [lang]
}, [state.rawLanguage])
function languageForRaw(rawLanguage: string): HighlightLanguage {
if (rawLanguage === 'wireframe') return 'javascript'
if (rawLanguage === 'plantuml') return 'plantuml'
return 'markdown'
}
export function RawOutputView({ state, height, containerRef }: RawOutputViewProps) {
const language = useMemo(() => languageForRaw(state.rawLanguage), [state.rawLanguage])
return (
<div
ref={containerRef}
className="relative min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input"
className="relative min-h-0 flex-1 w-full nodrag nopan overflow-auto border-t border-input"
>
<Button
type="button"
@@ -50,15 +46,16 @@ export function RawOutputView({ state, height, containerRef, theme }: RawOutputV
>
<Copy className="size-3.5" />
</Button>
<CodeMirror
<CodeEditor
value={state.rawDisplayContent}
height={`${height}px`}
theme={theme}
extensions={extensions}
onValueChange={() => {}}
language={language}
readOnly
editable={false}
basicSetup={{ lineNumbers: true, foldGutter: false }}
className="text-xs [&_.cm-editor]:outline-none [&_.cm-editor]:cursor-text [&_.cm-gutters]:border-0 [&_.cm-scroller]:min-h-0"
minHeight={height}
style={{ fontSize: 12 }}
textareaClassName="text-xs outline-none border-0 resize-none nodrag nopan"
preClassName="text-xs nodrag nopan min-h-0"
className="min-h-0 w-full"
/>
</div>
)

View File

@@ -1,51 +0,0 @@
import { useCallback } from 'react'
/**
* Ref shape for @uiw/react-codemirror (view/state not in package types).
* Use with: ref={editorRef} and type editorRef as React.MutableRefObject<CodeMirrorEditorRef | null>.
*/
export type CodeMirrorEditorRef = {
view: {
state: {
doc: { length: number; toString(): string }
selection: { main: { from: number } }
}
dispatch: (arg: { changes: { from: number; to: number; insert: string } }) => void
}
}
export type InsertPosition = 'prepend' | 'append' | 'cursor'
/**
* Returns a stable insertAt(insertText, mode) that inserts text into the CodeMirror
* editor at the given position (prepend, append, or cursor), then calls onChange
* with the new content. Falls back to string concatenation when the editor ref
* is not yet mounted.
*/
export function useCodeMirrorInsert(
editorRef: React.RefObject<CodeMirrorEditorRef | null>,
currentContent: string,
onChange: (value: string) => void
): (insertText: string, mode: InsertPosition) => void {
return useCallback(
(insertText: string, mode: InsertPosition) => {
const ref = editorRef.current
if (ref?.view) {
const view = ref.view
const doc = view.state.doc
const len = doc.length
const from =
mode === 'prepend' ? 0 : mode === 'append' ? len : view.state.selection.main.from
view.dispatch({ changes: { from, to: from, insert: insertText } })
onChange(view.state.doc.toString())
return
}
if (mode === 'prepend') {
onChange(insertText + currentContent)
} else {
onChange(currentContent + insertText)
}
},
[currentContent, onChange]
)
}

View File

@@ -21,8 +21,7 @@ export function useResizeHeight(
const unobserve = observeResize(el, (size) => {
if (size.height > 0) setHeight(size.height)
})
const initial = el.getBoundingClientRect().height
if (initial > 0) setHeight(initial)
// Rely on ResizeObserver for initial size to avoid forced synchronous layout (getBoundingClientRect).
return unobserve
}, deps ?? [])

View File

@@ -0,0 +1,52 @@
import { useCallback, useRef } from 'react'
export type InsertPosition = 'prepend' | 'append' | 'cursor'
/**
* Returns a stable insertAt(insertText, mode) that inserts text into the simple code editor
* (textarea identified by textareaId) at the given position, then calls onChange with the new content.
* Cursor is restored after the next render via a scheduled effect.
*/
export function useSimpleEditorInsert(
textareaId: string,
currentContent: string,
onChange: (value: string) => void
): (insertText: string, mode: InsertPosition) => void {
const pendingCursorRef = useRef<number | null>(null)
const insertAt = useCallback(
(insertText: string, mode: InsertPosition) => {
const ta = document.getElementById(textareaId) as HTMLTextAreaElement | null
let start: number
let end: number
if (ta) {
start = mode === 'cursor' ? ta.selectionStart : mode === 'prepend' ? 0 : ta.value.length
end = mode === 'cursor' ? ta.selectionEnd : start
} else {
start = mode === 'prepend' ? 0 : currentContent.length
end = start
}
const newValue =
currentContent.slice(0, start) + insertText + currentContent.slice(end)
const nextCursor = start + insertText.length
pendingCursorRef.current = nextCursor
onChange(newValue)
// Restore cursor after React re-renders
if (ta) {
requestAnimationFrame(() => {
const el = document.getElementById(textareaId) as HTMLTextAreaElement | null
if (el && pendingCursorRef.current !== null) {
el.focus()
el.setSelectionRange(pendingCursorRef.current, pendingCursorRef.current)
pendingCursorRef.current = null
}
})
} else {
pendingCursorRef.current = null
}
},
[textareaId, currentContent, onChange]
)
return insertAt
}

View File

@@ -1,98 +0,0 @@
import { CompletionContext, CompletionResult } from '@codemirror/autocomplete'
import type { EditorState } from '@codemirror/state'
const NUNJUCKS_KEYWORDS = [
'if', 'endif', 'elif', 'else', 'for', 'endfor', 'in', 'and', 'or', 'not',
'true', 'false', 'none', 'macro', 'endmacro', 'set', 'endset', 'block', 'endblock',
'extends', 'include', 'import', 'with', 'endwith', 'filter', 'endfilter', 'raw', 'endraw',
]
const NUNJUCKS_FILTERS = [
'default', 'length', 'upper', 'lower', 'title', 'trim', 'join', 'replace',
'first', 'last', 'round', 'int', 'float', 'string', 'list', 'sort', 'groupby',
'trim', 'escape', 'safe', 'striptags', 'capitalize', 'reverse', 'batch', 'slice',
]
/** Get line text (CodeMirror 6: doc.line(n) is 1-based) */
function getLineText(state: EditorState, lineNo0Based: number): string {
return state.doc.line(lineNo0Based + 1).text
}
/** Detect if position is inside {{ or {% from the start of the line */
function insideNunjucks(state: EditorState, lineNo0Based: number, posInLine: number): boolean {
const line = getLineText(state, lineNo0Based)
const before = line.slice(0, posInLine)
const openVar = before.lastIndexOf('{{')
const openTag = before.lastIndexOf('{%')
const closeVar = before.lastIndexOf('}}')
const closeTag = before.lastIndexOf('%}')
if (openVar > -1 && (closeVar === -1 || closeVar < openVar)) return true
if (openTag > -1 && (closeTag === -1 || closeTag < openTag)) return true
return false
}
/** Get the word fragment before the cursor for matching */
function wordBefore(state: EditorState, lineNo0Based: number, posInLine: number): string {
const line = getLineText(state, lineNo0Based)
let start = posInLine
while (start > 0 && /[\w.-]/.test(line[start - 1])) start -= 1
return line.slice(start, posInLine)
}
export function nunjucksCompletionSource(
variableIds: string[],
configTitles?: string[],
functionIds?: string[],
dataIds?: string[],
): (context: CompletionContext) => CompletionResult | null {
return (context: CompletionContext) => {
const { state, pos } = context
const line = state.doc.lineAt(pos)
if (!insideNunjucks(state, line.number - 1, pos - line.from)) return null
const word = wordBefore(state, line.number - 1, pos - line.from)
const from = pos - word.length
const options: { label: string; type?: string; info?: string }[] = []
for (const id of variableIds) {
if (!word || id.toLowerCase().startsWith(word.toLowerCase())) {
options.push({ label: id, type: 'variable', info: 'Variable' })
}
}
for (const id of functionIds ?? []) {
if (!word || id.toLowerCase().startsWith(word.toLowerCase())) {
options.push({ label: id, type: 'function', info: "Filter: {{ '' | " + id + " }}" })
}
}
for (const kw of NUNJUCKS_KEYWORDS) {
if (!word || kw.startsWith(word.toLowerCase())) {
options.push({ label: kw, type: 'keyword', info: 'Nunjucks keyword' })
}
}
for (const f of NUNJUCKS_FILTERS) {
if (!word || f.startsWith(word.toLowerCase())) {
options.push({ label: `${f}`, type: 'function', info: `Filter: ${f}` })
}
}
if (configTitles?.length) {
for (const t of configTitles) {
if (!word || t.toLowerCase().startsWith(word.toLowerCase())) {
options.push({ label: t, type: 'variable', info: 'Config' })
}
}
}
for (const dataId of dataIds ?? []) {
if (!word || dataId.toLowerCase().startsWith(word.toLowerCase())) {
options.push({ label: dataId, type: 'variable', info: 'Data (array of rows)' })
}
}
if (options.length === 0) return null
return {
from,
options: options.slice(0, 50),
validFor: /^[\w.-]*$/,
}
}
}

View File

@@ -0,0 +1,109 @@
/**
* Nunjucks-aware tokenizer: splits content by {{ }}, {% %}, {# #} and highlights
* code parts with Prism and nunjucks parts with fixed token types.
* Used so the code editor always supports Nunjucks templating syntax.
*/
import Prism from 'prismjs'
import type { Grammar } from 'prismjs'
export type Token = { types: string[]; content: string }
/** Single-line nunjucks patterns (variable {{ }}, tag {% %}, comment {# #}) */
const NUNJUCKS_VAR = /\{\{[^}]*\}\}/g
const NUNJUCKS_TAG = /\{\%[^%]*\%\}/g
const NUNJUCKS_COMMENT = /\{\#[^#]*\#\}/g
/** Combined: match the first nunjucks block on a line (variable, tag, or comment) */
const NUNJUCKS_PATTERN = /\{\{[^}]*\}\}|\{\%[^%]*\%\}|\{\#[^#]*\#\}/g
/** Nunjucks-specific token types so we can style them differently from the main language. */
const TOKEN_VARIABLE = ['nunjucks-var']
const TOKEN_TAG = ['nunjucks-tag']
const TOKEN_COMMENT = ['nunjucks-comment']
function prismTokenToTypes(t: string | Prism.Token): string[] {
if (typeof t === 'string') return ['plain']
const type = t.type
const types = Array.isArray(type) ? type : [type]
const alias = (t as Prism.Token & { alias?: string | string[] }).alias
if (alias) {
const a = Array.isArray(alias) ? alias : [alias]
return [...types, ...a]
}
return types
}
function flattenPrismTokens(
tokens: (string | Prism.Token)[],
acc: Token[] = []
): Token[] {
for (const t of tokens) {
if (typeof t === 'string') {
acc.push({ types: ['plain'], content: t })
} else {
const types = prismTokenToTypes(t)
const content = t.content
if (typeof content === 'string') {
acc.push({ types, content })
} else {
flattenPrismTokens(content as (string | Prism.Token)[], acc)
}
}
}
return acc
}
/**
* Split a line into segments: alternating code and nunjucks (variable/tag/comment).
* Each nunjucks segment is one match; code segments are tokenized with Prism.
*/
function tokenizeLine(
line: string,
grammar: Grammar
): Token[] {
const result: Token[] = []
let lastIndex = 0
const re = new RegExp(NUNJUCKS_PATTERN.source, 'g')
let m: RegExpExecArray | null
while ((m = re.exec(line)) !== null) {
const codeSegment = line.slice(lastIndex, m.index)
if (codeSegment.length > 0) {
try {
const prismTokens = Prism.tokenize(codeSegment, grammar)
result.push(...flattenPrismTokens(prismTokens))
} catch {
result.push({ types: ['plain'], content: codeSegment })
}
}
const nunjucksContent = m[0]
if (nunjucksContent.startsWith('{{')) {
result.push({ types: TOKEN_VARIABLE, content: nunjucksContent })
} else if (nunjucksContent.startsWith('{%')) {
result.push({ types: TOKEN_TAG, content: nunjucksContent })
} else {
result.push({ types: TOKEN_COMMENT, content: nunjucksContent })
}
lastIndex = re.lastIndex
}
const tail = line.slice(lastIndex)
if (tail.length > 0) {
try {
const prismTokens = Prism.tokenize(tail, grammar)
result.push(...flattenPrismTokens(prismTokens))
} catch {
result.push({ types: ['plain'], content: tail })
}
}
return result
}
/**
* Tokenize code with Nunjucks support. Returns lines of tokens (same shape as prism-react-renderer).
*/
export function tokenizeWithNunjucks(
code: string,
grammar: Grammar
): Token[][] {
const lines = code.split('\n')
return lines.map((line) => tokenizeLine(line, grammar))
}

View File

@@ -1,79 +0,0 @@
import { StreamLanguage } from '@codemirror/language'
/** Nunjucks block comment {# ... #} */
function tokenNunjucksComment(stream: { match: (re: RegExp) => unknown; next: () => string | void; eol: () => boolean }) {
if (stream.match(/^\{#/)) {
while (!stream.eol()) {
if (stream.match(/#\}/)) return 'comment'
stream.next()
}
return 'comment'
}
return null
}
/** Nunjucks variable {{ ... }} or tag {% ... %} - tokenize the whole block */
function tokenNunjucksBlock(stream: { match: (re: RegExp) => unknown; next: () => string | void; eol: () => boolean }) {
if (stream.match(/^\{\{/)) {
while (!stream.eol()) {
if (stream.match(/\}\}/)) return 'variableName.special'
stream.next()
}
return 'variableName.special'
}
if (stream.match(/^\{\%/)) {
while (!stream.eol()) {
if (stream.match(/%\}/)) return 'keyword'
stream.next()
}
return 'keyword'
}
return null
}
/** Simple PlantUML + Nunjucks stream parser for syntax highlighting in CodeMirror */
const plantumlParser = StreamLanguage.define({
name: 'plantuml',
token(stream) {
// Nunjucks {# ... #} comment
const nunjucksComment = tokenNunjucksComment(stream)
if (nunjucksComment) return nunjucksComment
// Nunjucks {{ }} and {% %}
const nunjucksBlock = tokenNunjucksBlock(stream)
if (nunjucksBlock) return nunjucksBlock
// Single-quote line comment (PlantUML)
if (stream.match(/^'/)) {
stream.skipToEnd()
return 'comment'
}
// Double-quoted string
if (stream.match(/^"/)) {
let escaped = false
while (!stream.eol()) {
if (escaped) {
escaped = false
stream.next()
continue
}
const ch = stream.next()
if (ch === '\\') escaped = true
else if (ch === '"') break
}
return 'string'
}
// @directives (@startuml, @enduml, etc.)
if (stream.match(/^@\w+/)) return 'meta'
// Skip whitespace
if (stream.eatSpace()) return null
// Arrows and connectors
if (stream.match(/^->>?|<-<?|-->>?|<<--?|<-?>/)) return 'keyword'
// Keywords (participant, actor, as, title, etc.)
if (stream.match(/^(participant|actor|as|title|autonumber|left|right|of|over|activate|deactivate|destroy|create|group|opt|alt|else|loop|par|end|note|legend|skinparam|start|stop|if|endif|elseif|while|endwhile|repeat|until|switch|case|endswitch|class|interface|enum|package|namespace|abstract|static|extends|implements)\b/i)) return 'keyword'
// Any other character (identifier, punctuation, etc.)
stream.next()
return null
},
})
export const plantumlLanguage = plantumlParser

View File

@@ -0,0 +1,7 @@
/**
* Load Prism and register extra languages. Import this once before any syntax highlighting (e.g. in main.tsx).
* setPrismGlobal must run first so component IIFEs see Prism on global.
*/
import './setPrismGlobal'
import 'prismjs/components/prism-markdown'
import 'prismjs/components/prism-plant-uml'

View File

@@ -0,0 +1,4 @@
/** Run first so Prism is on global when language components load. */
import Prism from 'prismjs'
;(globalThis as Record<string, unknown>).Prism = Prism
export {}

View File

@@ -0,0 +1,55 @@
/**
* Syntax highlighting for the code editor: Prism + prism-react-renderer + Nunjucks.
* Nunjucks ({{ }}, {% %}, {# #}) is always applied so the editor supports templating everywhere.
* Ensure prismSetup.ts is imported once in main.tsx, and prism theme: import 'prismjs/themes/prism.css'
*/
import React from 'react'
import Prism from 'prismjs'
import type { Grammar } from 'prismjs'
import { tokenizeWithNunjucks } from '@/lib/nunjucksTokenizer'
export type HighlightLanguage = 'javascript' | 'markdown' | 'plantuml'
const prismLang: Record<HighlightLanguage, string> = {
javascript: 'javascript',
markdown: 'markdown',
plantuml: 'plantuml',
}
function getGrammar(language: HighlightLanguage): Grammar {
const lang = prismLang[language]
const g = (Prism.languages as Record<string, Grammar>)[lang]
return g ?? {}
}
/**
* Returns highlighted code as React nodes with Nunjucks support.
* Used by the shared CodeEditor so all usages get the same features (base language + Nunjucks).
*/
export function highlight(
code: string,
language: HighlightLanguage
): React.ReactNode {
const grammar = getGrammar(language)
const lines = tokenizeWithNunjucks(code, grammar)
return (
<>
{lines.map((lineTokens, i) => (
<div key={i} className="token-line">
{lineTokens.length > 0 ? (
lineTokens.map((token, j) => (
<span
key={j}
className={`token ${token.types.join(' ')}`}
>
{token.content}
</span>
))
) : (
<span className="token">&#8203;</span>
)}
</div>
))}
</>
)
}

View File

@@ -9,6 +9,8 @@ import { KosmosPage } from './app/kosmos/KosmosPage'
import { ProjectsPage } from './app/pleroma/PleromaPage'
import { KeromaPage } from './app/keroma/KeromaPage'
import { CanvasRoute } from './app/canvas/CanvasRoute'
import './lib/prismSetup'
import 'prismjs/themes/prism.css'
import './styles.css'
import '@xyflow/react/dist/style.css'

View File

@@ -366,4 +366,31 @@ pre {
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
/* Nunjucks tokens in code editor: distinct from main language (Prism) */
.token.nunjucks-var {
color: #7c3aed;
}
.token.nunjucks-tag {
color: #c2410c;
}
.token.nunjucks-comment {
color: #0d9488;
}
.dark .token.nunjucks-var {
color: #a78bfa;
}
.dark .token.nunjucks-tag {
color: #fb923c;
}
.dark .token.nunjucks-comment {
color: #2dd4bf;
}
/* Prism default theme adds a background to .token.operator (e.g. "="); remove it in our code editor */
.code-editor .token.operator,
.code-editor .token.entity,
.code-editor .token.url {
background: none;
}