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:
@@ -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.
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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"
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"frontend"
|
||||
],
|
||||
"overrides": {
|
||||
"dompurify": "^3.4.17",
|
||||
"handlebars": "^4.7.10",
|
||||
"tar": "^7.5.7",
|
||||
"zod": "^4.6.6"
|
||||
|
||||
Reference in New Issue
Block a user