Code editor: CodeMirror 6 replaces Monaco

Node source (Python) and the JSON dialog share one CodeMirror editor with
find and replace, autocomplete, folding, indent guides, comment toggle and a
JSON lint, themed from the CSS tokens so light and dark follow the .dark class
without a second palette. Programmatic value changes no longer fire onChange,
blur ignores focus moving into the find bar, readOnly keeps selection and copy.

The editor chunk is 470 kB (157 kB gzipped) against 2.7 MB (704 kB) for Monaco,
which also pulled in a 1.2 MB JSON mode, two workers and a font. At 4x CPU on
slow 4G a node panel is ready in 5.7 s instead of 10.8 s, the JSON dialog in
1.5 s instead of 6.7 s. Typing without a key delay keeps every character.

monaco-editor, @monaco-editor/react and the dompurify override are removed.
Specs: typed code reaches the node source, a JSON setting saves only text that
parses. Docs name Tab and Ctrl-M.
This commit is contained in:
2026-10-28 17:32:49 +01:00
parent 5d0ea480bf
commit 87e750c776
12 changed files with 531 additions and 143 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ def content_security_policy(index_html: str) -> str:
The hash is taken from the file being served, so editing the theme script
in ``index.html`` cannot leave a stale one behind.
style-src 'unsafe-inline': Monaco, Radix's scroll lock and the toasts insert
style-src 'unsafe-inline': CodeMirror, Radix's scroll lock and the toasts insert
<style> elements at runtime, which no hash can name. img/frame-src take any
http(s): a camera stream or website widget points wherever its dashboard
says.
+46 -15
View File
@@ -9,8 +9,17 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"@codemirror/autocomplete": "^6.20.4",
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/language": "^6.13.1",
"@codemirror/lint": "^6.9.7",
"@codemirror/search": "^6.7.2",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.15",
"@hookform/resolvers": "^5.2.2",
"@monaco-editor/react": "^4.7.0",
"@lezer/highlight": "^1.2.5",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
@@ -41,7 +50,6 @@
"elkjs": "^0.12.0",
"form-data": "4.0.6",
"lucide-react": "^0.562.0",
"monaco-editor": "^0.56.0",
"motion": "^13.0.0",
"radix-ui": "^1.6.7",
"react": "^19.1.1",
@@ -75,7 +83,6 @@
},
},
"overrides": {
"dompurify": "^3.4.17",
"handlebars": "^4.7.10",
"tar": "^7.5.7",
"zod": "^4.6.6",
@@ -131,6 +138,26 @@
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.12", "", { "os": "win32", "cpu": "x64" }, "sha512-qqGVWqNNek0KikwPZlOIoxtXgsNGsX+rgdEzgw82Re8nF02W+E2WokaQhpF5TdBh/D/RQ3TLppH+otp6ztN0lw=="],
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.4", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-yF8RhOf2SXlg00+qtupzLhrglcas61boqaONiNcbPIebHCpJocpqTBA6dyrtUrQO47hqyTdeob/3XnYilT7TaQ=="],
"@codemirror/commands": ["@codemirror/commands@6.11.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg=="],
"@codemirror/lang-json": ["@codemirror/lang-json@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/json": "^1.0.0" } }, "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ=="],
"@codemirror/lang-python": ["@codemirror/lang-python@6.2.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.3.2", "@codemirror/language": "^6.8.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/python": "^1.1.4" } }, "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw=="],
"@codemirror/language": ["@codemirror/language@6.13.1", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/streamparser": "^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" } }, "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw=="],
"@codemirror/lint": ["@codemirror/lint@6.9.7", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg=="],
"@codemirror/search": ["@codemirror/search@6.7.2", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA=="],
"@codemirror/state": ["@codemirror/state@6.7.6", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w=="],
"@codemirror/streamparser": ["@codemirror/streamparser@6.0.0", "", {}, "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w=="],
"@codemirror/view": ["@codemirror/view@6.43.15", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-HHsfpqFeHyXLs983pSRe38Xajs0vFWZQ2BuX0m7Ki8w3jZ5iD8cuR3lBz2/5/X53Dhf32SBHKh6rtSTB6tKeGw=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
@@ -211,9 +238,17 @@
"@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="],
"@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="],
"@lezer/common": ["@lezer/common@1.5.4", "", {}, "sha512-xt3Q4z64xM+xhg4M7NXqw1N//1O74bUFo5kz6fwMeC4qwxSuRdwkYSmjcqCEEKYkNw5e67f2hcNqBoNERAEU0w=="],
"@monaco-editor/react": ["@monaco-editor/react@4.7.0", "", { "dependencies": { "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA=="],
"@lezer/highlight": ["@lezer/highlight@1.2.5", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w=="],
"@lezer/json": ["@lezer/json@1.0.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ=="],
"@lezer/lr": ["@lezer/lr@1.4.11", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw=="],
"@lezer/python": ["@lezer/python@1.1.19", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ=="],
"@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.4", "", {}, "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ=="],
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
@@ -519,8 +554,6 @@
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.4.0", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="],
@@ -605,6 +638,8 @@
"cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="],
"crelt": ["crelt@1.0.7", "", {}, "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
@@ -655,8 +690,6 @@
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"dompurify": ["dompurify@3.4.17", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w=="],
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
@@ -797,8 +830,6 @@
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A=="],
@@ -873,8 +904,6 @@
"mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="],
"monaco-editor": ["monaco-editor@0.56.0", "", { "dependencies": { "dompurify": "3.4.8", "marked": "14.0.0" } }, "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg=="],
"motion": ["motion@13.0.0", "", { "dependencies": { "framer-motion": "^13.0.0", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-RYZjEpgHUCKjBNqjmUQzW93VJWLvEZGTPdKRFSqOdOl07IIMvz+WrsZmD1XXAkoFLruW/8bO1L7UF+ViQTxtLg=="],
"motion-dom": ["motion-dom@13.0.0", "", { "dependencies": { "motion-utils": "^13.0.0" } }, "sha512-Xk+SJas70uMAUIApg+m3lZDShxI3LBFHq7mFGbBKoRXc2PVPDyAKmzN64Bbzt4CZdP/CItTiJxWtn4TA0v53Ng=="],
@@ -981,10 +1010,10 @@
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"style-mod": ["style-mod@4.1.4", "", {}, "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw=="],
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
@@ -1047,6 +1076,8 @@
"vite": ["vite@7.3.7", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA=="],
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="],
+4 -1
View File
@@ -88,7 +88,9 @@ Define `process(...)` in **Code**. If the file instead contains exactly one
public function, Fluksio uses that function.
Code saves as you type. ⌘S / Ctrl-S in the code editor saves immediately;
**Publish** applies ordinary node code to the running flow. Generated code
**Publish** applies ordinary node code to the running flow. Tab indents in the
editor; Ctrl-M (⇧⌥M on macOS) lets Tab move focus on to the next control
instead. Generated code
from `fluksio sync` is read-only: edit its source repository and sync again.
Only superusers can add or edit code-running nodes. Other users can read code
@@ -148,6 +150,7 @@ Use ⌘ on macOS and Ctrl elsewhere.
|---|---|
| ⌘P / Ctrl-P | command palette |
| ⌘S / Ctrl-S | publish the flow; inside the code editor, save code |
| Ctrl-M / ⇧⌥M | inside the code editor, make Tab move focus instead of indenting |
| ⌘Z / Ctrl-Z | undo a graph edit |
| ⌘⇧Z / Ctrl-Shift-Z | redo a graph edit |
| ⌘C / Ctrl-C | copy selected nodes |
+10 -2
View File
@@ -16,8 +16,17 @@
"check": "for f in $(find src -name '*.check.ts' | sort); do echo \" → $f\"; bun run \"$f\" || exit 1; done"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.4",
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/language": "^6.13.1",
"@codemirror/lint": "^6.9.7",
"@codemirror/search": "^6.7.2",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.15",
"@hookform/resolvers": "^5.2.2",
"@monaco-editor/react": "^4.7.0",
"@lezer/highlight": "^1.2.5",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
@@ -48,7 +57,6 @@
"elkjs": "^0.12.0",
"form-data": "4.0.6",
"lucide-react": "^0.562.0",
"monaco-editor": "^0.56.0",
"motion": "^13.0.0",
"radix-ui": "^1.6.7",
"react": "^19.1.1",
+1 -1
View File
@@ -330,7 +330,7 @@ async function captureFlows(page, dir) {
await page.screenshot({ path: `${dir}/app-flows.png` })
await page.locator(".react-flow__node").first().click()
await page.waitForSelector("[data-testid=node-panel], .monaco-editor", {
await page.waitForSelector("[data-testid=node-panel], .cm-editor", {
timeout: 15000,
})
await page.waitForTimeout(1500)
+182 -37
View File
@@ -1,9 +1,71 @@
import Editor from "@monaco-editor/react"
import { useEffect, useMemo, useRef, useState } from "react"
import {
autocompletion,
closeBrackets,
closeBracketsKeymap,
completionKeymap,
} from "@codemirror/autocomplete"
import {
defaultKeymap,
history,
historyKeymap,
indentWithTab,
} from "@codemirror/commands"
import { json, jsonParseLinter } from "@codemirror/lang-json"
import { python } from "@codemirror/lang-python"
import {
bracketMatching,
foldGutter,
foldKeymap,
indentOnInput,
indentUnit,
} from "@codemirror/language"
import { linter, lintGutter } from "@codemirror/lint"
import { highlightSelectionMatches, searchKeymap } from "@codemirror/search"
import {
Annotation,
Compartment,
EditorState,
type Extension,
} from "@codemirror/state"
import {
drawSelection,
EditorView,
highlightActiveLine,
highlightActiveLineGutter,
highlightSpecialChars,
keymap,
lineNumbers,
} from "@codemirror/view"
import { useEffect, useRef } from "react"
import { useTheme } from "@/components/theme-provider"
import { Skeleton } from "@/components/ui/skeleton"
import { monacoFontFamily, setupMonaco } from "./monacoSetup"
import { codeHighlight, codeTheme, indentGuides } from "./codeTheme"
/** Marks a change that came in as the `value` prop, so it is not typed text. */
const FromProp = Annotation.define<boolean>()
// One identity per setting, shared by every editor: each state keeps its own
// content for it.
const readOnlySlot = new Compartment()
const darkSlot = new Compartment()
/** Typed text is the editor's own to select and copy, just not to change. */
const readOnlyMode = (readOnly: boolean): Extension => [
EditorState.readOnly.of(readOnly),
EditorView.contentAttributes.of({ "aria-readonly": String(readOnly) }),
]
const HINT =
"Tab indents. Press Ctrl+M (Shift+Alt+M on a Mac) to make Tab move focus instead."
/** What Python gets that JSON does not, and the other way round. */
const languageSupport = (language: "python" | "json"): Extension =>
language === "json"
? [json(), linter(jsonParseLinter()), lintGutter()]
: python()
/** Typed values the parent has not handed back yet, kept to a few. */
const PENDING = 50
/**
* The embedded code editor, Python unless asked for JSON. Highlighting and
@@ -20,7 +82,7 @@ export default function NodeEditor({
}: {
value: string
onChange: (next: string) => void
/** Focus left the editor, its find and suggest widgets included. */
/** Focus left the editor, its find bar and suggestions included. */
onBlur?: () => void
language?: "python" | "json"
/** Generated code: readable, and not this panel's to change. */
@@ -29,44 +91,127 @@ export default function NodeEditor({
autoFocus?: boolean
}) {
const { resolvedTheme } = useTheme()
const [ready, setReady] = useState(false)
const blur = useRef(onBlur)
blur.current = onBlur
const dark = resolvedTheme === "dark"
const host = useRef<HTMLDivElement>(null)
const view = useRef<EditorView | null>(null)
// What a long-lived editor must read at the time it fires, not at mount.
const latest = useRef({ value, onChange, onBlur, readOnly, dark, autoFocus })
latest.current = { value, onChange, onBlur, readOnly, dark, autoFocus }
// The parent hands typed text back as `value` a render or two later, by which
// time the editor may be a keystroke ahead. An echo is not a new value.
const typed = useRef<string[]>([])
useEffect(() => {
setupMonaco()
setReady(true)
}, [])
const parent = host.current
if (!parent) return
const now = latest.current
const editor = new EditorView({
parent,
state: EditorState.create({
doc: now.value,
extensions: [
lineNumbers(),
foldGutter(),
highlightActiveLineGutter(),
highlightActiveLine(),
highlightSpecialChars(),
history(),
drawSelection(),
indentOnInput(),
bracketMatching(),
closeBrackets(),
autocompletion(),
highlightSelectionMatches(),
indentGuides,
indentUnit.of(" "),
EditorState.tabSize.of(4),
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
indentWithTab,
]),
languageSupport(language),
codeTheme,
codeHighlight,
EditorView.contentAttributes.of({
"aria-label": language === "json" ? "JSON" : "Python code",
"aria-description": HINT,
}),
readOnlySlot.of(readOnlyMode(now.readOnly)),
darkSlot.of(EditorView.darkTheme.of(now.dark)),
EditorView.updateListener.of((update) => {
if (
!update.docChanged ||
update.transactions.some((tr) => tr.annotation(FromProp))
)
return
const text = update.state.doc.toString()
typed.current = [...typed.current, text].slice(-PENDING)
latest.current.onChange(text)
}),
],
}),
})
view.current = editor
const fontFamily = useMemo(() => (ready ? monacoFontFamily() : ""), [ready])
// Leaving the editor means focus went somewhere outside it. Moving into
// the find bar or a suggestion list is still inside.
const leave = (event: FocusEvent) => {
if (editor.dom.contains(event.relatedTarget as Node | null)) return
latest.current.onBlur?.()
}
editor.dom.addEventListener("focusout", leave)
if (now.autoFocus) editor.focus()
if (!ready) return <Skeleton className="h-full w-full rounded-md" />
return () => {
editor.dom.removeEventListener("focusout", leave)
editor.destroy()
view.current = null
typed.current = []
}
}, [language])
useEffect(() => {
const editor = view.current
if (!editor) return
const echo = typed.current.indexOf(value)
if (echo >= 0) {
typed.current = typed.current.slice(echo + 1)
return
}
if (editor.state.doc.toString() === value) return
typed.current = []
editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: value },
selection: {
anchor: Math.min(editor.state.selection.main.head, value.length),
},
annotations: FromProp.of(true),
})
}, [value])
useEffect(() => {
view.current?.dispatch({
effects: readOnlySlot.reconfigure(readOnlyMode(readOnly)),
})
}, [readOnly])
useEffect(() => {
view.current?.dispatch({
effects: darkSlot.reconfigure(EditorView.darkTheme.of(dark)),
})
}, [dark])
return (
<Editor
language={language}
theme={resolvedTheme === "dark" ? "fluksio-dark" : "fluksio-light"}
value={value}
onChange={(next) => onChange(next ?? "")}
onMount={(editor) => {
editor.onDidBlurEditorWidget(() => blur.current?.())
if (autoFocus) editor.focus()
}}
loading={<Skeleton className="h-full w-full rounded-md" />}
options={{
readOnly,
fontFamily,
fontSize: 13,
minimap: { enabled: false },
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 4,
lineNumbersMinChars: 3,
padding: { top: 12, bottom: 12 },
renderLineHighlight: "line",
overviewRulerLanes: 0,
scrollbar: { verticalScrollbarSize: 8, horizontalScrollbarSize: 8 },
}}
<div
ref={host}
data-testid="code-editor"
data-language={language}
className="h-full w-full text-base md:text-sm"
/>
)
}
+2 -2
View File
@@ -2350,8 +2350,8 @@ function PanelBody({
{CODE_LOCKED}
</p>
)}
{/* `nokey`: Monaco types through a plain div (EditContext), which
the canvas takes for itself, so Space would pan instead. */}
{/* `nokey`: the editor types through a plain div, which the canvas
takes for itself, so Space would pan instead. */}
<div className="nokey min-h-0 flex-1 overflow-hidden rounded-md border border-border">
<Suspense
fallback={
+199
View File
@@ -0,0 +1,199 @@
/**
* The code editor's look, taken from the design tokens.
*
* Everything here is a CSS variable, so the light and dark palettes follow the
* `.dark` class by themselves and nothing is duplicated from `index.css`. The
* font size is not set here either: the editor's wrapper carries the scale
* classes, and the sheet underneath inherits it.
*/
import {
HighlightStyle,
indentUnit,
syntaxHighlighting,
} from "@codemirror/language"
import { type Extension, RangeSetBuilder, type Text } from "@codemirror/state"
import {
Decoration,
type DecorationSet,
EditorView,
ViewPlugin,
type ViewUpdate,
} from "@codemirror/view"
import { tags as t } from "@lezer/highlight"
const accent = "color-mix(in srgb, var(--primary) 25%, transparent)"
export const codeTheme = EditorView.theme({
"&": {
height: "100%",
color: "var(--foreground)",
backgroundColor: "var(--card)",
},
"&.cm-focused": { outline: "none" },
".cm-scroller": {
fontFamily: "var(--font-mono)",
lineHeight: "1.6",
scrollbarWidth: "thin",
},
".cm-content": { padding: "12px 0", caretColor: "var(--primary)" },
".cm-cursor, .cm-dropCursor": { borderLeftColor: "var(--primary)" },
".cm-gutters": {
backgroundColor: "var(--card)",
color: "var(--muted-foreground)",
border: "none",
},
".cm-activeLine, .cm-activeLineGutter": { backgroundColor: "var(--accent)" },
".cm-activeLineGutter": { color: "var(--foreground)" },
".cm-foldGutter .cm-gutterElement": { cursor: "pointer" },
".cm-foldPlaceholder": {
backgroundColor: "var(--muted)",
border: "none",
color: "var(--muted-foreground)",
},
"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground":
{ backgroundColor: accent },
".cm-selectionMatch": { backgroundColor: accent },
"&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": {
backgroundColor: accent,
outline: "none",
},
".cm-searchMatch": {
backgroundColor: "var(--muted)",
outline: "1px solid var(--border)",
},
".cm-searchMatch.cm-searchMatch-selected": { backgroundColor: accent },
// The find bar and the suggestions are the editor's own widgets, drawn in
// the same surfaces as the app's menus.
".cm-panels": {
backgroundColor: "var(--card)",
color: "var(--foreground)",
},
".cm-panels.cm-panels-top": { borderBottom: "1px solid var(--border)" },
".cm-panels.cm-panels-bottom": { borderTop: "1px solid var(--border)" },
".cm-panel.cm-search input, .cm-panel.cm-search button": {
fontSize: "inherit",
},
".cm-textfield": {
backgroundColor: "transparent",
border: "1px solid var(--input)",
borderRadius: "var(--radius-sm)",
color: "var(--foreground)",
},
".cm-button": {
backgroundImage: "none",
backgroundColor: "var(--secondary)",
border: "1px solid var(--border)",
borderRadius: "var(--radius-sm)",
color: "var(--secondary-foreground)",
},
".cm-panel.cm-search label": { color: "var(--muted-foreground)" },
".cm-tooltip": {
backgroundColor: "var(--popover)",
color: "var(--popover-foreground)",
border: "1px solid var(--border)",
borderRadius: "var(--radius-sm)",
},
".cm-tooltip-autocomplete > ul > li[aria-selected]": {
backgroundColor: "var(--accent)",
color: "var(--accent-foreground)",
},
".cm-completionDetail": { color: "var(--muted-foreground)" },
".cm-lintRange-error": {
backgroundImage: "none",
textDecoration: "underline wavy var(--destructive)",
},
".cm-lintPoint-error:after": { borderBottomColor: "var(--destructive)" },
".cm-diagnostic-error": { borderLeftColor: "var(--destructive)" },
".cm-indent-guides": {
backgroundImage:
"repeating-linear-gradient(90deg, var(--border) 0 1px, transparent 1px 4ch)",
backgroundSize: "calc(var(--indent) * 1ch) 100%",
backgroundRepeat: "no-repeat",
backgroundPosition: "6px 0",
},
})
const highlight = HighlightStyle.define([
{
tag: [t.keyword, t.controlKeyword, t.definitionKeyword, t.moduleKeyword],
color: "var(--primary)",
},
{ tag: [t.bool, t.null], color: "var(--primary)" },
{ tag: [t.string, t.special(t.string)], color: "var(--status-success)" },
// A JSON key is a string to the grammar, but reads as a name.
{ tag: t.propertyName, color: "var(--foreground)" },
{ tag: t.comment, color: "var(--muted-foreground)", fontStyle: "italic" },
])
/** Colours for the grammar's tokens. */
export const codeHighlight: Extension = syntaxHighlighting(highlight)
/** Columns of leading whitespace, or null when the line holds nothing else. */
function indentOf(text: string, tab: number): number | null {
let columns = 0
for (const char of text) {
if (char === " ") columns += 1
else if (char === "\t") columns += tab - (columns % tab)
else return columns
}
return null
}
/** How far a blank line sits inside the block around it. */
function blankIndent(doc: Text, number: number, tab: number): number {
const reach = (step: 1 | -1) => {
for (
let n = number + step, i = 0;
n >= 1 && n <= doc.lines && i < 40;
n += step, i++
) {
const found = indentOf(doc.line(n).text, tab)
if (found !== null) return found
}
return 0
}
return Math.min(reach(-1), reach(1))
}
function guides(view: EditorView): DecorationSet {
const { doc } = view.state
const tab = view.state.tabSize
const unit = view.state.facet(indentUnit).length || 4
const builder = new RangeSetBuilder<Decoration>()
let done = 0
for (const { from, to } of view.visibleRanges) {
for (let pos = from; pos <= to; ) {
const line = doc.lineAt(pos)
pos = line.to + 1
if (line.number <= done) continue
done = line.number
const columns =
indentOf(line.text, tab) ?? blankIndent(doc, line.number, tab)
if (columns < unit) continue
builder.add(
line.from,
line.from,
Decoration.line({
class: "cm-indent-guides",
attributes: { style: `--indent: ${columns}` },
}),
)
}
}
return builder.finish()
}
/** A hairline at every indent step, down the lines inside a block. */
export const indentGuides: Extension = ViewPlugin.fromClass(
class {
decorations: DecorationSet
constructor(view: EditorView) {
this.decorations = guides(view)
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged)
this.decorations = guides(update.view)
}
},
{ decorations: (plugin) => plugin.decorations },
)
@@ -1,81 +0,0 @@
/**
* Monaco, self-hosted and themed from the design tokens.
*
* Only the base editor, the Python grammar and JSON (for settings holding JSON)
* are pulled in; the other seventy languages would triple the chunk for
* nothing. This module is imported by NodeEditor alone, which the panel loads
* lazily.
*/
import { loader } from "@monaco-editor/react"
import * as monaco from "monaco-editor/editor/editor.api"
// Only Python registers itself; `basic-languages` would pull in all seventy.
import "monaco-editor/languages/definitions/python/register"
import "monaco-editor/languages/features/json/register"
import editorWorker from "monaco-editor/editor/editor.worker?worker"
import jsonWorker from "monaco-editor/languages/features/json/json.worker?worker"
let configured = false
function defineThemes() {
const themes = [
{ id: "fluksio-light", base: "vs" as const },
{ id: "fluksio-dark", base: "vs-dark" as const },
]
for (const theme of themes) {
const dark = theme.id.endsWith("dark")
const background = dark ? "#1a1a1a" : "#ffffff"
const foreground = dark ? "#f5f5f5" : "#333232"
const muted = dark ? "#a3a3a3" : "#6b6b6b"
const accent = dark ? "#262626" : "#f5f5f5"
const primary = dark ? "#7ba3b8" : "#4a7189"
const success = dark ? "#87b596" : "#5e8b6d"
monaco.editor.defineTheme(theme.id, {
base: theme.base,
inherit: true,
rules: [
{ token: "keyword", foreground: primary.slice(1) },
{ token: "string", foreground: success.slice(1) },
{ token: "comment", foreground: muted.slice(1), fontStyle: "italic" },
{ token: "number", foreground: foreground.slice(1) },
// JSON names its tokens more specifically, so the base theme's
// colours would win over the generic ones above.
{ token: "string.key.json", foreground: foreground.slice(1) },
{ token: "string.value.json", foreground: success.slice(1) },
{ token: "keyword.json", foreground: primary.slice(1) },
],
colors: {
"editor.background": background,
"editor.foreground": foreground,
"editorLineNumber.foreground": muted,
"editorLineNumber.activeForeground": foreground,
"editor.lineHighlightBackground": accent,
"editorCursor.foreground": primary,
"editor.selectionBackground": `${primary}33`,
"editorIndentGuide.background1": accent,
"editorWidget.background": background,
"editorGutter.background": background,
},
})
}
}
export function setupMonaco(): typeof monaco {
if (configured) return monaco
configured = true
// Self-hosted: no CDN fetch, so the editor works on a LAN install.
self.MonacoEnvironment = {
getWorker: (_, label) =>
label === "json" ? new jsonWorker() : new editorWorker(),
}
loader.config({ monaco })
defineThemes()
return monaco
}
export const monacoFontFamily = (): string =>
getComputedStyle(document.documentElement)
.getPropertyValue("--font-mono")
.trim() || "ui-monospace, monospace"
+2 -2
View File
@@ -17,14 +17,14 @@ export type Shortcuts = Record<string, (event: KeyboardEvent) => unknown>
export function isTextEntry(target: EventTarget | null): boolean {
return Boolean(
(target as Element | null)?.closest?.(
"input, textarea, [contenteditable='true'], .monaco-editor",
"input, textarea, [contenteditable='true'], .cm-editor",
),
)
}
/** Focus is inside the embedded code editor. */
export function inCodeEditor(target: EventTarget | null): boolean {
return Boolean((target as Element | null)?.closest?.(".monaco-editor"))
return Boolean((target as Element | null)?.closest?.(".cm-editor"))
}
function chordOf(event: KeyboardEvent): string {
+84
View File
@@ -221,3 +221,87 @@ test("a port's code name is a field only when it differs from its message", asyn
await api(page, `/flows/${name}`, { method: "DELETE" })
}
})
/**
* The code editor is a contenteditable the canvas must leave alone, and what is
* typed into it reaches the node's source through the panel's own debounce.
* Typed with no key delay at all, which is the pace that used to drop letters.
*/
test("code typed into a node's editor is saved", async ({ page }) => {
const name = `test_code_${Date.now().toString(36)}`
const text = "# level, other = 1, 2"
await page.goto("/")
const saved = await api(page, `/flows/${name}`, {
method: "PUT",
data: { name, nodes: [{ id: "writer", type: "python" }], inputs: [] },
})
expect(saved.ok()).toBeTruthy()
try {
await page.goto(`/flows/${name}?node=writer`)
const editor = page.getByTestId("code-editor").locator(".cm-content")
await editor.click()
await page.keyboard.press("ControlOrMeta+End")
await page.keyboard.type(`\n${text}`)
await expect(editor).toContainText(text)
await expect
.poll(
async () =>
(await (await api(page, `/flows/${name}/nodes/writer/source`)).json())
.code,
)
.toContain(text)
} finally {
await api(page, `/flows/${name}`, { method: "DELETE" })
}
})
/**
* A JSON setting opens in a dialog that edits a draft: the node only changes
* once the text parses and Save is pressed.
*/
test("a JSON setting saves only text that parses", async ({ page }) => {
const name = `test_json_${Date.now().toString(36)}`
await page.goto("/")
const saved = await api(page, `/flows/${name}`, {
method: "PUT",
data: {
name,
nodes: [
{
id: "gate",
type: "switch",
params: { rules: [], otherwise: "" },
requires: [{ name: "level", dtype: "float" }],
provides: [{ name: "high", dtype: "float" }],
},
],
inputs: [],
},
})
expect(saved.ok()).toBeTruthy()
try {
await page.goto(`/flows/${name}?node=gate`)
await page.getByTestId("json-field").first().click()
const dialog = page.getByTestId("json-dialog")
const editor = dialog.locator(".cm-content")
const save = dialog.getByTestId("json-save")
await expect(editor).toBeFocused()
await expect(save).toBeEnabled()
// Brackets close themselves, so this leaves `[1,]`: a trailing comma.
await page.keyboard.press("ControlOrMeta+a")
await page.keyboard.press("Backspace")
await page.keyboard.type("[1,")
await expect(save).toBeDisabled()
await expect(dialog.getByRole("alert")).toBeVisible()
await expect(dialog.locator(".cm-lint-marker-error")).toBeVisible()
await page.keyboard.press("Backspace")
await expect(save).toBeEnabled()
await save.click()
await expect(dialog).toHaveCount(0)
await expect(page.getByTestId("json-field").first()).toContainText("[1]")
} finally {
await api(page, `/flows/${name}`, { method: "DELETE" })
}
})
-1
View File
@@ -6,7 +6,6 @@
"frontend"
],
"overrides": {
"dompurify": "^3.4.17",
"handlebars": "^4.7.10",
"tar": "^7.5.7",
"zod": "^4.6.6"