From 4dca9c6478d4cbe0ea5e191101edeb50415d180c Mon Sep 17 00:00:00 2001 From: dtoro Date: Thu, 12 Mar 2026 23:07:51 +0100 Subject: [PATCH] feat: replace codemirror and image viewr, opt for simplier approach --- frontend/package-lock.json | 381 ++---------------- frontend/package.json | 8 +- frontend/src/components/editor/CodeEditor.tsx | 102 +++++ .../components/graph/NodeStatusIndicator.tsx | 4 +- .../components/nodes/config/ConfigNode.tsx | 54 +-- .../nodes/function/FunctionNode.tsx | 35 +- .../nodes/render/views/ImageOutputView.tsx | 61 +-- .../nodes/render/views/RawOutputView.tsx | 41 +- frontend/src/hooks/useCodeMirrorInsert.ts | 51 --- frontend/src/hooks/useResizeHeight.ts | 3 +- frontend/src/hooks/useSimpleEditorInsert.ts | 52 +++ frontend/src/lib/nunjucksAutocomplete.ts | 98 ----- frontend/src/lib/nunjucksTokenizer.ts | 109 +++++ frontend/src/lib/plantumlLanguage.ts | 79 ---- frontend/src/lib/prismSetup.ts | 7 + frontend/src/lib/setPrismGlobal.ts | 4 + frontend/src/lib/syntaxHighlight.tsx | 55 +++ frontend/src/main.tsx | 2 + frontend/src/styles.css | 27 ++ 19 files changed, 464 insertions(+), 709 deletions(-) create mode 100644 frontend/src/components/editor/CodeEditor.tsx delete mode 100644 frontend/src/hooks/useCodeMirrorInsert.ts create mode 100644 frontend/src/hooks/useSimpleEditorInsert.ts delete mode 100644 frontend/src/lib/nunjucksAutocomplete.ts create mode 100644 frontend/src/lib/nunjucksTokenizer.ts delete mode 100644 frontend/src/lib/plantumlLanguage.ts create mode 100644 frontend/src/lib/prismSetup.ts create mode 100644 frontend/src/lib/setPrismGlobal.ts create mode 100644 frontend/src/lib/syntaxHighlight.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a7a75b9..e6da2f4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 3843135..447090a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" diff --git a/frontend/src/components/editor/CodeEditor.tsx b/frontend/src/components/editor/CodeEditor.tsx new file mode 100644 index 0000000..785c1a0 --- /dev/null +++ b/frontend/src/components/editor/CodeEditor.tsx @@ -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(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 ( +
+ +
+ ) +} diff --git a/frontend/src/components/graph/NodeStatusIndicator.tsx b/frontend/src/components/graph/NodeStatusIndicator.tsx index 8516796..0edea05 100644 --- a/frontend/src/components/graph/NodeStatusIndicator.tsx +++ b/frontend/src/components/graph/NodeStatusIndicator.tsx @@ -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 }) }) diff --git a/frontend/src/components/nodes/config/ConfigNode.tsx b/frontend/src/components/nodes/config/ConfigNode.tsx index bd0e0b6..876e389 100644 --- a/frontend/src/components/nodes/config/ConfigNode.tsx +++ b/frontend/src/components/nodes/config/ConfigNode.tsx @@ -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(id, data ?? {}) - const editorRef = useRef(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) { /> -
} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input"> - } className="min-h-0 flex-1 w-full nodrag nopan overflow-auto border-t border-input" style={{ minHeight: editorHeight }}> +
diff --git a/frontend/src/components/nodes/function/FunctionNode.tsx b/frontend/src/components/nodes/function/FunctionNode.tsx index 0dc2e27..06b82cd 100644 --- a/frontend/src/components/nodes/function/FunctionNode.tsx +++ b/frontend/src/components/nodes/function/FunctionNode.tsx @@ -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 +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(id, data ?? {}) - const editorRef = useRef(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) { } /> -
} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input"> - } className="min-h-0 flex-1 w-full nodrag nopan overflow-auto border-t border-input" style={{ minHeight: editorHeight }}> +
diff --git a/frontend/src/components/nodes/render/views/ImageOutputView.tsx b/frontend/src/components/nodes/render/views/ImageOutputView.tsx index 95681f6..1f21020 100644 --- a/frontend/src/components/nodes/render/views/ImageOutputView.tsx +++ b/frontend/src/components/nodes/render/views/ImageOutputView.tsx @@ -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} > - - {({ zoomIn, zoomOut, resetTransform }) => ( - <> -
- - - -
-
- -
- -
- - )} - +
) } diff --git a/frontend/src/components/nodes/render/views/RawOutputView.tsx b/frontend/src/components/nodes/render/views/RawOutputView.tsx index 5a87e89..66eff04 100644 --- a/frontend/src/components/nodes/render/views/RawOutputView.tsx +++ b/frontend/src/components/nodes/render/views/RawOutputView.tsx @@ -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 (
- {}} + 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" />
) diff --git a/frontend/src/hooks/useCodeMirrorInsert.ts b/frontend/src/hooks/useCodeMirrorInsert.ts deleted file mode 100644 index 864aa20..0000000 --- a/frontend/src/hooks/useCodeMirrorInsert.ts +++ /dev/null @@ -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. - */ -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, - 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] - ) -} diff --git a/frontend/src/hooks/useResizeHeight.ts b/frontend/src/hooks/useResizeHeight.ts index 03eede3..9a46486 100644 --- a/frontend/src/hooks/useResizeHeight.ts +++ b/frontend/src/hooks/useResizeHeight.ts @@ -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 ?? []) diff --git a/frontend/src/hooks/useSimpleEditorInsert.ts b/frontend/src/hooks/useSimpleEditorInsert.ts new file mode 100644 index 0000000..01fb04d --- /dev/null +++ b/frontend/src/hooks/useSimpleEditorInsert.ts @@ -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(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 +} diff --git a/frontend/src/lib/nunjucksAutocomplete.ts b/frontend/src/lib/nunjucksAutocomplete.ts deleted file mode 100644 index 57e6ece..0000000 --- a/frontend/src/lib/nunjucksAutocomplete.ts +++ /dev/null @@ -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.-]*$/, - } - } -} diff --git a/frontend/src/lib/nunjucksTokenizer.ts b/frontend/src/lib/nunjucksTokenizer.ts new file mode 100644 index 0000000..1ee76b7 --- /dev/null +++ b/frontend/src/lib/nunjucksTokenizer.ts @@ -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)) +} diff --git a/frontend/src/lib/plantumlLanguage.ts b/frontend/src/lib/plantumlLanguage.ts deleted file mode 100644 index 247f911..0000000 --- a/frontend/src/lib/plantumlLanguage.ts +++ /dev/null @@ -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 diff --git a/frontend/src/lib/prismSetup.ts b/frontend/src/lib/prismSetup.ts new file mode 100644 index 0000000..9bf3fa5 --- /dev/null +++ b/frontend/src/lib/prismSetup.ts @@ -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' diff --git a/frontend/src/lib/setPrismGlobal.ts b/frontend/src/lib/setPrismGlobal.ts new file mode 100644 index 0000000..065ad83 --- /dev/null +++ b/frontend/src/lib/setPrismGlobal.ts @@ -0,0 +1,4 @@ +/** Run first so Prism is on global when language components load. */ +import Prism from 'prismjs' +;(globalThis as Record).Prism = Prism +export {} diff --git a/frontend/src/lib/syntaxHighlight.tsx b/frontend/src/lib/syntaxHighlight.tsx new file mode 100644 index 0000000..b542e1a --- /dev/null +++ b/frontend/src/lib/syntaxHighlight.tsx @@ -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 = { + javascript: 'javascript', + markdown: 'markdown', + plantuml: 'plantuml', +} + +function getGrammar(language: HighlightLanguage): Grammar { + const lang = prismLang[language] + const g = (Prism.languages as Record)[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) => ( +
+ {lineTokens.length > 0 ? ( + lineTokens.map((token, j) => ( + + {token.content} + + )) + ) : ( + + )} +
+ ))} + + ) +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 2e3bb6b..50083cd 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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' diff --git a/frontend/src/styles.css b/frontend/src/styles.css index d2a0fa1..551e524 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; } \ No newline at end of file