diff --git a/package-lock.json b/package-lock.json index 7ee2e439a..987b24644 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,12 +12,21 @@ "@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", "docson": "^2.1.0", "escodegen": "^2.1.0", "eslint-config-next": "^13.1.1", @@ -596,6 +605,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 +1680,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 +2466,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", @@ -4121,11 +4292,6 @@ "node": ">=6" } }, - "node_modules/codemirror": { - "version": "5.65.14", - "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.14.tgz", - "integrity": "sha512-VSNugIBDGt0OU9gDjeVr6fNkoFQznrWEUdAApMlXQNbfE8gGO19776D6MwSqF/V/w/sDwonsQ0z7KmmI9guScg==" - }, "node_modules/collapse-white-space": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz", @@ -4251,6 +4417,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 +20047,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 +20993,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..ef22c9ebe 100644 --- a/package.json +++ b/package.json @@ -12,12 +12,21 @@ "@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", "docson": "^2.1.0", "escodegen": "^2.1.0", "eslint-config-next": "^13.1.1", 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..a9077f8db --- /dev/null +++ b/plugins/cm6-reason-mode.js @@ -0,0 +1,152 @@ +// 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..165910ca8 --- /dev/null +++ b/plugins/cm6-rescript-mode.js @@ -0,0 +1,174 @@ +// 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..de446cfac 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,383 @@ module HoverHint = { } } -module HoverTooltip = { - type t = WebAPI.DOMAPI.element +// CodeMirror 6 bindings +module CM6 = { + type extension + type editorState + type editorView + type compartment + type effect - type state = - | Hidden - | Shown({ - el: WebAPI.DOMAPI.element, - marker: CM.TextMarker.t, - hoverHint: HoverHint.t, - hideTimer: option, - }) + type keymapSpec - 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 + module Extension = { + type t = extension + external fromArray: array => t = "%identity" + } + + module Text = { + type t + type line + @send external toString: t => string = "toString" + @get external lines: t => int = "lines" + @send external line: (t, int) => line = "line" + @get external lineFrom: line => int = "from" + @get external lineLength: line => int = "length" } - let hide = (t: t) => WebAPI.DOMTokenList.add(t.classList, "hidden") + module EditorState = { + type createConfig = {doc: string, extensions: array} - 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 + @module("@codemirror/state") @scope("EditorState") + external create: createConfig => editorState = "create" + + module ReadOnly = { + @module("@codemirror/state") @scope(("EditorState", "readOnly")) @val + external of_: bool => extension = "of" + } + @get external doc: editorState => Text.t = "doc" } - let attach = (t: t) => WebAPI.Element.appendChild(document.body->Obj.magic, t)->ignore + module Compartment = { + @module("@codemirror/state") @new + external create: unit => compartment = "Compartment" + @send external make: (compartment, extension) => extension = "of" + @send external reconfigure: (compartment, extension) => effect = "reconfigure" + } - let clear = (t: t) => WebAPI.Element.remove(t->Obj.magic) -} + module EditorView = { + type createConfig = {state: editorState, parent: WebAPI.DOMAPI.element} + @module("@codemirror/view") @new + external create: createConfig => editorView = "EditorView" -// 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() + @send external destroy: editorView => unit = "destroy" + @get external state: editorView => editorState = "state" + @get external dom: editorView => WebAPI.DOMAPI.htmlElement = "dom" -type state = {mutable marked: array, mutable hoverHints: array} + type change = {from: int, to: int, insert: string} + type dispatchArg = {changes: change} + @send + external dispatch: (editorView, dispatchArg) => unit = "dispatch" -let isSpanToken = (element: WebAPI.DOMAPI.element) => - element.tagName->String.toUpperCase === "SPAN" && - element->WebAPI.Element.getAttribute("role") !== Value("presentation") + type dispatchEffectsArg = {effects: effect} + @send + external dispatchEffects: (editorView, dispatchEffectsArg) => unit = "dispatch" -let useHoverTooltip = (~cmStateRef: React.ref, ~cmRef: React.ref>, ()) => { - let stateRef = React.useRef(HoverTooltip.Hidden) + @module("@codemirror/view") @scope("EditorView") @val + external lineWrapping: extension = "lineWrapping" - let markerRef = React.useRef(None) + @module("@codemirror/view") + external lineNumbers: unit => extension = "lineNumbers" - React.useEffect(() => { - tooltip->HoverTooltip.attach + @module("@codemirror/view") + external highlightActiveLine: unit => extension = "highlightActiveLine" - Some( - () => { - tooltip->HoverTooltip.clear - }, - ) - }, []) + @module("@codemirror/view") + external highlightActiveLineGutter: unit => extension = "highlightActiveLineGutter" - let checkIfTextMarker = (element: WebAPI.DOMAPI.element) => { - let isToken = - element.tagName->String.toUpperCase === "SPAN" && - element->WebAPI.Element.getAttribute("role") !== Value("presentation") + @module("@codemirror/view") + external drawSelection: unit => extension = "drawSelection" - isToken && RegExp.test(/CodeMirror-hover-hint-marker/, element.className) - } + @module("@codemirror/view") + external dropCursor: unit => extension = "dropCursor" - 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 => () - } - } - | _ => () + module UpdateListener = { + type update + @get external view: update => editorView = "view" + @get external docChanged: update => bool = "docChanged" + + @module("@codemirror/view") @scope(("EditorView", "updateListener")) + external of_: (update => unit) => extension = "of" } - () } - let onMouseOut = _evt => { - switch stateRef.current { - | Shown({el, hoverHint, marker, hideTimer}) => - switch hideTimer { - | Some(timerId) => clearTimeout(timerId) - | None => () - } + module Commands = { + @module("@codemirror/commands") + external history: unit => extension = "history" - 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), - }) - | _ => () - } + @module("@codemirror/commands") @val + external defaultKeymap: array = "defaultKeymap" + + @module("@codemirror/commands") @val + external historyKeymap: array = "historyKeymap" } - let onMouseMove = evt => { - switch stateRef.current { - | Shown({hoverHint}) => - let pageX = evt->ReactEvent.Mouse.pageX - let pageY = evt->ReactEvent.Mouse.pageY + module Search = { + @module("@codemirror/search") @val + external searchKeymap: array = "searchKeymap" - tooltip->HoverTooltip.update(~top=pageY - 35, ~left=pageX, ~text=hoverHint.hint) - () - | _ => () - } + @module("@codemirror/search") + external highlightSelectionMatches: unit => extension = "highlightSelectionMatches" } - (onMouseOver, onMouseOut, onMouseMove) -} + module Language = { + @module("@codemirror/language") + external bracketMatching: unit => extension = "bracketMatching" + + type syntaxConfig = {fallback: bool} -module GutterMarker = { - // Note: this is not a React component - let make = (~rowCol: (int, int), ~kind: Error.kind, ()): WebAPI.DOMAPI.element => { - // row, col + @module("@codemirror/language") + external syntaxHighlighting: (extension, syntaxConfig) => extension = "syntaxHighlighting" + + @module("@codemirror/language") @val + external defaultHighlightStyle: extension = "defaultHighlightStyle" + } + + module Keymap = { + @module("@codemirror/view") @scope("keymap") @val + external of_: array => extension = "of" + } - let marker = WebAPI.Document.createElement(document, "div") - let colorClass = switch kind { - | #Warning => "text-orange bg-orange-15" - | #Error => "text-fire bg-fire-100" + module Lint = { + type diagnostic = { + from: int, + to: int, + severity: string, + message: string, } - 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 + type linterSource = editorView => array - marker.innerHTML = "!" + @module("@codemirror/lint") + external linter: linterSource => extension = "linter" - marker + @module("@codemirror/lint") + external lintGutter: unit => extension = "lintGutter" + } + + module JavaScript = { + @module("@codemirror/lang-javascript") + external javascript: unit => extension = "javascript" + } + + module Vim = { + @module("@replit/codemirror-vim") + external vim: unit => extension = "vim" + } + + module CustomLanguages = { + @module("../../plugins/cm6-rescript-mode.js") @val + external rescriptLanguage: extension = "rescriptLanguage" + + @module("../../plugins/cm6-reason-mode.js") @val + external reasonLanguage: extension = "reasonLanguage" } } -let _clearMarks = (state: state): unit => { - Array.forEach(state.marked, mark => mark->CM.TextMarker.clear) - state.marked = [] +type editorInstance = { + view: CM6.editorView, + languageConf: CM6.compartment, + readOnlyConf: CM6.compartment, + keymapConf: CM6.compartment, + lintConf: CM6.compartment, } -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 +type editorConfig = { + parent: WebAPI.DOMAPI.element, + initialValue: string, + mode: string, + readOnly: bool, + lineNumbers: bool, + lineWrapping: bool, + keyMap: string, + onChange: option unit>, + errors: array, + hoverHints: array, + minHeight: option, + maxHeight: option, +} + +let createLinterExtension = (errors: array): CM6.extension => { + let linterSource = (view: CM6.editorView): array => { + if Array.length(errors) === 0 { + [] + } else { + let doc = CM6.EditorView.state(view)->CM6.EditorState.doc + let diagnostics = [] + + Array.forEach(errors, err => { + try { + // Error row/endRow are 1-based (same as CodeMirror 5) + // Error column/endColumn are 0-based (same as CodeMirror 5) + let fromLine = Math.Int.max(1, Math.Int.min(err.row, CM6.Text.lines(doc))) + let toLine = Math.Int.max(1, Math.Int.min(err.endRow, CM6.Text.lines(doc))) + + let startLine = CM6.Text.line(doc, fromLine) + let endLine = CM6.Text.line(doc, toLine) + + let fromCol = Math.Int.max(0, Math.Int.min(err.column, CM6.Text.lineLength(startLine))) + let toCol = Math.Int.max(0, Math.Int.min(err.endColumn, CM6.Text.lineLength(endLine))) + + let diagnostic = { + CM6.Lint.from: CM6.Text.lineFrom(startLine) + fromCol, + to: CM6.Text.lineFrom(endLine) + toCol, + severity: err.kind === #Error ? "error" : "warning", + message: err.text, + } + + Array.push(diagnostics, diagnostic) + } catch { + | _ => Console.warn("Error creating lint marker") + } + }) + + diagnostics } - | _ => None } -module ErrorHash = Belt.Id.MakeHashable({ - type t = int - let hash = a => a - let eq = (a, b) => a == b -}) + CM6.Lint.linter(linterSource) +} -let updateErrors = ( - ~state: state, - ~onMarkerFocus=?, - ~onMarkerFocusLeave as _=?, - ~cm: CM.t, - errors, -) => { - Array.forEach(state.marked, mark => mark->CM.TextMarker.clear) +let createEditor = (config: editorConfig): editorInstance => { + // Setup language based on mode + let language = switch config.mode { + | "rescript" => CM6.CustomLanguages.rescriptLanguage + | "reason" => CM6.CustomLanguages.reasonLanguage + | _ => CM6.JavaScript.javascript() + } - let errorsMap = Belt.HashMap.make(~hintSize=Array.length(errors), ~id=module(ErrorHash)) - state.marked = [] - cm->CM.clearGutter(CM.errorGutterId) + // Setup compartments for dynamic config + let languageConf = CM6.Compartment.create() + let readOnlyConf = CM6.Compartment.create() + let keymapConf = CM6.Compartment.create() + let lintConf = CM6.Compartment.create() + + // Basic extensions + let extensions = [ + CM6.Compartment.make(languageConf, (language: CM6.extension)), + CM6.Commands.history(), + CM6.EditorView.drawSelection(), + CM6.EditorView.dropCursor(), + CM6.Language.bracketMatching(), + CM6.Search.highlightSelectionMatches(), + CM6.Language.syntaxHighlighting(CM6.Language.defaultHighlightStyle, {fallback: true}), + ] + + // Add optional extensions + if config.lineNumbers { + Array.push(extensions, CM6.EditorView.lineNumbers()) + Array.push(extensions, CM6.EditorView.highlightActiveLineGutter()) + } - let wrapper = cm->CM.getWrapperElement + if !config.readOnly { + Array.push(extensions, CM6.EditorView.highlightActiveLine()) + } - Array.forEachWithIndex(errors, (e, idx) => { - open Error + if config.lineWrapping { + Array.push(extensions, CM6.EditorView.lineWrapping) + } - 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 + // Add readonly conf + Array.push( + extensions, + CM6.Compartment.make(readOnlyConf, CM6.EditorState.ReadOnly.of_(config.readOnly)), + ) + + // Add keymap + let keymapExtension = if config.keyMap === "vim" { + let vimExt = CM6.Vim.vim() + let defaultKeymapExt = CM6.Keymap.of_(CM6.Commands.defaultKeymap) + let historyKeymapExt = CM6.Keymap.of_(CM6.Commands.historyKeymap) + let searchKeymapExt = CM6.Keymap.of_(CM6.Search.searchKeymap) + // Return vim extension combined with keymap extensions + // We need to wrap them in an array and convert to extension + /* combine extensions into a JS array value */ + [vimExt, defaultKeymapExt, historyKeymapExt, searchKeymapExt]->CM6.Extension.fromArray + } else { + let defaultKeymapExt = CM6.Keymap.of_(CM6.Commands.defaultKeymap) + let historyKeymapExt = CM6.Keymap.of_(CM6.Commands.historyKeymap) + let searchKeymapExt = CM6.Keymap.of_(CM6.Search.searchKeymap) + // Return combined keymap extensions as a JS array + [defaultKeymapExt, historyKeymapExt, searchKeymapExt]->CM6.Extension.fromArray + } + Array.push(extensions, CM6.Compartment.make(keymapConf, keymapExtension)) + + // Add change listener + switch config.onChange { + | Some(onChange) => + let updateListener = CM6.EditorView.UpdateListener.of_(update => { + if CM6.EditorView.UpdateListener.docChanged(update) { + let view = CM6.EditorView.UpdateListener.view(update) + let newValue = CM6.EditorView.state(view)->CM6.EditorState.doc->CM6.Text.toString + onChange(newValue) + } + }) + Array.push(extensions, updateListener) + | None => () + } - // CodeMirrors line numbers are (strangely enough) zero based - let row = e.row - 1 - let endRow = e.endRow - 1 + // Add linter for errors (wrap the raw linter extension in the compartment) + Array.push(extensions, CM6.Compartment.make(lintConf, createLinterExtension(config.errors))) + Array.push(extensions, CM6.Lint.lintGutter()) - cm->CM.setGutterMarker(row, CM.errorGutterId, marker) + // Create editor + let state = CM6.EditorState.create({doc: config.initialValue, extensions}) - let from = {CM.line: row, ch: e.column} - let to_ = {CM.line: endRow, ch: e.endColumn} + let view = CM6.EditorView.create({state, parent: config.parent}) - let markTextColor = switch e.kind { - | #Error => "border-fire" - | #Warning => "border-orange" - } + // Apply custom styling + let dom = CM6.EditorView.dom(view) + switch config.minHeight { + | Some(minHeight) => dom.style.minHeight = minHeight + | None => () + } + switch config.maxHeight { + | Some(maxHeight) => + dom.style.maxHeight = maxHeight + dom.style.overflow = "auto" + | None => () + } - 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 - () - } - }) + { + view, + languageConf, + readOnlyConf, + keymapConf, + lintConf, + } +} - let isMarkerId = id => - String.startsWith(id, "gutter-marker") || String.startsWith(id, "text-marker") +let editorSetValue = (instance: editorInstance, value: string): unit => { + let doc = CM6.EditorView.state(instance.view)->CM6.EditorState.doc + CM6.EditorView.dispatch( + instance.view, + {changes: {from: 0, to: CM6.Text.toString(doc)->String.length, insert: value}}, + ) +} - WebAPI.Element.addEventListener(wrapper, Mouseover, (evt: WebAPI.UIEventsAPI.mouseEvent) => { - let target = (Obj.magic(evt.target): Null.t) +let editorGetValue = (instance: editorInstance): string => { + CM6.EditorView.state(instance.view)->CM6.EditorState.doc->CM6.Text.toString +} - switch target { - | Value(target) => - if isMarkerId(target.id) { - switch extractRowColFromId(target.id) { - | Some(rowCol) => Option.forEach(onMarkerFocus, cb => cb(rowCol)) - | None => () - } - } - | Null => () - } - }) +let editorDestroy = (instance: editorInstance): unit => { + CM6.EditorView.destroy(instance.view) +} - WebAPI.Element.addEventListener(wrapper, Mouseout, (evt: WebAPI.UIEventsAPI.mouseEvent) => { - let target = (Obj.magic(evt.target): Null.t) +let editorSetMode = (instance: editorInstance, mode: string): unit => { + let language = switch mode { + | "rescript" => CM6.CustomLanguages.rescriptLanguage + | "reason" => CM6.CustomLanguages.reasonLanguage + | _ => CM6.JavaScript.javascript() + } - switch target { - | Value(target) => - if isMarkerId(target.id) { - switch extractRowColFromId(target.id) { - | Some(rowCol) => Option.forEach(onMarkerFocus, cb => cb(rowCol)) - | None => () - } - } - | Null => () - } - }) + CM6.EditorView.dispatchEffects( + instance.view, + {effects: CM6.Compartment.reconfigure(instance.languageConf, (language: CM6.extension))}, + ) +} + +let editorSetErrors = (instance: editorInstance, errors: array): unit => { + CM6.EditorView.dispatchEffects( + instance.view, + { + effects: CM6.Compartment.reconfigure(instance.lintConf, createLinterExtension(errors)), + }, + ) } @react.component -let make = // props relevant for the react wrapper -( +let make = ( ~errors: array=[], ~hoverHints: array=[], ~minHeight: option=?, @@ -535,159 +473,84 @@ 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) + // Note: onMarkerFocus/onMarkerFocusLeave are kept for backward compatibility but not yet implemented in v6 + // These callbacks were used in v5 for hovering over error markers + ~onMarkerFocus as _: option<((int, int)) => unit>=?, + ~onMarkerFocusLeave as _: option<((int, int)) => unit>=?, ~value: string, - // props for codemirror options - ~mode, + ~mode: string, ~readOnly=false, ~lineNumbers=true, - ~scrollbarStyle="native", + // Note: scrollbarStyle is deprecated in CodeMirror 6 but kept for backward compatibility (ignored) + ~scrollbarStyle as _=?, ~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"], + switch containerRef.current->Nullable.toOption { + | Some(parent) => + let config: editorConfig = { + parent, + initialValue: value, mode, - lineWrapping, - fixedGutter: false, readOnly, lineNumbers, - scrollbarStyle, + lineWrapping, keyMap: KeyMap.toString(keyMap), + onChange, + errors, + hoverHints, + minHeight, + maxHeight, } - let cm = CM.fromTextArea(input, options) - - Option.forEach(minHeight, minHeight => { - let element = (Obj.magic(cm->CM.getScrollerElement): WebAPI.DOMAPI.htmlElement) - element.style.minHeight = minHeight - }) - Option.forEach(maxHeight, maxHeight => { - let element = (Obj.magic(cm->CM.getScrollerElement): WebAPI.DOMAPI.htmlElement) - element.style.maxHeight = maxHeight - }) + let editor = createEditor(config) + editorRef.current = Some(editor) - Option.forEach(onChange, onValueChange => - cm->CM.onChange(instance => onValueChange(instance->CM.getValue)) - ) - - // 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) + Some(() => editorDestroy(editor)) | 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 = editorGetValue(editor) + if currentValue !== value { + editorSetValue(editor, value) + } | None => () } None - }, [errorsFingerprint]) + }, [value]) + // Update mode when it changes React.useEffect(() => { - let cm = Option.getOrThrow(cmRef.current) - cm->CM.setMode(mode) + switch editorRef.current { + | Some(editor) => editorSetMode(editor, mode) + | None => () + } None }, [mode]) - /* - Needed in case the className visually hides / shows - a codemirror instance, or the window has been resized. - */ + // Update errors when they change React.useEffect(() => { - switch cmRef.current { - | Some(cm) => cm->CM.refresh + switch editorRef.current { + | Some(editor) => editorSetErrors(editor, errors) | None => () } None - }, (className, windowWidth)) - -
-