From f47860476c8a90ab74ffe64a15a0bbff73b7184b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Oct 2025 17:02:47 +0000 Subject: [PATCH 01/10] Initial plan From 34acb8f06571aa5b63f7ed133a6162bbe5f2d46f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Oct 2025 17:12:29 +0000 Subject: [PATCH 02/10] Migrate CodeMirror from v5 to v6 - initial implementation Co-authored-by: tsnobip <2479216+tsnobip@users.noreply.github.com> --- package-lock.json | 190 ++++++ package.json | 10 + pages/_app.js | 1 - plugins/cm6-reason-mode.js | 140 +++++ plugins/cm6-rescript-mode.js | 162 +++++ src/Playground.res | 10 +- src/components/CodeMirror.res | 762 +++++++----------------- src/components/CodeMirror.res.v5.backup | 641 ++++++++++++++++++++ src/components/CodeMirror.resi | 17 - 9 files changed, 1346 insertions(+), 587 deletions(-) create mode 100644 plugins/cm6-reason-mode.js create mode 100644 plugins/cm6-rescript-mode.js create mode 100644 src/components/CodeMirror.res.v5.backup diff --git a/package-lock.json b/package-lock.json index 7ee2e439a..1b96cae5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,9 +12,19 @@ "@babel/generator": "^7.24.7", "@babel/parser": "^7.24.7", "@babel/traverse": "^7.24.7", + "@codemirror/commands": "^6.9.0", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/language": "^6.11.3", + "@codemirror/lint": "^6.9.0", + "@codemirror/search": "^6.5.11", + "@codemirror/state": "^6.5.2", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.38.5", "@docsearch/react": "^3.9.0", "@headlessui/react": "^2.2.4", + "@lezer/highlight": "^1.2.1", "@mdx-js/loader": "^3.1.0", + "@replit/codemirror-vim": "^6.3.0", "@rescript/react": "^0.14.0-rc.1", "@rescript/webapi": "^0.1.0-experimental-0b87498", "codemirror": "^5.54.0", @@ -596,6 +606,114 @@ "node": ">=6.9.0" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.19.0.tgz", + "integrity": "sha512-61Hfv3cF07XvUxNeC3E7jhG8XNi1Yom1G0lRC936oLnlF+jrbrv8rc/J98XlYzcsAoTVupfsf5fLej1aI8kyIg==", + "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.9.0", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.9.0.tgz", + "integrity": "sha512-454TVgjhO6cMufsyyGN70rGIfJxJEjcqjBG2x2Y03Y/+Fm99d3O/Kv1QDYWuG6hvxsgmjXmBuATikIIYvERX+w==", + "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-javascript": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz", + "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==", + "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/language": { + "version": "6.11.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz", + "integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.1.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.0.tgz", + "integrity": "sha512-wZxW+9XDytH3SKvS8cQzMyQCaaazH8XL1EMHleHe00wVzsv7NBQKVW2yzEHrRhmM7ZOhVdItPbvlRBvMp9ej7A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz", + "integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", + "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", + "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.38.5", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.5.tgz", + "integrity": "sha512-SFVsNAgsAoou+BjRewMqN+m9jaztB9wCWN9RSRgePqUbq8UVlvJfku5zB2KVhLPgH/h0RLk38tvd4tGeAhygnw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", @@ -1563,6 +1681,47 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lezer/common": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz", + "integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz", + "integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^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.2", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz", + "integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^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/@mdx-js/loader": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@mdx-js/loader/-/loader-3.1.0.tgz", @@ -2308,6 +2467,19 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@replit/codemirror-vim": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@replit/codemirror-vim/-/codemirror-vim-6.3.0.tgz", + "integrity": "sha512-aTx931ULAMuJx6xLf7KQDOL7CxD+Sa05FktTDrtLaSy53uj01ll3Zf17JdKsriER248oS55GBzg0CfCTjEneAQ==", + "license": "MIT", + "peerDependencies": { + "@codemirror/commands": "6.x.x", + "@codemirror/language": "6.x.x", + "@codemirror/search": "6.x.x", + "@codemirror/state": "6.x.x", + "@codemirror/view": "6.x.x" + } + }, "node_modules/@rescript/darwin-arm64": { "version": "12.0.0-beta.6", "resolved": "https://registry.npmjs.org/@rescript/darwin-arm64/-/darwin-arm64-12.0.0-beta.6.tgz", @@ -4251,6 +4423,12 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" }, + "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", @@ -19875,6 +20053,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-mod": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz", + "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==", + "license": "MIT" + }, "node_modules/style-to-js": { "version": "1.1.16", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.16.tgz", @@ -20815,6 +20999,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "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/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/package.json b/package.json index 397f27727..0153fe153 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,19 @@ "@babel/generator": "^7.24.7", "@babel/parser": "^7.24.7", "@babel/traverse": "^7.24.7", + "@codemirror/commands": "^6.9.0", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/language": "^6.11.3", + "@codemirror/lint": "^6.9.0", + "@codemirror/search": "^6.5.11", + "@codemirror/state": "^6.5.2", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.38.5", "@docsearch/react": "^3.9.0", "@headlessui/react": "^2.2.4", + "@lezer/highlight": "^1.2.1", "@mdx-js/loader": "^3.1.0", + "@replit/codemirror-vim": "^6.3.0", "@rescript/react": "^0.14.0-rc.1", "@rescript/webapi": "^0.1.0-experimental-0b87498", "codemirror": "^5.54.0", diff --git a/pages/_app.js b/pages/_app.js index 5ddf51bcb..b210a0fcf 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -1,4 +1,3 @@ -import "codemirror/lib/codemirror.css"; import "styles/docson.css"; import "styles/main.css"; import "styles/utils.css"; diff --git a/plugins/cm6-reason-mode.js b/plugins/cm6-reason-mode.js new file mode 100644 index 000000000..a14ced393 --- /dev/null +++ b/plugins/cm6-reason-mode.js @@ -0,0 +1,140 @@ +// CodeMirror 6 Reason language mode +// Adapted from the CodeMirror 5 version + +import { StreamLanguage } from "@codemirror/language"; + +const reasonLanguage = StreamLanguage.define({ + name: "reason", + startState: () => ({ tokenize: null, context: [] }), + + token(stream, state) { + // Handle whitespace + if (stream.eatSpace()) return null; + + // Handle comments + if (stream.match("//")) { + stream.skipToEnd(); + return "comment"; + } + if (stream.match("/*")) { + state.tokenize = tokenComment; + return state.tokenize(stream, state); + } + + // Handle strings + if (stream.match(/^b?"/)) { + state.tokenize = tokenString; + return state.tokenize(stream, state); + } + + // Handle character literals + if (stream.match(/'(?:[^'\\]|\\(?:[nrt0'"]|x[\da-fA-F]{2}|u\{[\da-fA-F]{6}\}))'/)) { + return "string-2"; + } + + // Handle byte literals + if (stream.match(/b'(?:[^']|\\(?:['\\nrt0]|x[\da-fA-F]{2}))'/)) { + return "string-2"; + } + + // Handle numbers + if (stream.match(/^(?:(?:[0-9][0-9_]*)(?:(?:[Ee][+-]?[0-9_]+)|\.[0-9_]+(?:[Ee][+-]?[0-9_]+)?)(?:f32|f64)?)|(?:0(?:b[01_]+|(?:o[0-7_]+)|(?:x[0-9a-fA-F_]+))|(?:[0-9][0-9_]*))(?:u8|u16|u32|u64|i8|i16|i32|i64|isize|usize)?/)) { + return "number"; + } + + // Handle let/type definitions + if (stream.match(/^(let|type)(\s+rec)?(\s+)/)) { + stream.match(/[a-zA-Z_][a-zA-Z0-9_]*/); + return "keyword"; + } + + // Handle keywords + if (stream.match(/^(?:switch|module|as|do|else|external|for|if|in|loop|mod|pub|ref|type|while|open|open\!)\b/)) { + return "keyword"; + } + + // Handle rec keyword + if (stream.match(/^(?:rec)\b/)) { + return "keyword"; + } + + // Handle atoms + if (stream.match(/^(?:char|bool|option|int|string)\b/)) { + return "atom"; + } + + // Handle booleans + if (stream.match(/^(?:true|false)\b/)) { + return "builtin"; + } + + // Handle fun definitions + if (stream.match(/^fun\s+/)) { + stream.match(/[a-zA-Z_\|][a-zA-Z0-9_]*/); + return "keyword"; + } + + // Handle module references + if (stream.match(/^[A-Z][a-zA-Z0-9_]*\./)) { + return "namespace"; + } + + // Handle variant constructors + if (stream.match(/^[A-Z][a-zA-Z0-9_]*/)) { + return "typeName"; + } + + // Handle operators + if (stream.match(/^[-+\/*=<>!\|]+/)) { + return "operator"; + } + + // Handle identifiers + if (stream.match(/^[a-zA-Z_]\w*/)) { + return "variableName"; + } + + // Move forward if nothing matched + stream.next(); + return null; + }, + + tokenTable: { + comment: "comment", + string: "string", + "string-2": "string", + number: "number", + keyword: "keyword", + atom: "atom", + builtin: "builtin", + namespace: "namespace", + typeName: "typeName", + operator: "operator", + variableName: "variableName" + } +}); + +function tokenString(stream, state) { + let escaped = false; + let next; + while ((next = stream.next()) != null) { + if (next === '"' && !escaped) { + state.tokenize = null; + break; + } + escaped = !escaped && next === "\\"; + } + return "string"; +} + +function tokenComment(stream, state) { + while (stream.next() != null) { + if (stream.current().includes("*/")) { + state.tokenize = null; + break; + } + } + return "comment"; +} + +export { reasonLanguage }; diff --git a/plugins/cm6-rescript-mode.js b/plugins/cm6-rescript-mode.js new file mode 100644 index 000000000..6a9a77a08 --- /dev/null +++ b/plugins/cm6-rescript-mode.js @@ -0,0 +1,162 @@ +// CodeMirror 6 ReScript language mode +// Adapted from the CodeMirror 5 version + +import { StreamLanguage } from "@codemirror/language"; + +const rescriptLanguage = StreamLanguage.define({ + name: "rescript", + startState: () => ({ tokenize: null, context: [] }), + + token(stream, state) { + // Handle whitespace + if (stream.eatSpace()) return null; + + // Handle comments + if (stream.match("//")) { + stream.skipToEnd(); + return "comment"; + } + if (stream.match("/*")) { + state.tokenize = tokenComment; + return state.tokenize(stream, state); + } + + // Handle strings + if (stream.match(/^b?"/)) { + state.tokenize = tokenString; + return state.tokenize(stream, state); + } + + // Handle template/interpolation strings + if (stream.match(/^b?`/)) { + state.tokenize = tokenTemplateString; + return state.tokenize(stream, state); + } + + // Handle character literals + if (stream.match(/'(?:[^'\\]|\\(?:[nrt0'"]|x[\da-fA-F]{2}|u\{[\da-fA-F]{6}\}))'/)) { + return "string-2"; + } + + // Handle byte literals + if (stream.match(/b'(?:[^']|\\(?:['\\nrt0]|x[\da-fA-F]{2}))'/)) { + return "string-2"; + } + + // Handle numbers + if (stream.match(/^(?:(?:[0-9][0-9_]*)(?:(?:[Ee][+-]?[0-9_]+)|\.[0-9_]+(?:[Ee][+-]?[0-9_]+)?)(?:f32|f64)?)|(?:0(?:b[01_]+|(?:o[0-7_]+)|(?:x[0-9a-fA-F_]+))|(?:[0-9][0-9_]*))(?:u8|u16|u32|u64|i8|i16|i32|i64|isize|usize)?/)) { + return "number"; + } + + // Handle let/type definitions + if (stream.match(/^(let|type)(\s+rec)?(\s+)/)) { + stream.match(/[a-zA-Z_][a-zA-Z0-9_]*/); + return "keyword"; + } + + // Handle keywords + if (stream.match(/^(?:and|as|assert|catch|async|await|constraint|downto|else|exception|export|external|false|for|if|import|in|include|lazy|let|module|mutable|of|open|private|switch|to|true|try|type|when|while|with\!)\b/)) { + return "keyword"; + } + + // Handle rec, list keywords + if (stream.match(/^(?:rec|list)\b/)) { + return "keyword"; + } + + // Handle atoms + if (stream.match(/^(?:char|bool|option|int|string)\b/)) { + return "atom"; + } + + // Handle booleans + if (stream.match(/^(?:true|false)\b/)) { + return "builtin"; + } + + // Handle module references + if (stream.match(/^[A-Z][a-zA-Z0-9_]*\./)) { + return "namespace"; + } + + // Handle variant constructors + if (stream.match(/^[A-Z][a-zA-Z0-9_]*/)) { + return "typeName"; + } + + // Handle polyvars + if (stream.match(/^#[a-zA-Z0-9_"]*/)) { + return "typeName"; + } + + // Handle decorators + if (stream.match(/^@[.\w\(\)]*/)) { + return "meta"; + } + + // Handle operators + if (stream.match(/^[-+\/*=<>!\|]+/)) { + return "operator"; + } + + // Handle identifiers + if (stream.match(/^[a-zA-Z_]\w*/)) { + return "variableName"; + } + + // Move forward if nothing matched + stream.next(); + return null; + }, + + tokenTable: { + comment: "comment", + string: "string", + "string-2": "string", + number: "number", + keyword: "keyword", + atom: "atom", + builtin: "builtin", + namespace: "namespace", + typeName: "typeName", + meta: "meta", + operator: "operator", + variableName: "variableName" + } +}); + +function tokenString(stream, state) { + let escaped = false; + let next; + while ((next = stream.next()) != null) { + if (next === '"' && !escaped) { + state.tokenize = null; + break; + } + escaped = !escaped && next === "\\"; + } + return "string"; +} + +function tokenTemplateString(stream, state) { + while (stream.next() != null) { + if (stream.current().endsWith("`")) { + state.tokenize = null; + break; + } + } + return "string"; +} + +function tokenComment(stream, state) { + let maybeEnd = false; + while (stream.next() != null) { + if (stream.current().includes("*/")) { + state.tokenize = null; + break; + } + } + return "comment"; +} + +export { rescriptLanguage }; diff --git a/src/Playground.res b/src/Playground.res index 0208825ce..1491946dd 100644 --- a/src/Playground.res +++ b/src/Playground.res @@ -1,12 +1,4 @@ -%%raw(` -if (typeof window !== "undefined" && typeof window.navigator !== "undefined") { - require("codemirror/mode/javascript/javascript"); - require("codemirror/keymap/vim"); - require("codemirror/addon/scroll/simplescrollbars"); - require("plugins/cm-rescript-mode"); - require("plugins/cm-reason-mode"); -} -`) +// CodeMirror 6 - no longer need these requires since we import directly in the component open CompilerManagerHook module Api = RescriptCompilerApi diff --git a/src/components/CodeMirror.res b/src/components/CodeMirror.res index ecb80ec7a..36841a425 100644 --- a/src/components/CodeMirror.res +++ b/src/components/CodeMirror.res @@ -6,6 +6,8 @@ ! If you load this component in a Next page without using dynamic loading, you will get a SSR error ! This file is providing the core functionality and logic of our CodeMirror instances. + + Migrated to CodeMirror 6 */ module KeyMap = { @@ -23,7 +25,7 @@ module KeyMap = { } } -let useWindowWidth: unit => int = %raw(` () => { +let useWindowWidth: unit => int = %raw(`() => { const isClient = typeof window === 'object'; function getSize() { @@ -58,131 +60,8 @@ let useWindowWidth: unit => int = %raw(` () => { return windowSize.width; } return null; - } - `) - -/* The module for interacting with the imperative CodeMirror API */ -module CM = { - type t - - let errorGutterId = "errors" - - module Options = { - type t = { - theme: string, - gutters?: array, - mode: string, - lineNumbers?: bool, - readOnly?: bool, - lineWrapping?: bool, - fixedGutter?: bool, - scrollbarStyle?: string, - keyMap?: string, - } - } - - @module("codemirror") - external onMouseOver: ( - WebAPI.DOMAPI.element, - @as("mouseover") _, - ReactEvent.Mouse.t => unit, - ) => unit = "on" - - @module("codemirror") - external onMouseMove: ( - WebAPI.DOMAPI.element, - @as("mousemove") _, - ReactEvent.Mouse.t => unit, - ) => unit = "on" - - @module("codemirror") - external offMouseOver: ( - WebAPI.DOMAPI.element, - @as("mouseover") _, - ReactEvent.Mouse.t => unit, - ) => unit = "off" - - @module("codemirror") - external offMouseOut: ( - WebAPI.DOMAPI.element, - @as("mouseout") _, - ReactEvent.Mouse.t => unit, - ) => unit = "off" - - @module("codemirror") - external offMouseMove: ( - WebAPI.DOMAPI.element, - @as("mousemove") _, - ReactEvent.Mouse.t => unit, - ) => unit = "off" - - @module("codemirror") - external onMouseOut: ( - WebAPI.DOMAPI.element, - @as("mouseout") _, - ReactEvent.Mouse.t => unit, - ) => unit = "on" - - @module("codemirror") - external fromTextArea: (WebAPI.DOMAPI.element, Options.t) => t = "fromTextArea" - - @send - external setMode: (t, @as("mode") _, string) => unit = "setOption" - - @send - external getScrollerElement: t => WebAPI.DOMAPI.element = "getScrollerElement" - - @send - external getWrapperElement: t => WebAPI.DOMAPI.element = "getWrapperElement" - - @send external refresh: t => unit = "refresh" - - @send - external onChange: (t, @as("change") _, t => unit) => unit = "on" - - @send external toTextArea: t => unit = "toTextArea" - - @send external setValue: (t, string) => unit = "setValue" - - @send external getValue: t => string = "getValue" - - @send - external operation: (t, unit => unit) => unit = "operation" - - @send - external setGutterMarker: (t, int, string, WebAPI.DOMAPI.element) => unit = "setGutterMarker" - - @send external clearGutter: (t, string) => unit = "clearGutter" - - type markPos = { - line: int, - ch: int, - } - - module TextMarker = { - type t - - @send external clear: t => unit = "clear" - } - - module MarkTextOption = { - type t - - module Attr = { - type t - @obj external make: (~id: string=?, unit) => t = "" - } - - @obj - external make: (~className: string=?, ~attributes: Attr.t=?, unit) => t = "" - } - - @send - external markText: (t, markPos, markPos, MarkTextOption.t) => TextMarker.t = "markText" - - @send - external coordsChar: (t, {"top": int, "left": int}) => {"line": int, "ch": int} = "coordsChar" } +`) module Error = { type kind = [#Error | #Warning] @@ -210,324 +89,175 @@ module HoverHint = { } } -module HoverTooltip = { - type t = WebAPI.DOMAPI.element - - type state = - | Hidden - | Shown({ - el: WebAPI.DOMAPI.element, - marker: CM.TextMarker.t, - hoverHint: HoverHint.t, - hideTimer: option, - }) - - let make = () => { - let tooltip = WebAPI.Document.createElement(document, "div") - tooltip.id = "hover-tooltip" - tooltip.className = "absolute hidden select-none font-mono text-12 z-10 bg-sky-10 py-1 px-2 rounded" - tooltip - } - - let hide = (t: t) => WebAPI.DOMTokenList.add(t.classList, "hidden") - - let update = (t: t, ~top: int, ~left: int, ~text: string) => { - let t = (Obj.magic(t): WebAPI.DOMAPI.htmlElement) - t.style.left = `${left->Int.toString}px` - t.style.top = `${top->Int.toString}px` - t.classList->WebAPI.DOMTokenList.remove("hidden") - t.innerHTML = text - } - - let attach = (t: t) => WebAPI.Element.appendChild(document.body->Obj.magic, t)->ignore - - let clear = (t: t) => WebAPI.Element.remove(t->Obj.magic) -} - -// We'll keep this tooltip instance outside the -// hook, so we don't need to use a React.ref to -// keep the instance around -let tooltip = HoverTooltip.make() - -type state = {mutable marked: array, mutable hoverHints: array} - -let isSpanToken = (element: WebAPI.DOMAPI.element) => - element.tagName->String.toUpperCase === "SPAN" && - element->WebAPI.Element.getAttribute("role") !== Value("presentation") - -let useHoverTooltip = (~cmStateRef: React.ref, ~cmRef: React.ref>, ()) => { - let stateRef = React.useRef(HoverTooltip.Hidden) - - let markerRef = React.useRef(None) - - React.useEffect(() => { - tooltip->HoverTooltip.attach - - Some( - () => { - tooltip->HoverTooltip.clear - }, - ) - }, []) - - let checkIfTextMarker = (element: WebAPI.DOMAPI.element) => { - let isToken = - element.tagName->String.toUpperCase === "SPAN" && - element->WebAPI.Element.getAttribute("role") !== Value("presentation") - - isToken && RegExp.test(/CodeMirror-hover-hint-marker/, element.className) - } - - let onMouseOver = evt => { - switch cmRef.current { - | Some(cm) => - let target = (Obj.magic(ReactEvent.Mouse.target(evt)): WebAPI.DOMAPI.element) - - // If mouseover is triggered for a text marker, we don't want to trigger any logic - if checkIfTextMarker(target) { - () - } else if isSpanToken(target) { - let {hoverHints} = cmStateRef.current - let pageX = evt->ReactEvent.Mouse.pageX - let pageY = evt->ReactEvent.Mouse.pageY - - let coords = cm->CM.coordsChar({"top": pageY, "left": pageX}) - - let col = coords["ch"] - let line = coords["line"] + 1 - - let found = hoverHints->Array.find(item => { - let {start, end} = item - line >= start.line && line <= end.line && col >= start.col && col <= end.col - }) - - switch found { - | Some(hoverHint) => - tooltip->HoverTooltip.update(~top=pageY - 35, ~left=pageX, ~text=hoverHint.hint) - - let from = {CM.line: hoverHint.start.line - 1, ch: hoverHint.start.col} - let to_ = {CM.line: hoverHint.end.line - 1, ch: hoverHint.end.col} - - let markerObj = CM.MarkTextOption.make( - ~className="CodeMirror-hover-hint-marker border-b", - (), - ) - - switch stateRef.current { - | Hidden => - let marker = cm->CM.markText(from, to_, markerObj) - markerRef.current = Some(marker) - stateRef.current = Shown({ - el: target, - marker, - hoverHint, - hideTimer: None, - }) - | Shown({el, marker: prevMarker, hideTimer}) => - switch hideTimer { - | Some(timerId) => clearTimeout(timerId) - | None => () - } - CM.TextMarker.clear(prevMarker) - let marker = cm->CM.markText(from, to_, markerObj) - - stateRef.current = Shown({ - el, - marker, - hoverHint, - hideTimer: None, - }) - } - | None => () - } - } - | _ => () +// Raw JavaScript to create and manage CodeMirror 6 editor +let createEditor = %raw(` + function(config) { + const { + parent, + initialValue, + mode, + readOnly, + lineNumbers, + lineWrapping, + keyMap, + onChange, + errors, + hoverHints, + minHeight, + maxHeight + } = config; + + // Import CodeMirror 6 modules + const { EditorView, lineNumbers: lineNumbersExt, highlightActiveLine, highlightActiveLineGutter, drawSelection, dropCursor, keymap } = require("@codemirror/view"); + const { EditorState, Compartment } = require("@codemirror/state"); + const { defaultKeymap, historyKeymap, history } = require("@codemirror/commands"); + const { searchKeymap, highlightSelectionMatches } = require("@codemirror/search"); + const { syntaxHighlighting, defaultHighlightStyle, HighlightStyle, bracketMatching } = require("@codemirror/language"); + const { tags } = require("@lezer/highlight"); + const { linter, lintGutter } = require("@codemirror/lint"); + const { javascript } = require("@codemirror/lang-javascript"); + const { vim } = require("@replit/codemirror-vim"); + + // Import custom language modes + const { rescriptLanguage } = require("plugins/cm6-rescript-mode"); + const { reasonLanguage } = require("plugins/cm6-reason-mode"); + + // Setup language based on mode + let language; + if (mode === "rescript") { + language = rescriptLanguage; + } else if (mode === "reason") { + language = reasonLanguage; + } else { + language = javascript(); } - () - } - - let onMouseOut = _evt => { - switch stateRef.current { - | Shown({el, hoverHint, marker, hideTimer}) => - switch hideTimer { - | Some(timerId) => clearTimeout(timerId) - | None => () - } - - marker->CM.TextMarker.clear - let timerId = setTimeout(~handler=() => { - stateRef.current = Hidden - tooltip->HoverTooltip.hide - }, ~timeout=200) - - stateRef.current = Shown({ - el, - hoverHint, - marker, - hideTimer: Some(timerId), - }) - | _ => () + + // Setup compartments for dynamic config + const languageConf = new Compartment(); + const readOnlyConf = new Compartment(); + const keymapConf = new Compartment(); + + // Basic extensions + const extensions = [ + languageConf.of(language), + history(), + drawSelection(), + dropCursor(), + bracketMatching(), + highlightSelectionMatches(), + syntaxHighlighting(defaultHighlightStyle, {fallback: true}), + ]; + + // Add optional extensions + if (lineNumbers) { + extensions.push(lineNumbersExt()); + extensions.push(highlightActiveLineGutter()); } - } - - let onMouseMove = evt => { - switch stateRef.current { - | Shown({hoverHint}) => - let pageX = evt->ReactEvent.Mouse.pageX - let pageY = evt->ReactEvent.Mouse.pageY - - tooltip->HoverTooltip.update(~top=pageY - 35, ~left=pageX, ~text=hoverHint.hint) - () - | _ => () + + if (!readOnly) { + extensions.push(highlightActiveLine()); } - } - - (onMouseOver, onMouseOut, onMouseMove) -} - -module GutterMarker = { - // Note: this is not a React component - let make = (~rowCol: (int, int), ~kind: Error.kind, ()): WebAPI.DOMAPI.element => { - // row, col - - let marker = WebAPI.Document.createElement(document, "div") - let colorClass = switch kind { - | #Warning => "text-orange bg-orange-15" - | #Error => "text-fire bg-fire-100" + + if (lineWrapping) { + extensions.push(EditorView.lineWrapping); } - - let (row, col) = rowCol - marker.id = `gutter-marker_${row->Int.toString}-${col->Int.toString}` - marker.className = - "flex items-center justify-center text-14 text-center ml-1 h-6 font-bold hover:cursor-pointer " ++ - colorClass - - marker.innerHTML = "!" - - marker - } -} - -let _clearMarks = (state: state): unit => { - Array.forEach(state.marked, mark => mark->CM.TextMarker.clear) - state.marked = [] -} - -let extractRowColFromId = (id: string): option<(int, int)> => - switch String.split(id, "_") { - | [_, rowColStr] => - switch String.split(rowColStr, "-") { - | [rowStr, colStr] => - let row = Int.fromString(rowStr) - let col = Int.fromString(colStr) - switch (row, col) { - | (Some(row), Some(col)) => Some((row, col)) - | _ => None - } - | _ => None + + // Add readonly conf + extensions.push(readOnlyConf.of(EditorState.readOnly.of(readOnly))); + + // Add keymap + let keymapValue = keyMap === "vim" + ? [vim(), ...defaultKeymap, ...historyKeymap, ...searchKeymap] + : [...defaultKeymap, ...historyKeymap, ...searchKeymap]; + extensions.push(keymapConf.of(keymap.of(keymapValue))); + + // Add change listener + if (onChange) { + extensions.push(EditorView.updateListener.of((update) => { + if (update.docChanged) { + const newValue = update.state.doc.toString(); + onChange(newValue); + } + })); } - | _ => None - } - -module ErrorHash = Belt.Id.MakeHashable({ - type t = int - let hash = a => a - let eq = (a, b) => a == b -}) - -let updateErrors = ( - ~state: state, - ~onMarkerFocus=?, - ~onMarkerFocusLeave as _=?, - ~cm: CM.t, - errors, -) => { - Array.forEach(state.marked, mark => mark->CM.TextMarker.clear) - - let errorsMap = Belt.HashMap.make(~hintSize=Array.length(errors), ~id=module(ErrorHash)) - state.marked = [] - cm->CM.clearGutter(CM.errorGutterId) - - let wrapper = cm->CM.getWrapperElement - - Array.forEachWithIndex(errors, (e, idx) => { - open Error - - if !Belt.HashMap.has(errorsMap, e.row) { - let marker = GutterMarker.make(~rowCol=(e.row, e.column), ~kind=e.kind, ()) - Belt.HashMap.set(errorsMap, e.row, idx) - WebAPI.Element.appendChild(wrapper, marker)->ignore - - // CodeMirrors line numbers are (strangely enough) zero based - let row = e.row - 1 - let endRow = e.endRow - 1 - - cm->CM.setGutterMarker(row, CM.errorGutterId, marker) - - let from = {CM.line: row, ch: e.column} - let to_ = {CM.line: endRow, ch: e.endColumn} - - let markTextColor = switch e.kind { - | #Error => "border-fire" - | #Warning => "border-orange" - } - - cm - ->CM.markText( - from, - to_, - CM.MarkTextOption.make( - ~className="border-b border-dotted hover:cursor-pointer " ++ markTextColor, - ~attributes=CM.MarkTextOption.Attr.make( - ~id="text-marker_" ++ (Int.toString(e.row) ++ ("-" ++ (Int.toString(e.column) ++ ""))), - (), - ), - (), - ), - ) - ->Array.push(state.marked, _) - ->ignore - () + + // Add linter for errors + if (errors && errors.length > 0) { + extensions.push(linter((view) => { + return errors.map(err => ({ + from: view.state.doc.line(err.row).from + err.column, + to: view.state.doc.line(err.endRow).from + err.endColumn, + severity: err.kind === 0 ? "error" : "warning", + message: err.text + })); + })); + extensions.push(lintGutter()); } - }) - - let isMarkerId = id => - String.startsWith(id, "gutter-marker") || String.startsWith(id, "text-marker") - - WebAPI.Element.addEventListener(wrapper, Mouseover, (evt: WebAPI.UIEventsAPI.mouseEvent) => { - let target = (Obj.magic(evt.target): Null.t) - - switch target { - | Value(target) => - if isMarkerId(target.id) { - switch extractRowColFromId(target.id) { - | Some(rowCol) => Option.forEach(onMarkerFocus, cb => cb(rowCol)) - | None => () - } - } - | Null => () + + // Create editor + const state = EditorState.create({ + doc: initialValue || "", + extensions + }); + + const view = new EditorView({ + state, + parent + }); + + // Apply custom styling + if (minHeight) { + view.dom.style.minHeight = minHeight; } - }) - - WebAPI.Element.addEventListener(wrapper, Mouseout, (evt: WebAPI.UIEventsAPI.mouseEvent) => { - let target = (Obj.magic(evt.target): Null.t) - - switch target { - | Value(target) => - if isMarkerId(target.id) { - switch extractRowColFromId(target.id) { - | Some(rowCol) => Option.forEach(onMarkerFocus, cb => cb(rowCol)) - | None => () + if (maxHeight) { + view.dom.style.maxHeight = maxHeight; + view.dom.style.overflow = "auto"; + } + + // Return object with methods + return { + view, + languageConf, + readOnlyConf, + keymapConf, + setValue(value) { + view.dispatch({ + changes: {from: 0, to: view.state.doc.length, insert: value} + }); + }, + getValue() { + return view.state.doc.toString(); + }, + destroy() { + view.destroy(); + }, + setMode(newMode) { + let newLang; + if (newMode === "rescript") { + newLang = rescriptLanguage; + } else if (newMode === "reason") { + newLang = reasonLanguage; + } else { + newLang = javascript(); } + view.dispatch({ + effects: languageConf.reconfigure(newLang) + }); + }, + setKeyMap(newKeyMap) { + const newKeymapValue = newKeyMap === "vim" + ? [vim(), ...defaultKeymap, ...historyKeymap, ...searchKeymap] + : [...defaultKeymap, ...historyKeymap, ...searchKeymap]; + view.dispatch({ + effects: keymapConf.reconfigure(keymap.of(newKeymapValue)) + }); } - | Null => () - } - }) -} + }; + } +`) @react.component -let make = // props relevant for the react wrapper -( +let make = ( ~errors: array=[], ~hoverHints: array=[], ~minHeight: option=?, @@ -535,159 +265,71 @@ let make = // props relevant for the react wrapper ~className: option=?, ~style: option=?, ~onChange: option unit>=?, - ~onMarkerFocus: option<((int, int)) => unit>=?, // (row, column) - ~onMarkerFocusLeave: option<((int, int)) => unit>=?, // (row, column) + ~onMarkerFocus: option<((int, int)) => unit>=?, + ~onMarkerFocusLeave: option<((int, int)) => unit>=?, ~value: string, - // props for codemirror options - ~mode, + ~mode: string, ~readOnly=false, ~lineNumbers=true, ~scrollbarStyle="native", ~keyMap=KeyMap.Default, ~lineWrapping=false, ): React.element => { - let inputElement = React.useRef(Nullable.null) - let cmRef: React.ref> = React.useRef(None) - let cmStateRef = React.useRef({marked: [], hoverHints}) - - let windowWidth = useWindowWidth() - let (onMouseOver, onMouseOut, onMouseMove) = useHoverTooltip(~cmStateRef, ~cmRef, ()) + let containerRef = React.useRef(Nullable.null) + let editorRef: React.ref> = React.useRef(None) + let _windowWidth = useWindowWidth() + // Initialize editor React.useEffect(() => { - switch inputElement.current->Nullable.toOption { - | Some(input) => - let options = { - CM.Options.theme: "material", - gutters: [CM.errorGutterId, "CodeMirror-linenumbers"], - mode, - lineWrapping, - fixedGutter: false, - readOnly, - lineNumbers, - scrollbarStyle, - keyMap: KeyMap.toString(keyMap), - } - let cm = CM.fromTextArea(input, options) - - Option.forEach(minHeight, minHeight => { - let element = (Obj.magic(cm->CM.getScrollerElement): WebAPI.DOMAPI.htmlElement) - element.style.minHeight = minHeight + switch containerRef.current->Nullable.toOption { + | Some(parent) => + let editor = %raw(`createEditor`)({ + "parent": parent, + "initialValue": value, + "mode": mode, + "readOnly": readOnly, + "lineNumbers": lineNumbers, + "lineWrapping": lineWrapping, + "keyMap": KeyMap.toString(keyMap), + "onChange": onChange, + "errors": errors, + "hoverHints": hoverHints, + "minHeight": minHeight, + "maxHeight": maxHeight, }) - Option.forEach(maxHeight, maxHeight => { - let element = (Obj.magic(cm->CM.getScrollerElement): WebAPI.DOMAPI.htmlElement) - element.style.maxHeight = maxHeight - }) + editorRef.current = Some(editor) - Option.forEach(onChange, onValueChange => - cm->CM.onChange(instance => onValueChange(instance->CM.getValue)) + Some( + () => { + %raw(`editor.destroy()`) + }, ) - - // For some reason, injecting value with the options doesn't work - // so we need to set the initial value imperatively - cm->CM.setValue(value) - - let wrapper = cm->CM.getWrapperElement - wrapper->CM.onMouseOver(onMouseOver) - wrapper->CM.onMouseOut(onMouseOut) - wrapper->CM.onMouseMove(onMouseMove) - - cmRef.current = Some(cm) - - let cleanup = () => { - /* Console.log2("cleanup", options->CM.Options.mode); */ - CM.offMouseOver(wrapper, onMouseOver) - CM.offMouseOut(wrapper, onMouseOut) - CM.offMouseMove(wrapper, onMouseMove) - - // This will destroy the CM instance - cm->CM.toTextArea - cmRef.current = None - } - - Some(cleanup) | None => None } }, [keyMap]) + // Update value when it changes externally React.useEffect(() => { - cmStateRef.current.hoverHints = hoverHints - None - }, [hoverHints]) - - /* - Previously we did this in a useEffect([|value|) setup, but - this issues for syncing up the current editor value state - with the passed value prop. - - Example: Let's assume you press a format code button for a - piece of code that formats to the same value as the previously - passed value prop. Even though the source code looks different - in the editor (as observed via getValue) it doesn't recognize - that there is an actual change. - - By checking if the local state of the CM instance is different - to the input value, we can sync up both states accordingly - */ - switch cmRef.current { - | Some(cm) => - if CM.getValue(cm) === value { - () - } else { - let state = cmStateRef.current - cm->CM.operation(() => - updateErrors(~onMarkerFocus?, ~onMarkerFocusLeave?, ~state, ~cm, errors) - ) - cm->CM.setValue(value) - } - | None => () - } - - /* - This is required since the incoming error - array is not guaranteed to be the same instance, - so we need to make a single string that React's - useEffect is able to act on for equality checks - */ - let errorsFingerprint = Array.map(errors, e => { - let {Error.row: row, column} = e - `${row->Int.toString}-${column->Int.toString}` - })->Array.join(";") - - React.useEffect(() => { - let state = cmStateRef.current - switch cmRef.current { - | Some(cm) => - cm->CM.operation(() => - updateErrors(~onMarkerFocus?, ~onMarkerFocusLeave?, ~state, ~cm, errors) - ) + switch editorRef.current { + | Some(_editor) => + let currentValue = %raw(`_editor.getValue()`) + if currentValue !== value { + %raw(`_editor.setValue`)(value) + } | None => () } None - }, [errorsFingerprint]) + }, [value]) + // Update mode when it changes React.useEffect(() => { - let cm = Option.getOrThrow(cmRef.current) - cm->CM.setMode(mode) - None - }, [mode]) - - /* - Needed in case the className visually hides / shows - a codemirror instance, or the window has been resized. - */ - React.useEffect(() => { - switch cmRef.current { - | Some(cm) => cm->CM.refresh + switch editorRef.current { + | Some(_editor) => %raw(`_editor.setMode`)(mode) | None => () } None - }, (className, windowWidth)) + }, [mode]) -
-