diff --git a/_typos.toml b/_typos.toml index 8bc0f2fa3f..fc5e15d4a5 100644 --- a/_typos.toml +++ b/_typos.toml @@ -12,6 +12,10 @@ extend-exclude = [ "*.gradle", "plugins", "platforms", + # Preserve upstream CodeMirror source and generated patch text verbatim. + "vendor/codemirror-view", + "vendor/codemirror-language", + "vendor/patches", "src/lang/ar-ye.json", "src/lang/be-by.json", "src/lang/bn-bd.json", diff --git a/package-lock.json b/package-lock.json index 7550aea35f..d8d182e95d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "@codemirror/lang-wast": "^6.0.2", "@codemirror/lang-xml": "^6.1.0", "@codemirror/lang-yaml": "^6.1.3", - "@codemirror/language": "^6.12.4", + "@codemirror/language": "file:vendor/packages/codemirror-language-6.12.4-acode.69cac821a501233a.tgz", "@codemirror/language-data": "^6.5.2", "@codemirror/legacy-modes": "^6.5.3", "@codemirror/lint": "^6.9.7", @@ -40,7 +40,7 @@ "@codemirror/search": "^6.7.1", "@codemirror/state": "^6.7.1", "@codemirror/theme-one-dark": "^6.1.3", - "@codemirror/view": "^6.43.9", + "@codemirror/view": "file:vendor/packages/codemirror-view-6.43.9-acode.1838be74283c053e.tgz", "@deadlyjack/ajax": "^1.2.6", "@emmetio/codemirror6-plugin": "^0.4.1", "@lezer/common": "^1.5.0", @@ -100,6 +100,7 @@ "@babel/traverse": "^7.29.8", "@biomejs/biome": "2.4.11", "@lezer/generator": "^1.8.0", + "@marijn/buildtool": "1.2.0", "@rspack/cli": "^2.2.0", "@rspack/core": "^2.2.0", "@types/ace": "^0.0.52", @@ -131,6 +132,7 @@ "cordova-plugin-websocket": "file:src/plugins/websocket", "css-loader": "^7.1.4", "happy-dom": "^20.11.6", + "jpeg-js": "0.4.4", "mini-css-extract-plugin": "^2.10.2", "path-browserify": "^1.0.1", "postcss-loader": "^8.2.1", @@ -3927,8 +3929,8 @@ }, "node_modules/@codemirror/language": { "version": "6.12.4", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", - "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "resolved": "file:vendor/packages/codemirror-language-6.12.4-acode.69cac821a501233a.tgz", + "integrity": "sha512-Vz+sic0ROneIDITsWfcEod4VgGNRKyAgY5fLaPVfrbeThQvNqK86K3u/3EhdUCsQxbetKa7buMAzdtU3Cu6HKQ==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", @@ -4040,8 +4042,8 @@ }, "node_modules/@codemirror/view": { "version": "6.43.9", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.9.tgz", - "integrity": "sha512-sTuUzTpPMFebRhg6dawChoKKgndIwfjmJgKVxBefPElcU2NwQ6AFroupk0SFqEerQyZOGRfDNnSN8Dw/lMAsXw==", + "resolved": "file:vendor/packages/codemirror-view-6.43.9-acode.1838be74283c053e.tgz", + "integrity": "sha512-40f04TMhIm+oD4XYjfDkezpEIDwfeta6GFhOhypjXKga2mtFsVTH0Akk0cX/RoYd6RxRaaQPi39VE45MRVvSrQ==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.7.0", @@ -4197,6 +4199,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -4428,6 +4441,21 @@ "@lezer/lr": "^1.4.0" } }, + "node_modules/@marijn/buildtool": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@marijn/buildtool/-/buildtool-1.2.0.tgz", + "integrity": "sha512-ytEK7n4NVrq6Rhq2J+N/CcvJLl6NX0bZ1qSi3w8lxZVcI4L6Y6vj1pugcTnv+YdyFNwFuACSdDesetZmQqSy1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mocha": "^9.1.1", + "acorn": "^8.10.0", + "acorn-walk": "^8.2.0", + "rollup": "^4.8.0", + "rollup-plugin-dts": "^6.1.0", + "typescript": "^5.3.3" + } + }, "node_modules/@marijn/find-cluster-break": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", @@ -4443,6 +4471,23 @@ "@chevrotain/types": "~11.1.2" } }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -5377,6 +5422,356 @@ "dev": true, "license": "MIT" }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rspack/binding": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-2.2.0.tgz", @@ -6116,6 +6511,13 @@ "license": "MIT", "peer": true }, + "node_modules/@types/mocha": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-9.1.1.tgz", + "integrity": "sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "26.1.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", @@ -6582,6 +6984,19 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -9787,6 +10202,13 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -12459,6 +12881,118 @@ "@rolldown/binding-win32-x64-msvc": "1.2.6" } }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup-plugin-dts": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-6.5.1.tgz", + "integrity": "sha512-jODTXp3H7MK/Ur/ErtsrQ0G1GvaCmc3du+y5pNrdBMf6d7HlL2Nd/N6TkEr+f75CkUj01zEoEd7y2elH0eHi1Q==", + "dev": true, + "license": "LGPL-3.0-only", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.5.5", + "convert-source-map": "^2.0.0", + "magic-string": "^0.30.21" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/Swatinem" + }, + "optionalDependencies": { + "@babel/code-frame": "^8.0.0" + }, + "peerDependencies": { + "@typescript/typescript6": "^6", + "rollup": "^3 || ^4", + "typescript": "^4.5 || ^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "@typescript/typescript6": { + "optional": true + } + } + }, + "node_modules/rollup-plugin-dts/node_modules/@babel/code-frame": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz", + "integrity": "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/helper-validator-identifier": "^8.0.0", + "js-tokens": "^10.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/rollup-plugin-dts/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/rollup-plugin-dts/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/roughjs": { "version": "4.6.6", "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", diff --git a/package.json b/package.json index 2a2a7deae2..d1ec0ee0d7 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,13 @@ "format": "biome format --write", "check": "biome check --write", "check:admob-bundle": "node utils/scripts/checkAdmobBundle.js", + "build:codemirror-vendor": "node utils/scripts/buildCodemirrorVendor.mjs", "test": "vitest run", "typecheck": "tsc --noEmit", "updateAce": "node ./utils/updateAce.js", - "test:watch": "vitest" + "test:watch": "vitest", + "codemirror:upstream": "node utils/scripts/codemirrorUpstream.mjs", + "test:codemirror": "vitest run tests/unit/codemirrorRendering.test.ts tests/unit/codemirrorPriorityParsing.test.ts tests/unit/codemirrorVendorPackaging.test.js tests/unit/codemirrorUpstream.test.js tests/unit/mainEditorExtensions.test.ts tests/unit/keyboardScroll.test.ts tests/unit/codemirrorFocus.test.ts tests/unit/keyboardInput.test.ts tests/unit/fileLanguage.test.ts tests/unit/readOnlySelection.test.ts" }, "keywords": [ "ecosystem:cordova" @@ -74,6 +77,7 @@ "@babel/traverse": "^7.29.8", "@biomejs/biome": "2.4.11", "@lezer/generator": "^1.8.0", + "@marijn/buildtool": "1.2.0", "@rspack/cli": "^2.2.0", "@rspack/core": "^2.2.0", "@types/ace": "^0.0.52", @@ -105,6 +109,7 @@ "cordova-plugin-websocket": "file:src/plugins/websocket", "css-loader": "^7.1.4", "happy-dom": "^20.11.6", + "jpeg-js": "0.4.4", "mini-css-extract-plugin": "^2.10.2", "path-browserify": "^1.0.1", "postcss-loader": "^8.2.1", @@ -145,7 +150,7 @@ "@codemirror/lang-wast": "^6.0.2", "@codemirror/lang-xml": "^6.1.0", "@codemirror/lang-yaml": "^6.1.3", - "@codemirror/language": "^6.12.4", + "@codemirror/language": "file:vendor/packages/codemirror-language-6.12.4-acode.69cac821a501233a.tgz", "@codemirror/language-data": "^6.5.2", "@codemirror/legacy-modes": "^6.5.3", "@codemirror/lint": "^6.9.7", @@ -153,7 +158,7 @@ "@codemirror/search": "^6.7.1", "@codemirror/state": "^6.7.1", "@codemirror/theme-one-dark": "^6.1.3", - "@codemirror/view": "^6.43.9", + "@codemirror/view": "file:vendor/packages/codemirror-view-6.43.9-acode.1838be74283c053e.tgz", "@deadlyjack/ajax": "^1.2.6", "@emmetio/codemirror6-plugin": "^0.4.1", "@lezer/common": "^1.5.0", @@ -204,11 +209,11 @@ "overrides": { "@codemirror/autocomplete": "^6.20.3", "@codemirror/commands": "^6.11.0", - "@codemirror/language": "^6.12.4", + "@codemirror/language": "$@codemirror/language", "@codemirror/lint": "^6.9.7", "@codemirror/search": "^6.7.1", "@codemirror/state": "^6.7.1", - "@codemirror/view": "^6.43.9", + "@codemirror/view": "$@codemirror/view", "tar": "^7.5.22" }, "browserslist": "cover 100%", diff --git a/src/cm/editorReadOnly.ts b/src/cm/editorReadOnly.ts index 927b7a8e73..8298c084ec 100644 --- a/src/cm/editorReadOnly.ts +++ b/src/cm/editorReadOnly.ts @@ -2,11 +2,12 @@ import { Compartment, EditorSelection, EditorState, - Transaction, type Extension, type SelectionRange, + Transaction, } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; +import { showEditorKeyboard } from "./keyboardInput"; interface PointerTapSnapshot { pointerId: number; @@ -66,6 +67,10 @@ const readOnlyFocusGuard = EditorView.domEventHandlers({ const readOnlyCursor = [ EditorView.editorAttributes.of({ class: "cm-read-only" }), EditorView.theme({ + "&.cm-read-only .cm-content": { + userSelect: "text", + WebkitUserSelect: "text", + }, "&.cm-read-only > .cm-scroller > .cm-cursorLayer": { animation: "none", }, @@ -133,6 +138,7 @@ export function focusEditorIfEditable(view: EditorView): boolean { return false; } view.focus(); + showEditorKeyboard(view); return true; } diff --git a/src/cm/fileLanguage.ts b/src/cm/fileLanguage.ts new file mode 100644 index 0000000000..1227f2cf83 --- /dev/null +++ b/src/cm/fileLanguage.ts @@ -0,0 +1,68 @@ +import type { Extension } from "@codemirror/state"; +import type { LanguageExtensionProvider } from "./modelist"; + +type FileLanguage = { + currentLanguageExtension?: LanguageExtensionProvider | null; +}; +type PreparedLanguage = { + provider: LanguageExtensionProvider; + value: Extension | Promise; +}; +const prepared = new WeakMap(); + +/** Start only the restored foreground tab's language, alongside file I/O. */ +export function preloadFileLanguage(file: FileLanguage): Promise { + const provider = file.currentLanguageExtension; + if (typeof provider !== "function") return Promise.resolve(); + let entry = prepared.get(file); + if (!entry || entry.provider !== provider) { + try { + entry = { provider, value: provider() }; + prepared.set(file, entry); + } catch (error) { + return Promise.reject(error); + } + } + const current = entry; + return Promise.resolve(current.value).then( + (value) => { + current.value = value; + }, + (error) => { + if (prepared.get(file) === current) prepared.delete(file); + throw error; + }, + ); +} + +/** Briefly await highlighting, then let text open with language loading in the background. */ +export async function waitForFileLanguage(file: FileLanguage): Promise { + const entry = prepared.get(file); + if (entry && entry.provider === file.currentLanguageExtension) { + let timer: ReturnType | undefined; + try { + await Promise.race([ + entry.value, + new Promise((resolve) => { + timer = setTimeout(resolve, 250); + }), + ]); + } catch { + /* A failed language must not prevent opening the file. */ + } finally { + clearTimeout(timer); + } + } +} + +/** Consume a startup preparation once; later settings changes use the provider again. */ +export function getFileLanguageExtension( + file: FileLanguage, +): Extension | Promise { + const entry = prepared.get(file); + if (entry && entry.provider === file.currentLanguageExtension) { + prepared.delete(file); + return entry.value; + } + return file.currentLanguageExtension?.() || []; +} diff --git a/src/cm/keyboardInput.ts b/src/cm/keyboardInput.ts new file mode 100644 index 0000000000..04ae27a797 --- /dev/null +++ b/src/cm/keyboardInput.ts @@ -0,0 +1,151 @@ +import { EditorView, ViewPlugin } from "@codemirror/view"; + +interface VirtualKeyboardControl { + show(): void; +} + +function keyboardControl(view: EditorView): VirtualKeyboardControl | null { + const navigator = view.dom.ownerDocument.defaultView?.navigator as + | (Navigator & { + virtualKeyboard?: VirtualKeyboardControl; + }) + | undefined; + if (!navigator) return null; + return /Android\b/.test(navigator.userAgent) && + "virtualKeyboardPolicy" in view.contentDOM && + typeof navigator.virtualKeyboard?.show === "function" + ? navigator.virtualKeyboard + : null; +} + +// Android may open the IME on finger-down even though DOM focus never changed. +// Keep keyboard visibility independent of focus and virtualized DOM selection. +// https://developer.chrome.com/docs/web-platform/virtual-keyboard#the-virtual-keyboard-policy +class KeyboardInput { + private pointer: { + id: number; + x: number; + y: number; + at: number; + moved: boolean; + } | null = null; + private tap = false; + + constructor(private view: EditorView) { + if (!keyboardControl(view)) return; + view.contentDOM.addEventListener("pointerdown", this.onDown, true); + view.dom.ownerDocument.addEventListener("pointermove", this.onMove, true); + view.dom.ownerDocument.addEventListener("pointerup", this.onUp, true); + view.dom.ownerDocument.addEventListener( + "pointercancel", + this.onCancel, + true, + ); + view.contentDOM.addEventListener("click", this.onClick); + } + + private isContent(target: EventTarget | null) { + if (!(target instanceof Element) || !this.view.contentDOM.contains(target)) + return false; + return !target.closest( + "input, textarea, select, button, a, [contenteditable=false]", + ); + } + + private onDown = (event: PointerEvent) => { + this.tap = false; + this.pointer = + event.isPrimary && event.button === 0 && this.isContent(event.target) + ? { + id: event.pointerId, + x: event.clientX, + y: event.clientY, + at: event.timeStamp, + moved: false, + } + : null; + }; + private onMove = (event: PointerEvent) => { + const pointer = this.pointer; + if ( + pointer?.id === event.pointerId && + Math.hypot(event.clientX - pointer.x, event.clientY - pointer.y) >= 6 + ) + pointer.moved = true; + }; + private onUp = (event: PointerEvent) => { + const pointer = this.pointer; + if (!pointer || pointer.id !== event.pointerId) return; + this.onMove(event); + this.tap = + !pointer.moved && + event.timeStamp - pointer.at <= 500 && + this.isContent(event.target); + this.pointer = null; + }; + private onCancel = () => { + this.pointer = null; + this.tap = false; + }; + private onClick = (event: MouseEvent) => { + const deliberate = this.tap || event.detail === 0; // Accessibility activation. + this.tap = false; + if ( + !keyboardControl(this.view) || + !deliberate || + event.defaultPrevented || + !this.isContent(event.target) + ) + return; + if ( + !this.view.state.readOnly && + this.view.state.facet(EditorView.editable) + ) { + if (!this.view.hasFocus) this.view.focus(); + this.show(); + } + }; + + show() { + if ( + this.view.hasFocus && + !this.view.state.readOnly && + this.view.state.facet(EditorView.editable) + ) + keyboardControl(this.view)?.show(); + } + + destroy() { + this.view.contentDOM.removeEventListener("pointerdown", this.onDown, true); + this.view.dom.ownerDocument.removeEventListener( + "pointermove", + this.onMove, + true, + ); + this.view.dom.ownerDocument.removeEventListener( + "pointerup", + this.onUp, + true, + ); + this.view.dom.ownerDocument.removeEventListener( + "pointercancel", + this.onCancel, + true, + ); + this.view.contentDOM.removeEventListener("click", this.onClick); + } +} + +const keyboardInputPlugin = ViewPlugin.fromClass(KeyboardInput); + +export const keyboardInput = [ + EditorView.contentAttributes.of((view) => + keyboardControl(view) ? { virtualkeyboardpolicy: "manual" } : null, + ), + keyboardInputPlugin, +]; + +/** Call after an explicit editing command focuses the editor, never on resize/scroll. */ +export function showEditorKeyboard(view: EditorView) { + view.plugin(keyboardInputPlugin)?.show(); +} diff --git a/src/cm/keyboardScroll.ts b/src/cm/keyboardScroll.ts new file mode 100644 index 0000000000..326868c3b7 --- /dev/null +++ b/src/cm/keyboardScroll.ts @@ -0,0 +1,140 @@ +import { type EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; + +// Browsing survives keyboard open/close. Only an explicit selection or edit +// resumes following the caret. Keep this state per view, including split panes. +class KeyboardScroll { + browsing = false; + private touchStart: { x: number; y: number } | null = null; + + constructor(private view: EditorView) { + view.scrollDOM.addEventListener("touchstart", this.onTouchStart, { + passive: true, + }); + view.scrollDOM.addEventListener("touchmove", this.onTouchMove, { + passive: true, + }); + view.scrollDOM.addEventListener("touchend", this.onTouchEnd, { + passive: true, + }); + view.scrollDOM.addEventListener("touchcancel", this.onTouchEnd, { + passive: true, + }); + view.scrollDOM.addEventListener("wheel", this.onWheel, { passive: true }); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.selectionSet) this.browsing = false; + } + + private onTouchStart = (event: TouchEvent) => { + const touch = event.touches.length === 1 ? event.touches[0] : null; + this.touchStart = touch ? { x: touch.clientX, y: touch.clientY } : null; + }; + private onTouchMove = (event: TouchEvent) => { + if (!this.touchStart || event.touches.length !== 1) return; + const touch = event.touches[0]; + if ( + Math.max( + Math.abs(touch.clientX - this.touchStart.x), + Math.abs(touch.clientY - this.touchStart.y), + ) >= 6 + ) { + this.browsing = true; + } + }; + private onTouchEnd = () => { + this.touchStart = null; + }; + private onWheel = (event: WheelEvent) => { + if (event.deltaX || event.deltaY) this.browsing = true; + }; + + destroy() { + this.view.scrollDOM.removeEventListener("touchstart", this.onTouchStart); + this.view.scrollDOM.removeEventListener("touchmove", this.onTouchMove); + this.view.scrollDOM.removeEventListener("touchend", this.onTouchEnd); + this.view.scrollDOM.removeEventListener("touchcancel", this.onTouchEnd); + this.view.scrollDOM.removeEventListener("wheel", this.onWheel); + } +} + +export const keyboardScroll = ViewPlugin.fromClass(KeyboardScroll); + +export function markKeyboardScrollBrowsing(view: EditorView) { + const policy = view.plugin(keyboardScroll); + if (policy) policy.browsing = true; +} + +type KeyboardTransition = + | "keyboardShowStart" + | "keyboardShow" + | "keyboardHideStart" + | "keyboardHide"; +interface KeyboardEvents { + on(name: KeyboardTransition, callback: () => void): void; + off(name: KeyboardTransition, callback: () => void): void; +} + +export function registerKeyboardCursorReveal(options: { + events: KeyboardEvents; + getView: () => EditorView | null; + canReveal: () => boolean; +}) { + const { events, getView, canReveal } = options; + const measureKey = {}; + let disposed = false; + let generation = 0; + function show() { + const current = ++generation; + const view = getView(); + if (!view) return; + const policy = view.plugin(keyboardScroll); + const allowed = () => + !disposed && + current === generation && + getView() === view && + view.hasFocus && + view.plugin(keyboardScroll) === policy && + !policy?.browsing && + canReveal(); + if (!allowed()) return; + view.requestMeasure({ + key: measureKey, + read: () => { + if (!allowed()) return null; + const caret = view.coordsAtPos(view.state.selection.main.head); + if (!caret) return null; + const scroller = view.scrollDOM; + const rect = scroller.getBoundingClientRect(); + const top = rect.top + 16; + const bottom = rect.top + scroller.clientHeight - 24; + const delta = + caret.top < top + ? caret.top - top + : caret.bottom > bottom + ? caret.bottom - bottom + : 0; + return delta ? Math.max(0, scroller.scrollTop + delta) : null; + }, + write: (top) => { + if (top != null && allowed()) view.scrollDOM.scrollTop = top; + }, + }); + } + events.on("keyboardShowStart", show); + events.on("keyboardShow", show); + const hide = () => { + generation++; + }; + events.on("keyboardHideStart", hide); + events.on("keyboardHide", hide); + // Closing the keyboard increases the visible area. It must never pull the + // viewport back to an old caret position. + return () => { + disposed = true; + events.off("keyboardShowStart", show); + events.off("keyboardShow", show); + events.off("keyboardHideStart", hide); + events.off("keyboardHide", hide); + }; +} diff --git a/src/cm/mainEditorExtensions.ts b/src/cm/mainEditorExtensions.ts index f825898d4a..4c528df4cc 100644 --- a/src/cm/mainEditorExtensions.ts +++ b/src/cm/mainEditorExtensions.ts @@ -1,5 +1,8 @@ +import { viewportPriorityParsing } from "@codemirror/language"; import type { Extension } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; +import { keyboardInput } from "./keyboardInput"; +import { keyboardScroll } from "./keyboardScroll"; import searchMatchHighlighter from "./searchMatchHighlighter"; interface MainEditorExtensionOptions { @@ -14,6 +17,8 @@ interface MainEditorExtensionOptions { quickToolsModifierInputExtension?: Extension; searchExtension?: Extension; readOnlyExtension?: Extension; + /** Override with [] to compare upstream rendering without rebuilding packages. */ + renderingExtensions?: readonly Extension[]; optionExtensions?: Extension[]; } @@ -27,11 +32,19 @@ export const fixedHeightTheme = EditorView.theme({ ".cm-scroller": { height: "100%", overflow: "auto", - willChange: "transform", - contentVisibility: "auto", }, }); +export const renderingPerformanceExtensions: Extension[] = [ + EditorView.viewportBuffer.of({ maxAhead: 2, settleDelay: 120 }), + EditorView.controlledTouchScroll.of({ + maxAhead: 2, + settleDelay: 120, + waitForRendering: false, + }), + viewportPriorityParsing({ sliceMs: 4, approximate: "outer-language" }), +]; + export function createMainEditorExtensions( options: MainEditorExtensionOptions = {}, ): Extension[] { @@ -47,6 +60,10 @@ export function createMainEditorExtensions( pushExtension(extensions, options.commandKeymapExtension); pushExtension(extensions, options.themeExtension); extensions.push(fixedHeightTheme); + extensions.push(keyboardScroll, keyboardInput); + extensions.push( + ...(options.renderingExtensions ?? renderingPerformanceExtensions), + ); pushExtension(extensions, options.pointerCursorVisibilityExtension); pushExtension(extensions, options.shiftClickSelectionExtension); pushExtension(extensions, options.multiCursorSelectionExtension); diff --git a/src/cm/touchSelectionMenu.js b/src/cm/touchSelectionMenu.js index b017acb1fb..15496e65ef 100644 --- a/src/cm/touchSelectionMenu.js +++ b/src/cm/touchSelectionMenu.js @@ -1,11 +1,6 @@ import { LSPPlugin } from "@codemirror/lsp-client"; import { EditorSelection } from "@codemirror/state"; -import { - focusEditorIfEditable, - placeReadOnlyCursor, - resolveReadOnlyContextSelection, - shouldCommitReadOnlyTap, -} from "cm/editorReadOnly"; +import { focusEditorIfEditable } from "cm/editorReadOnly"; import { bindSelectionMenuButton, filterSelectionMenuItems, @@ -186,7 +181,6 @@ class TouchSelectionMenuController { #isScrolling = false; #isPointerInteracting = false; #pointerSelectionSession = null; - #readOnlyTapSession = null; #pendingPointerSelectionClick = null; #menuActive = false; #menuRequested = false; @@ -219,7 +213,6 @@ class TouchSelectionMenuController { const root = this.#view.dom; root.addEventListener("contextmenu", this.#onContextMenu, true); document.addEventListener("pointerdown", this.#onGlobalPointerDown, true); - document.addEventListener("pointermove", this.#onGlobalPointerMove, true); document.addEventListener("pointerup", this.#onGlobalPointerUp, true); document.addEventListener("pointercancel", this.#onGlobalPointerUp, true); @@ -251,11 +244,6 @@ class TouchSelectionMenuController { true, ); document.removeEventListener("pointerup", this.#onGlobalPointerUp, true); - document.removeEventListener( - "pointermove", - this.#onGlobalPointerMove, - true, - ); document.removeEventListener( "pointercancel", this.#onGlobalPointerUp, @@ -265,7 +253,6 @@ class TouchSelectionMenuController { cancelAnimationFrame(this.#stateSyncRaf); this.#stateSyncRaf = 0; this.#pointerSelectionSession = null; - this.#readOnlyTapSession = null; this.#pendingPointerSelectionClick = null; this.#tooltipObserver?.disconnect(); this.#hideMenu(true); @@ -275,7 +262,6 @@ class TouchSelectionMenuController { this.#enabled = !!enabled; if (this.#enabled) return; this.#pointerSelectionSession = null; - this.#readOnlyTapSession = null; this.#pendingPointerSelectionClick = null; this.#dismissedSelection = null; this.#menuRequested = false; @@ -320,7 +306,6 @@ class TouchSelectionMenuController { if (this.#isScrolling) return; this.#clearMenuShowTimer(); this.#isScrolling = true; - this.#cancelReadOnlyTap(); this.#hideMenu(); } @@ -332,7 +317,6 @@ class TouchSelectionMenuController { onStateChanged(meta = {}) { if (!this.#enabled) return; - if (meta.selectionChanged) this.#cancelReadOnlyTap(); if ( meta.selectionChanged && this.#dismissedSelection !== this.#selectionSignature() @@ -356,7 +340,6 @@ class TouchSelectionMenuController { onSessionChanged() { if (!this.#enabled) return; this.#pointerSelectionSession = null; - this.#readOnlyTapSession = null; this.#pendingPointerSelectionClick = null; this.#dismissedSelection = null; this.#menuRequested = false; @@ -369,17 +352,9 @@ class TouchSelectionMenuController { #onContextMenu = (event) => { if (!this.#enabled) return; if (this.#isIgnoredPointerTarget(event.target)) return; - this.#cancelReadOnlyTap(); - if (this.#isReadOnly()) { - const pos = this.#safePosAtCoords(event.clientX, event.clientY); - if (pos != null) { - const range = resolveReadOnlyContextSelection(this.#view.state, pos); - this.#view.dispatch({ - selection: EditorSelection.create([range]), - userEvent: "select.pointer", - }); - } - } + // Read-only text uses the browser's selection handles and Copy/Select All + // menu. Preventing this event can suppress Android selection entirely. + if (this.#isReadOnly()) return; event.preventDefault(); event.stopPropagation(); this.#dismissedSelection = null; @@ -390,47 +365,32 @@ class TouchSelectionMenuController { #onGlobalPointerDown = (event) => { const target = event.target; if (this.$menu.contains(target)) { - this.#readOnlyTapSession = null; return; } if (this.#isIgnoredPointerTarget(target)) { this.#pointerSelectionSession = null; - this.#readOnlyTapSession = null; return; } if (target instanceof Node && this.#view.dom.contains(target)) { this.#dismissedSelection = null; this.#capturePointerSelection(event); - this.#captureReadOnlyTap(event); + // Native read-only taps must not be replaced by cursor placement that + // clears document.getSelection() and dismisses selection handles. this.#isPointerInteracting = true; this.#clearMenuShowTimer(); return; } this.#pointerSelectionSession = null; - this.#readOnlyTapSession = null; this.#isPointerInteracting = false; this.#menuRequested = false; this.#hideMenu(); }; - #onGlobalPointerMove = (event) => { - const session = this.#readOnlyTapSession; - if (!session || session.pointerId !== event.pointerId) return; - if ( - Math.hypot(event.clientX - session.x, event.clientY - session.y) > - TAP_MAX_DISTANCE - ) { - this.#cancelReadOnlyTap(); - } - }; - #onGlobalPointerUp = (event) => { if (event.type === "pointerup") { this.#commitPointerSelection(event); - this.#commitReadOnlyTap(event); } else { this.#pointerSelectionSession = null; - this.#readOnlyTapSession = null; } if (!this.#isPointerInteracting) return; this.#isPointerInteracting = false; @@ -445,76 +405,6 @@ class TouchSelectionMenuController { this.#hideMenu(); }; - #captureReadOnlyTap(event) { - this.#readOnlyTapSession = null; - if (!this.#enabled || !this.#isReadOnly()) return; - if (!(event.isTrusted && event.isPrimary)) return; - if (typeof event.button === "number" && event.button !== 0) return; - if (this.#canExtendSelection(event) || this.#canAddSelectionRange(event)) { - return; - } - - this.#readOnlyTapSession = { - pointerId: event.pointerId, - x: event.clientX, - y: event.clientY, - timeStamp: event.timeStamp, - isPrimary: event.isPrimary, - button: event.button, - selection: this.#view.state.selection, - cancelled: false, - }; - } - - #cancelReadOnlyTap() { - if (this.#readOnlyTapSession) { - this.#readOnlyTapSession.cancelled = true; - } - } - - #commitReadOnlyTap(event) { - const session = this.#readOnlyTapSession; - this.#readOnlyTapSession = null; - if (!session || !this.#enabled || !this.#isReadOnly()) return false; - if (!this.#view.state.selection.eq(session.selection)) return false; - if ( - !shouldCommitReadOnlyTap( - session, - { - pointerId: event.pointerId, - x: event.clientX, - y: event.clientY, - timeStamp: event.timeStamp, - isPrimary: event.isPrimary, - button: event.button, - }, - { maxDelay: TAP_MAX_DELAY, maxDistance: TAP_MAX_DISTANCE }, - ) - ) { - return false; - } - const target = event.target; - if (!(target instanceof Node) || !this.#view.dom.contains(target)) { - return false; - } - if (this.#isIgnoredPointerTarget(target)) return false; - - const pos = this.#safePosAtCoords(event.clientX, event.clientY); - if (pos == null) return false; - if (!placeReadOnlyCursor(this.#view, pos)) return false; - this.#menuRequested = false; - this.#clearMenuShowTimer(); - this.#hideMenu(true); - try { - document.getSelection()?.removeAllRanges(); - } catch (error) { - console.warn("Failed to clear native read-only selection.", error); - } - event.preventDefault(); - focusEditorIfEditable(this.#view); - return true; - } - #capturePointerSelection(event) { if (!this.#canHandlePointerSelection(event)) { this.#pointerSelectionSession = null; @@ -610,6 +500,7 @@ class TouchSelectionMenuController { } #shouldShowMenu() { + if (this.#isReadOnly()) return false; if (this.#isScrolling || this.#isPointerInteracting) return false; if (!this.#view.hasFocus && !this.#isReadOnly()) return false; if ( diff --git a/src/lib/editorFile.js b/src/lib/editorFile.js index e638cdb84b..df82de402a 100644 --- a/src/lib/editorFile.js +++ b/src/lib/editorFile.js @@ -6,6 +6,7 @@ import { reconfigureEditorReadOnly, } from "cm/editorReadOnly"; import { clearSelection, getDocText } from "cm/editorUtils"; +import { waitForFileLanguage } from "cm/fileLanguage"; import { getMode, getModeForPath } from "cm/modelist"; import quickTools from "components/quickTools"; import Sidebar from "components/sidebar"; @@ -1891,6 +1892,7 @@ export default class EditorFile { } } + await waitForFileLanguage(this); const isUnsaved = this.isUnsaved; this.markChanged = false; this.session = restoreSessionSelection( diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index 3ce9068465..6b4df9c72c 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -44,6 +44,11 @@ import { focusEditorIfEditable, reconfigureEditorReadOnly, } from "cm/editorReadOnly"; +import { getFileLanguageExtension } from "cm/fileLanguage"; +import { + markKeyboardScrollBrowsing, + registerKeyboardCursorReveal, +} from "cm/keyboardScroll"; import { handleLineNumberClick } from "cm/lineNumberSelection"; import localWordCompletions, { localWordCompletionSource, @@ -736,7 +741,8 @@ async function EditorManager($header, $body) { function activatePane(pane, options = {}) { if (!pane) return null; if (activePane === pane) { - if (options.focusEditor !== false) pane.editor?.focus?.(); + if (options.focusEditor !== false && pane.editor) + focusEditorIfEditable(pane.editor); return pane; } @@ -747,7 +753,8 @@ async function EditorManager($header, $body) { } setActivePane(pane, { emitSwitch: false }); - if (options.focusEditor !== false) pane.editor?.focus?.(); + if (options.focusEditor !== false && pane.editor) + focusEditorIfEditable(pane.editor); return pane; } @@ -825,34 +832,11 @@ async function EditorManager($header, $body) { }; function registerSoftKeyboardCursorReveal() { - const shouldRevealCursor = () => { - const view = editor; - if (!view || manager?.activeFile?.type !== "editor") return false; - const activeElement = document.activeElement; - return ( - view.contentDOM === activeElement || - view.contentDOM.contains(activeElement) - ); - }; - - keyboardHandler.on("keyboardShowStart", () => { - requestAnimationFrame(() => { - if (!shouldRevealCursor()) return; - if (isCursorRevealSuppressed()) return; - scrollCursorIntoView({ behavior: "instant" }); - }); - }); - keyboardHandler.on("keyboardShow", () => { - if (!shouldRevealCursor()) return; - if (isCursorRevealSuppressed()) return; - scrollCursorIntoView(); - }); - keyboardHandler.on("keyboardHide", () => { - requestAnimationFrame(() => { - if (!shouldRevealCursor()) return; - if (isCursorRevealSuppressed()) return; - scrollCursorIntoView({ behavior: "instant" }); - }); + registerKeyboardCursorReveal({ + events: keyboardHandler, + getView: () => editor, + canReveal: () => + manager?.activeFile?.type === "editor" && !isCursorRevealSuppressed(), }); } @@ -2743,7 +2727,7 @@ async function EditorManager($header, $body) { let result; try { - result = langExtFn(); + result = getFileLanguageExtension(file); } catch (_) { markLanguageReady(file, languageSignature, true); return []; @@ -4323,8 +4307,6 @@ async function EditorManager($header, $body) { // Attach native DOM event listeners directly to the editor's contentDOM const contentDOM = editor.contentDOM; - let readOnlyNativeContextActive = false; - let readOnlyNativeContextResetTimer = null; const isReadOnlyEditor = () => { const activeFile = pane.activeFile; return ( @@ -4344,46 +4326,18 @@ async function EditorManager($header, $body) { keyboardHandler.off("keyboardHide", pendingKeyboardHideBlur); pendingKeyboardHideBlur = null; }; - const releaseReadOnlyNativeContext = () => { - clearTimeout(readOnlyNativeContextResetTimer); - readOnlyNativeContextResetTimer = null; - if (!readOnlyNativeContextActive) return; - readOnlyNativeContextActive = false; - setNativeContextMenuDisabled(false); - }; - const scheduleReadOnlyNativeContextRelease = () => { - if (!readOnlyNativeContextActive) return; - clearTimeout(readOnlyNativeContextResetTimer); - readOnlyNativeContextResetTimer = setTimeout( - releaseReadOnlyNativeContext, - 800, - ); - }; function handleContentPointerDown() { - if (!isReadOnlyEditor()) return; - clearTimeout(readOnlyNativeContextResetTimer); - readOnlyNativeContextResetTimer = null; - readOnlyNativeContextActive = true; - setNativeContextMenuDisabled(true); - } - function handleDocumentPointerDown(event) { - if (!readOnlyNativeContextActive) return; - if (event.target instanceof Node && editor.dom.contains(event.target)) { - return; - } - releaseReadOnlyNativeContext(); + if (isReadOnlyEditor()) setNativeContextMenuDisabled(false); } const isFocused = contentDOM === document.activeElement || contentDOM.contains(document.activeElement); - setNativeContextMenuDisabled(isFocused); + setNativeContextMenuDisabled(isFocused && !isReadOnlyEditor()); function handleContentFocus(_event) { setActivePane(pane); if (isReadOnlyEditor()) { - if (!readOnlyNativeContextActive) { - setNativeContextMenuDisabled(false); - } + setNativeContextMenuDisabled(false); clearPendingKeyboardHideBlur(); clearFileFocusState(); blurEditorIfReadOnly(editor, true); @@ -4399,9 +4353,7 @@ async function EditorManager($header, $body) { } async function handleContentBlur(_event) { - if (!readOnlyNativeContextActive) { - setNativeContextMenuDisabled(false); - } + setNativeContextMenuDisabled(false); touchSelectionController?.setMenu(false); if (isReadOnlyEditor()) { clearPendingKeyboardHideBlur(); @@ -4442,17 +4394,6 @@ async function EditorManager($header, $body) { contentDOM.addEventListener("blur", handleContentBlur); contentDOM.addEventListener("keydown", handleContentKeydown); contentDOM.addEventListener("pointerdown", handleContentPointerDown, true); - document.addEventListener("pointerdown", handleDocumentPointerDown, true); - document.addEventListener( - "pointerup", - scheduleReadOnlyNativeContextRelease, - true, - ); - document.addEventListener( - "pointercancel", - scheduleReadOnlyNativeContextRelease, - true, - ); pane.cleanupEditorListeners = () => { scroller?.removeEventListener("scroll", handleEditorScroll); @@ -4467,22 +4408,6 @@ async function EditorManager($header, $body) { handleContentPointerDown, true, ); - document.removeEventListener( - "pointerdown", - handleDocumentPointerDown, - true, - ); - document.removeEventListener( - "pointerup", - scheduleReadOnlyNativeContextRelease, - true, - ); - document.removeEventListener( - "pointercancel", - scheduleReadOnlyNativeContextRelease, - true, - ); - releaseReadOnlyNativeContext(); clearTimeout(scrollTimeout); if (scrollSyncRaf) { cancelAnimationFrame(scrollSyncRaf); @@ -4614,6 +4539,7 @@ async function EditorManager($header, $body) { function onscrollV(value) { const scroller = editor?.scrollDOM; if (!scroller) return; + markKeyboardScrollBrowsing(editor); suppressCursorReveal(); const normalized = clamp01(value); const maxScroll = Math.max( @@ -4644,6 +4570,7 @@ async function EditorManager($header, $body) { if (appSettings.value.textWrap) return; const scroller = editor?.scrollDOM; if (!scroller) return; + markKeyboardScrollBrowsing(editor); suppressCursorReveal(); const normalized = clamp01(value); const maxScroll = Math.max(scroller.scrollWidth - scroller.clientWidth, 0); diff --git a/src/lib/restoreFiles.js b/src/lib/restoreFiles.js index 4f844ac678..eb29df3ca6 100644 --- a/src/lib/restoreFiles.js +++ b/src/lib/restoreFiles.js @@ -1,3 +1,4 @@ +import { preloadFileLanguage } from "cm/fileLanguage"; import EditorFile from "./editorFile"; /** @@ -17,6 +18,11 @@ export default async function restoreFiles(files) { emitUpdate: false, }; const restoredFile = new EditorFile(file.filename, options); + if (render) { + void preloadFileLanguage(restoredFile).catch((error) => { + console.warn(`Failed to preload language for ${file.filename}`, error); + }); + } const load = Promise.resolve(restoredFile.load?.()); if (isRemoteUri(file.uri)) { diff --git a/tests/unit/codemirrorFocus.test.ts b/tests/unit/codemirrorFocus.test.ts new file mode 100644 index 0000000000..c8754ca7ae --- /dev/null +++ b/tests/unit/codemirrorFocus.test.ts @@ -0,0 +1,127 @@ +// @vitest-environment happy-dom + +import { EditorState } from "@codemirror/state"; +import { describe, expect, it, vi } from "vitest"; +import browser from "../../vendor/codemirror-view/src/browser"; +import { EditorView } from "../../vendor/codemirror-view/src/editorview"; + +describe("Android selection repair after scrolling", () => { + it.each([ + true, + false, + ])("does not cycle focus with controlled scrolling (waitForRendering=%s)", (waitForRendering) => { + checkSelectionRepair(waitForRendering, false); + }); + + it("retains upstream keyboard recovery when controlled scrolling is not installed", () => { + checkSelectionRepair(null, true); + }); +}); + +describe("Android delayed keyboard recovery", () => { + it.each([ + true, + false, + ])("respects dismissal after deletion when controlled scrolling is %s", (controlled) => { + vi.useFakeTimers(); + const previous = { android: browser.android, chrome: browser.chrome }; + const viewportDescriptor = Object.getOwnPropertyDescriptor( + window, + "visualViewport", + ); + const viewport = { + height: 420, + addEventListener() {}, + removeEventListener() {}, + }; + Object.defineProperty(window, "visualViewport", { + configurable: true, + value: viewport, + }); + Object.assign(browser, { android: true, chrome: true }); + const parent = document.body.appendChild(document.createElement("div")); + const view = new EditorView({ + parent, + state: EditorState.create({ + doc: "text", + extensions: controlled + ? [EditorView.controlledTouchScroll.of({ waitForRendering: false })] + : [], + }), + }); + try { + view.focus(); + const focus = vi.spyOn(view.contentDOM, "focus"); + const blur = vi.spyOn(view.contentDOM, "blur"); + const key = vi + .spyOn(view.observer, "delayAndroidKey") + .mockImplementation(() => {}); + view.contentDOM.dispatchEvent( + new InputEvent("beforeinput", { + inputType: "deleteContentBackward", + bubbles: true, + }), + ); + expect(key).toHaveBeenCalledWith("Backspace", 8); + viewport.height = 720; // Android Back dismisses the IME, keeping DOM focus. + vi.advanceTimersByTime(150); + expect(focus).toHaveBeenCalledTimes(controlled ? 0 : 1); + expect(blur).toHaveBeenCalledTimes(controlled ? 0 : 1); + } finally { + view.destroy(); + parent.remove(); + Object.assign(browser, previous); + if (viewportDescriptor) + Object.defineProperty(window, "visualViewport", viewportDescriptor); + else Reflect.deleteProperty(window, "visualViewport"); + vi.restoreAllMocks(); + vi.useRealTimers(); + } + }); +}); + +function checkSelectionRepair( + waitForRendering: boolean | null, + expectsFocusCycle: boolean, +) { + const previous = { android: browser.android, chrome: browser.chrome }; + Object.assign(browser, { android: true, chrome: true }); + const parent = document.body.appendChild(document.createElement("div")); + const view = new EditorView({ + parent, + state: EditorState.create({ + doc: "one\ntwo\nthree", + extensions: + waitForRendering === null + ? [] + : EditorView.controlledTouchScroll.of({ waitForRendering }), + }), + }); + try { + view.focus(); // Android Back can dismiss the keyboard without losing DOM focus. + const focus = vi.spyOn(view.contentDOM, "focus"); + const blur = vi.spyOn(view.contentDOM, "blur"); + const gap = document.createElement("div"); + gap.contentEditable = "false"; + gap.appendChild(document.createTextNode("virtualized gap")); + view.observer.ignore(() => view.contentDOM.appendChild(gap)); + for (let redraw = 0; redraw < 3; redraw++) { + view.observer.ignore(() => + document.getSelection()!.collapse(gap.firstChild, 0), + ); + view.docView.forceSelection = true; + view.docView.updateSelection(true); + } + expect(focus).toHaveBeenCalledTimes(expectsFocusCycle ? 3 : 0); + expect(blur).toHaveBeenCalledTimes(expectsFocusCycle ? 3 : 0); + expect(document.activeElement).toBe(view.contentDOM); + focus.mockClear(); + view.focus(); // Explicit user-driven focus remains available. + expect(focus).toHaveBeenCalledTimes(1); + } finally { + view.destroy(); + parent.remove(); + Object.assign(browser, previous); + vi.restoreAllMocks(); + } +} diff --git a/tests/unit/codemirrorPriorityParsing.test.ts b/tests/unit/codemirrorPriorityParsing.test.ts new file mode 100644 index 0000000000..f366c830f7 --- /dev/null +++ b/tests/unit/codemirrorPriorityParsing.test.ts @@ -0,0 +1,402 @@ +// @vitest-environment happy-dom + +import { EditorState, Text } from "@codemirror/state"; +import { NodeType, Parser, Tree } from "@lezer/common"; +import { describe, expect, it, vi } from "vitest"; +import { + outerLanguageHighlighters, + provisionalHighlightRange, + retainHighlightingDuringControlledPreparation, + retainMappedHighlighting, + retainMappedHighlightingWhileParsing, +} from "../../vendor/codemirror-language/src/highlight"; +import { + defineLanguageFacet, + Language, + ParseWorker, + provisionalParseIsCurrent, + viewportPriorityParsing, +} from "../../vendor/codemirror-language/src/language"; + +describe("viewport-priority parsing lifecycle", () => { + it("publishes provisional colors during an active gesture while canonical work yields", () => { + vi.useFakeTimers(); + let now = 0; + vi.spyOn(Date, "now").mockImplementation(() => ++now); + let canonicalAdvances = 0; + class TestParser extends Parser { + createParse( + _input: unknown, + _fragments: unknown, + ranges: readonly { from: number; to: number }[], + ) { + const range = ranges[0]; + return { + parsedPos: range.from, + stoppedAt: null as number | null, + stopAt(pos: number) { + this.stoppedAt = pos; + }, + advance() { + if (!range.from) { + canonicalAdvances++; + if (this.stoppedAt === this.parsedPos) return Tree.empty; + return null; + } + return new Tree(NodeType.none, [], [], range.to - range.from); + }, + }; + } + } + const lang = new Language(defineLanguageFacet(), new TestParser()); + const state = EditorState.create({ + doc: "const value = 1;\n".repeat(1000), + extensions: [ + lang, + viewportPriorityParsing({ sliceMs: 4, approximate: "outer-language" }), + ], + }); + const dispatch = vi.fn(); + const view = { + state, + viewport: { from: 8000, to: 8200 }, + visibleRanges: [{ from: 8000, to: 8200 }], + win: { performance: { now: () => ++now } }, + controlledTouchScrollController: { renderCritical: true }, + dispatch, + }; + const worker = new ParseWorker( + view as unknown as import("@codemirror/view").EditorView, + ); + try { + const before = canonicalAdvances; + vi.advanceTimersByTime(20); + expect(worker.viewportTree()).toMatchObject({ + from: state.doc.line(385).from, + approximate: "outer-language", + }); + expect(dispatch).toHaveBeenCalled(); + worker.work(); + expect(canonicalAdvances).toBe(before); + } finally { + worker.destroy(); + vi.restoreAllMocks(); + vi.useRealTimers(); + } + }); + + it("keeps published colors and finishes stable windows through continuous scrolling", () => { + vi.useFakeTimers(); + let now = 0; + vi.spyOn(Date, "now").mockImplementation(() => ++now); + const starts: number[] = []; + class SlowParser extends Parser { + createParse( + _input: unknown, + _fragments: unknown, + ranges: readonly { from: number; to: number }[], + ) { + const range = ranges[0]; + starts.push(range.from); + let steps = 0; + return { + parsedPos: range.from, + stoppedAt: null as number | null, + stopAt(pos: number) { + this.stoppedAt = pos; + }, + advance() { + if (this.stoppedAt === this.parsedPos) return Tree.empty; + if (!range.from || ++steps < 8) return null; + return new Tree(NodeType.none, [], [], range.to - range.from); + }, + }; + } + } + const lang = new Language(defineLanguageFacet(), new SlowParser()); + const state = EditorState.create({ + doc: Array.from({ length: 10000 }, (_, index) => + index % 997 === 0 ? "x".repeat(100000) : "const value = 1;", + ).join("\n"), + extensions: [ + lang, + viewportPriorityParsing({ sliceMs: 4, approximate: "outer-language" }), + ], + }); + const view = { + state, + viewport: { from: state.doc.line(400).from, to: state.doc.line(420).to }, + visibleRanges: [] as { from: number; to: number }[], + win: { performance: { now: () => ++now } }, + controlledTouchScrollController: { renderCritical: true }, + dispatch: vi.fn(), + }; + view.visibleRanges = [view.viewport]; + const worker = new ParseWorker( + view as unknown as import("@codemirror/view").EditorView, + ); + const move = (line: number) => { + view.viewport = { + from: state.doc.line(line).from, + to: state.doc.line(line + 20).to, + }; + view.visibleRanges = [view.viewport]; + worker.update({ + view, + state: view.state, + startState: view.state, + viewportChanged: true, + docChanged: false, + selectionSet: false, + } as unknown as import("@codemirror/view").ViewUpdate); + }; + try { + const first = worker.provisional; + for (let line = 401; line < 409; line++) { + move(line); + vi.advanceTimersByTime(16); + } + expect(starts.filter((from) => from === first!.from)).toHaveLength(1); + expect(worker.viewportTree()?.tree).toBe(first!.tree); + const published = first!.tree; + move(500); // Cross a window boundary while the replacement is still running. + expect( + worker.viewportTrees().some((tree) => tree.tree === published), + ).toBe(true); + vi.advanceTimersByTime(500); + expect( + worker + .viewportTrees() + .some((tree) => tree.to >= state.doc.line(700).to), + ).toBe(true); + move(400); // Reverse direction without throwing away the original colors. + expect(worker.viewportTree()?.tree).toBe(published); + for (let line = 1000; line < 8000; line += 500) { + move(line); + vi.advanceTimersByTime(500); + expect(worker.viewportTrees().length).toBeLessThanOrEqual(12); + expect(worker.provisionalPending()).toBe(false); + } + const previous = view.state; + view.state = view.state.update({ + changes: { from: 0, insert: "// edit\n" }, + }).state; + worker.update({ + view, + state: view.state, + startState: previous, + viewportChanged: false, + docChanged: true, + selectionSet: false, + } as unknown as import("@codemirror/view").ViewUpdate); + expect(worker.viewportTrees()).toEqual([]); + } finally { + worker.destroy(); + vi.restoreAllMocks(); + vi.useRealTimers(); + } + }); + + it("rejects stale, edited, language-changed, and destroyed work", () => { + const doc = Text.of(["const value = 1"]); + const otherDoc = Text.of(["const value = 2"]); + const parser = { advance: () => null, parsedPos: 0, stopAt: () => {} }; + const language = {} as Language; + const task = { + generation: 4, + doc, + language, + from: 0, + to: doc.length, + parse: parser, + tree: null, + published: false, + failed: false, + }; + + expect(provisionalParseIsCurrent(task, 4, doc, language, false)).toBe(true); + expect(provisionalParseIsCurrent(task, 5, doc, language, false)).toBe( + false, + ); + expect(provisionalParseIsCurrent(task, 4, otherDoc, language, false)).toBe( + false, + ); + expect(provisionalParseIsCurrent(task, 4, doc, {} as Language, false)).toBe( + false, + ); + expect(provisionalParseIsCurrent(task, 4, doc, language, true)).toBe(false); + }); + + it("hands uncovered ranges to provisional highlighting only until exact coverage", () => { + expect(provisionalHighlightRange(100, 200, 140, 100, 200)).toEqual({ + from: 140, + to: 200, + }); + expect(provisionalHighlightRange(100, 200, 200, 100, 200)).toBeNull(); + }); + + it("limits provisional styling to the outer language scope", () => { + const outer = NodeType.define({ id: 1, name: "Outer" }); + const embedded = NodeType.define({ id: 2, name: "Embedded" }); + const provisional = { + tree: new Tree(outer, [], [], 10), + from: 0, + to: 10, + version: 1, + approximate: "outer-language" as const, + }; + const [highlighter] = outerLanguageHighlighters(provisional, [ + { style: () => "token", scope: () => true }, + ]); + + expect(highlighter.scope?.(outer)).toBe(true); + expect(highlighter.scope?.(embedded)).toBe(false); + }); + + it("retains mapped colors only for covered same-language edits", () => { + expect( + retainMappedHighlighting( + true, + true, + false, + true, + false, + 100, + 1000, + 2000, + 900, + 2100, + ), + ).toBe(true); + expect( + retainMappedHighlighting( + true, + true, + false, + false, + false, + 100, + 1000, + 2000, + 900, + 2100, + ), + ).toBe(false); + expect( + retainMappedHighlighting( + true, + true, + false, + true, + false, + 2000, + 1000, + 2000, + 900, + 2100, + ), + ).toBe(false); + }); + + it("keeps mapped colors through incomplete canonical progress", () => { + expect( + retainMappedHighlightingWhileParsing( + true, + false, + true, + false, + false, + false, + 500, + 1000, + 2000, + 900, + 2100, + ), + ).toBe(true); + expect( + retainMappedHighlightingWhileParsing( + true, + false, + true, + false, + true, + false, + 500, + 1000, + 2000, + 900, + 2100, + ), + ).toBe(false); + expect( + retainMappedHighlightingWhileParsing( + true, + false, + true, + false, + false, + false, + 2000, + 1000, + 2000, + 900, + 2100, + ), + ).toBe(false); + expect( + retainMappedHighlightingWhileParsing( + true, + false, + true, + false, + false, + true, + 500, + 1000, + 2000, + 900, + 2100, + ), + ).toBe(false); + }); + + it("retains the colored safe range while a controlled corridor expands", () => { + expect( + retainHighlightingDuringControlledPreparation( + true, + true, + true, + false, + false, + false, + 500, + 2000, + ), + ).toBe(true); + expect( + retainHighlightingDuringControlledPreparation( + true, + true, + true, + false, + true, + false, + 500, + 2000, + ), + ).toBe(false); + expect( + retainHighlightingDuringControlledPreparation( + true, + true, + true, + false, + false, + false, + 2000, + 2000, + ), + ).toBe(false); + }); +}); diff --git a/tests/unit/codemirrorRendering.test.ts b/tests/unit/codemirrorRendering.test.ts new file mode 100644 index 0000000000..27b7903eb3 --- /dev/null +++ b/tests/unit/codemirrorRendering.test.ts @@ -0,0 +1,1100 @@ +// @vitest-environment happy-dom + +import { javascript } from "@codemirror/lang-javascript"; +import { + defaultHighlightStyle, + syntaxHighlighting, +} from "@codemirror/language"; +import { Compartment, EditorState, type Transaction } from "@codemirror/state"; +import { __test, EditorView } from "@codemirror/view"; +import { describe, expect, it, vi } from "vitest"; + +function touchEvent( + type: string, + x: number, + y: number, + touchCount = 1, + at?: number, +) { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, "touches", { + value: Array.from({ length: touchCount }, () => ({ + clientX: x, + clientY: y, + })), + }); + if (at != null) Object.defineProperty(event, "timeStamp", { value: at }); + return event; +} + +function controlledScrollHarness( + bounds = { from: 0, to: 1200 }, + options: { + waitForRendering?: boolean; + autoCover?: boolean; + prepareMs?: number; + scrollHeight?: number; + maxAhead?: number; + startTop?: number; + } = {}, +) { + const scrollDOM = document.createElement("div"); + const contentDOM = document.createElement("div"); + scrollDOM.append(contentDOM); + const log: string[] = []; + let scrollTop = options.startTop ?? 500; + let now = 0; + let nextFrame = 1; + const frames = new Map(); + let corridorActive = false; + + Object.defineProperties(scrollDOM, { + clientHeight: { value: 600 }, + scrollHeight: { value: options.scrollHeight ?? 3000 }, + scrollTop: { + get: () => scrollTop, + set: (value: number) => { + scrollTop = value; + log.push(`scroll:${value}`); + }, + }, + scrollTo: { + value: ({ top, behavior }: ScrollToOptions) => { + if (typeof top === "number") scrollTop = top; + log.push(`${behavior}:${top}`); + }, + }, + }); + + const state = EditorState.create({ + extensions: [ + EditorView.controlledTouchScroll.of({ + maxAhead: options.maxAhead ?? 4, + waitForRendering: options.waitForRendering ?? true, + settleDelay: 120, + }), + ], + }); + const windowEvents = new EventTarget(); + const visualViewport = new EventTarget(); + const fakeWindow = { + visualViewport, + performance: { now: () => now }, + requestAnimationFrame(callback: FrameRequestCallback) { + const id = nextFrame++; + frames.set(id, callback); + return id; + }, + cancelAnimationFrame(id: number) { + frames.delete(id); + }, + addEventListener: windowEvents.addEventListener.bind(windowEvents), + removeEventListener: windowEvents.removeEventListener.bind(windowEvents), + }; + const viewState = { + scaleY: 1, + get controlledScrollActive() { + return corridorActive; + }, + setControlledScrollCorridor(from: number, to: number) { + corridorActive = true; + log.push(`corridor:${from}:${to}`); + if (options.autoCover) { + bounds.from = Math.max(0, Math.min(bounds.from, from)); + bounds.to = Math.max( + bounds.to, + Math.min((options.scrollHeight ?? 3000) - 600, Math.floor(to - 602)), + ); + } + return true; + }, + clearControlledScrollCorridor() { + const changed = corridorActive; + corridorActive = false; + return changed; + }, + controlledScrollBounds: () => bounds, + }; + const view = { + state, + scrollDOM, + contentDOM, + win: fakeWindow, + viewState, + dispatch() { + log.push("dispatch"); + }, + requestMeasure(request?: { + read: () => unknown; + write?: (value: unknown) => void; + }) { + if (!request) return; + log.push("render"); + const measured = request.read(); + now += options.prepareMs ?? 0; + log.push("dom"); + request.write?.(measured); + }, + }; + const Controller = __test.ControlledTouchScroll as unknown as new ( + view: unknown, + supported: boolean, + ) => { + readonly renderCritical: boolean; + beforeUpdate(transactions: readonly Transaction[]): void; + destroy(): void; + onScrollChanged(): boolean; + clampToRenderedOffset(target: number): number; + onHighlightCoverageReady(): void; + onHighlightCoverageInvalidated(): void; + onHighlightCoverageRemoved(): void; + debugSnapshot(): { + active: boolean; + releaseVelocity: number; + expectedDistance: number; + expectedDuration: number; + actualDuration: number; + currentVelocity: number; + committedDistance: number; + termination: string | null; + corridorFrom: number; + corridorTo: number; + idlePrewarmReady: boolean; + preparationDuration: number; + preparationChunkScreens: number; + firstMovementDelay: number; + remainingSafeDistance: number; + }; + }; + const controller = new Controller(view, true); + + return { + state, + windowEvents, + visualViewport, + controller, + log, + scrollDOM, + contentDOM, + get scrollTop() { + return scrollTop; + }, + runFrame(elapsed = 16) { + const pending = [...frames.values()]; + frames.clear(); + now += elapsed; + for (const callback of pending) callback(now); + }, + get frameCount() { + return frames.size; + }, + }; +} + +function startMomentum(harness: ReturnType) { + harness.scrollDOM.dispatchEvent(touchEvent("touchstart", 100, 500)); + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 400)); + harness.runFrame(); + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 300)); + harness.runFrame(); + harness.scrollDOM.dispatchEvent(touchEvent("touchend", 100, 300, 0)); + harness.runFrame(); +} + +describe("vendored CodeMirror rendering interfaces", () => { + it("caps directional buffering and normalizes its settle delay", () => { + const state = EditorState.create({ + extensions: [ + EditorView.viewportBuffer.of({ maxAhead: 20, settleDelay: -10 }), + ], + }); + const config = state.facet(EditorView.viewportBuffer); + + expect(config).toEqual({ maxAhead: 4, settleDelay: 0 }); + }); + + it("caps controlled touch scrolling and normalizes its settle delay", () => { + const state = EditorState.create({ + extensions: [ + EditorView.controlledTouchScroll.of({ maxAhead: 20, settleDelay: -10 }), + ], + }); + + expect(state.facet(EditorView.controlledTouchScroll)).toEqual({ + maxAhead: 4, + settleDelay: 0, + waitForRendering: true, + }); + }); + + it("grows only ahead and returns to the baseline after settling", () => { + expect(__test.applyViewportBuffer(500, 500, 4, 1, true, 600, 1)).toEqual({ + top: 500, + bottom: 2400, + }); + expect(__test.applyViewportBuffer(500, 500, 4, -1, true, 600, 1)).toEqual({ + top: 2400, + bottom: 500, + }); + expect(__test.applyViewportBuffer(500, 500, 4, 1, false, 600, 1)).toEqual({ + top: 500, + bottom: 500, + }); + }); + + it("limits directional buffering to native touch and wheel activity", () => { + const now = 10_000; + + expect(__test.isNativeScrollActivity(false, 0, 0, now)).toBe(false); + expect(__test.isNativeScrollActivity(true, 0, 0, now)).toBe(true); + expect(__test.isNativeScrollActivity(false, now - 1_999, 0, now)).toBe( + true, + ); + expect(__test.isNativeScrollActivity(false, 0, now - 199, now)).toBe(true); + expect(__test.isNativeScrollActivity(false, now - 2_000, 0, now)).toBe( + false, + ); + }); + + it("claims only deliberate vertical gestures", () => { + expect(__test.claimControlledTouchGesture(2, 5)).toBe("pending"); + expect(__test.claimControlledTouchGesture(8, 7)).toBe("vertical"); + expect(__test.claimControlledTouchGesture(4, 8)).toBe("vertical"); + expect(__test.claimControlledTouchGesture(0, -8)).toBe("vertical"); + }); + + it("uses only recent committed positions for momentum", () => { + const samples = [ + { at: 0, top: 0 }, + { at: 40, top: 80 }, + { at: 100, top: 200 }, + ]; + + expect(__test.estimateCommittedVelocity(samples, 100)).toBe(2000); + expect(__test.estimateCommittedVelocity(samples.slice(0, 1), 100)).toBe(0); + expect( + __test.estimateCommittedVelocity( + [ + { at: 0, top: 0 }, + { at: 100, top: 200 }, + ], + 100, + ), + ).toBe(2000); + }); + + it("matches Android spline distance and duration snapshots", () => { + expect(__test.createAndroidSplineFling(4000)).toEqual({ + initialVelocity: 4000, + distance: 2156, + duration: 1540, + }); + expect(__test.createAndroidSplineFling(-4000)).toEqual({ + initialVelocity: -4000, + distance: -2156, + duration: 1540, + }); + expect(__test.createAndroidSplineFling(20)).toBeNull(); + expect(__test.createAndroidSplineFling(20_000)?.initialVelocity).toBe(8000); + }); + + it("decelerates monotonically along the Android spline", () => { + const fling = __test.createAndroidSplineFling(4000)!; + const samples = [0, 0.25, 0.5, 0.75, 1].map((progress) => + __test.sampleAndroidSpline(fling, fling.duration * progress), + ); + + expect(samples.map((sample) => Math.round(sample.distance))).toEqual([ + 0, 1259, 1851, 2094, 2156, + ]); + for (let index = 1; index < samples.length; index++) { + expect(samples[index].distance).toBeGreaterThan( + samples[index - 1].distance, + ); + expect(samples[index].velocity).toBeLessThan(samples[index - 1].velocity); + } + expect(samples.at(-1)?.done).toBe(true); + }); + + it("commits the first covered move immediately and coalesces follow-ups", () => { + const harness = controlledScrollHarness(); + try { + harness.scrollDOM.dispatchEvent(touchEvent("touchstart", 100, 500)); + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 480)); + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 450)); + + expect(harness.frameCount).toBe(1); + expect(harness.log).toEqual(["scroll:520"]); + harness.runFrame(); + expect(harness.scrollTop).toBe(550); + expect( + harness.log.filter((entry) => entry.startsWith("scroll:")), + ).toEqual(["scroll:520", "scroll:550"]); + } finally { + harness.controller.destroy(); + } + }); + + it("prewarms one screen in both directions while idle", () => { + vi.useFakeTimers(); + const bounds = { from: 400, to: 600 }; + const harness = controlledScrollHarness(bounds, { autoCover: true }); + try { + vi.advanceTimersByTime(400); + + const result = harness.controller.debugSnapshot(); + expect(result.idlePrewarmReady).toBe(true); + expect(result.corridorFrom).toBe(0); + expect(result.corridorTo).toBe(1700); + expect(harness.log.filter((entry) => entry === "render").length).toBe(4); + } finally { + harness.controller.destroy(); + vi.useRealTimers(); + } + }); + + it("commits the first covered move directly from a prewarmed gesture", () => { + vi.useFakeTimers(); + const harness = controlledScrollHarness( + { from: 0, to: 1200 }, + { autoCover: true }, + ); + try { + vi.advanceTimersByTime(400); + harness.log.length = 0; + harness.scrollDOM.dispatchEvent(touchEvent("touchstart", 100, 500)); + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 450)); + + expect(harness.scrollTop).toBe(550); + expect(harness.frameCount).toBe(0); + expect(harness.log).toEqual(["scroll:550"]); + } finally { + harness.controller.destroy(); + vi.useRealTimers(); + } + }); + + it("shrinks adaptive preparation chunks after a slow render", () => { + vi.useFakeTimers(); + const harness = controlledScrollHarness( + { from: 400, to: 600 }, + { autoCover: true, prepareMs: 24 }, + ); + try { + vi.advanceTimersByTime(400); + + const result = harness.controller.debugSnapshot(); + expect(result.preparationDuration).toBe(24); + expect(result.preparationChunkScreens).toBe(0.125); + expect(result.idlePrewarmReady).toBe(true); + } finally { + harness.controller.destroy(); + vi.useRealTimers(); + } + }); + + it.each([ + [10, 12], + [-10, 12], + [10, -12], + [-10, -12], + [12, 10], + [10, 10], + ])("scrolls diagonal swipes (%i, %i) vertically", (dx, dy) => { + const harness = controlledScrollHarness(undefined, { + waitForRendering: false, + }); + try { + expect(harness.scrollDOM.style.touchAction).toBe("pan-x"); + harness.scrollDOM.dispatchEvent(touchEvent("touchstart", 100, 500)); + const move = touchEvent("touchmove", 100 + dx, 500 + dy); + harness.scrollDOM.dispatchEvent(move); + expect(move.defaultPrevented).toBe(true); + expect(harness.scrollTop).toBe(500 - dy); + } finally { + harness.controller.destroy(); + } + }); + + it("passes horizontal gestures through without scrolling", () => { + const harness = controlledScrollHarness(); + try { + harness.scrollDOM.dispatchEvent(touchEvent("touchstart", 100, 500)); + const move = touchEvent("touchmove", 112, 495); + harness.scrollDOM.dispatchEvent(move); + + expect(move.defaultPrevented).toBe(false); + expect(harness.frameCount).toBe(0); + expect(harness.scrollTop).toBe(500); + } finally { + harness.controller.destroy(); + } + }); + + it("leaves taps and long-press starts on the native selection path", () => { + const harness = controlledScrollHarness(); + try { + const start = touchEvent("touchstart", 100, 500); + const end = touchEvent("touchend", 100, 500, 0); + harness.scrollDOM.dispatchEvent(start); + harness.scrollDOM.dispatchEvent(end); + + expect(start.defaultPrevented).toBe(false); + expect(end.defaultPrevented).toBe(false); + expect(harness.frameCount).toBe(0); + expect(harness.scrollTop).toBe(500); + } finally { + harness.controller.destroy(); + } + }); + + it("does not own caret anchoring or block parsing during a pending tap", () => { + const harness = controlledScrollHarness(); + try { + harness.scrollDOM.dispatchEvent(touchEvent("touchstart", 100, 500)); + expect(harness.controller.renderCritical).toBe(false); + expect(harness.controller.clampToRenderedOffset(900)).toBe(900); + harness.contentDOM.dispatchEvent(new Event("focus")); + harness.scrollDOM.dispatchEvent(touchEvent("touchend", 100, 500, 0)); + expect(harness.controller.clampToRenderedOffset(900)).toBe(900); + } finally { + harness.controller.destroy(); + } + }); + + it.each([ + "focus", + "blur", + "contextmenu", + "keyboard", + "window-blur", + ])("releases momentum and anchoring on %s", (reason) => { + const harness = controlledScrollHarness(); + try { + startMomentum(harness); + expect(harness.controller.debugSnapshot().active).toBe(true); + if (reason === "keyboard") + harness.visualViewport.dispatchEvent(new Event("resize")); + else if (reason === "window-blur") + harness.windowEvents.dispatchEvent(new Event("blur")); + else + harness.contentDOM.dispatchEvent(new Event(reason, { bubbles: true })); + const top = harness.scrollTop; + harness.runFrame(); + expect(harness.scrollTop).toBe(top); + expect(harness.controller.debugSnapshot().active).toBe(false); + expect(harness.controller.clampToRenderedOffset(1000)).toBe(1000); + } finally { + harness.controller.destroy(); + } + }); + + it.each([ + "before-claim", + "after-claim", + ])("keeps a keyboard resize from converting a drag into a tap (%s)", (when) => { + const harness = controlledScrollHarness(undefined, { + waitForRendering: false, + }); + try { + harness.scrollDOM.dispatchEvent(touchEvent("touchstart", 100, 500)); + if (when === "after-claim") + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 450)); + harness.visualViewport.dispatchEvent(new Event("resize")); + harness.windowEvents.dispatchEvent(new Event("resize")); + const top = harness.scrollTop; + const move = touchEvent("touchmove", 100, 400); + harness.scrollDOM.dispatchEvent(move); + expect(move.defaultPrevented).toBe(true); + expect(harness.scrollTop).toBeGreaterThan(top); + const end = touchEvent("touchend", 100, 400, 0); + harness.scrollDOM.dispatchEvent(end); + expect(end.defaultPrevented).toBe(true); + harness.scrollDOM.dispatchEvent(touchEvent("touchstart", 100, 400)); + const tap = touchEvent("touchend", 100, 400, 0); + harness.scrollDOM.dispatchEvent(tap); + expect(tap.defaultPrevented).toBe(false); + } finally { + harness.controller.destroy(); + } + }); + + it("consumes a scrolled finger release even after navigation cancels movement", () => { + const harness = controlledScrollHarness(undefined, { + waitForRendering: false, + }); + try { + harness.scrollDOM.dispatchEvent(touchEvent("touchstart", 100, 500)); + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 400)); + harness.controller.beforeUpdate([ + harness.state.update({ selection: { anchor: 0 } }), + ]); + const end = touchEvent("touchend", 100, 400, 0); + harness.scrollDOM.dispatchEvent(end); + expect(end.defaultPrevented).toBe(true); + } finally { + harness.controller.destroy(); + } + }); + + it("releases before explicit selection and scroll navigation transactions", () => { + for (const spec of [ + { selection: { anchor: 0 } }, + { scrollIntoView: true }, + { effects: EditorView.scrollIntoView(0) }, + ]) { + const harness = controlledScrollHarness(); + try { + startMomentum(harness); + harness.controller.beforeUpdate([harness.state.update(spec)]); + const top = harness.scrollTop; + harness.runFrame(); + expect(harness.scrollTop).toBe(top); + expect(harness.controller.clampToRenderedOffset(1000)).toBe(1000); + } finally { + harness.controller.destroy(); + } + } + }); + + it("preserves external scrolling inside prepared coverage during a fling", () => { + const harness = controlledScrollHarness(); + try { + startMomentum(harness); + harness.scrollDOM.scrollTop = 800; + expect(harness.controller.onScrollChanged()).toBe(false); + harness.runFrame(); + expect(harness.scrollTop).toBe(800); + expect(harness.controller.debugSnapshot().active).toBe(false); + } finally { + harness.controller.destroy(); + } + }); + + it("keeps interactive widgets on their own touch path", () => { + const harness = controlledScrollHarness(); + const input = document.createElement("textarea"); + harness.contentDOM.append(input); + try { + input.dispatchEvent(touchEvent("touchstart", 100, 500)); + const move = touchEvent("touchmove", 100, 400); + input.dispatchEvent(move); + harness.runFrame(); + expect(move.defaultPrevented).toBe(false); + expect(harness.scrollTop).toBe(500); + } finally { + harness.controller.destroy(); + } + }); + + it("prepares more coverage during a held drag and does not replay blocked distance", () => { + const bounds = { from: 0, to: 550 }; + const harness = controlledScrollHarness(bounds, { autoCover: true }); + try { + harness.controller.onHighlightCoverageReady(); + harness.scrollDOM.dispatchEvent(touchEvent("touchstart", 100, 500)); + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 300)); + harness.runFrame(); + expect(harness.scrollTop).toBe(550); + expect(bounds.to).toBeGreaterThan(550); + harness.controller.onHighlightCoverageReady(); + harness.runFrame(); + expect(harness.scrollTop).toBe(550); + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 280)); + harness.runFrame(); + expect(harness.scrollTop).toBe(570); + } finally { + harness.controller.destroy(); + } + }); + + it("tracks finger distance immediately even when DOM and colors lag behind", () => { + const harness = controlledScrollHarness( + { from: 0, to: 520 }, + { waitForRendering: false }, + ); + try { + harness.controller.onHighlightCoverageReady(); + harness.controller.onHighlightCoverageInvalidated(); + harness.scrollDOM.dispatchEvent(touchEvent("touchstart", 100, 500, 1, 0)); + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 300, 1, 16)); + expect(harness.scrollTop).toBe(700); + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 220, 1, 32)); + expect(harness.scrollTop).toBe(780); + expect(harness.frameCount).toBe(0); + expect(harness.controller.onScrollChanged()).toBe(false); + expect(harness.controller.clampToRenderedOffset(500)).toBe(780); + } finally { + harness.controller.destroy(); + } + }); + + it("uses touch velocity for a continuous fling beyond stale rendering coverage", () => { + const harness = controlledScrollHarness( + { from: 0, to: 520 }, + { waitForRendering: false, scrollHeight: 20000 }, + ); + try { + harness.scrollDOM.dispatchEvent(touchEvent("touchstart", 100, 500, 1, 0)); + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 400, 1, 40)); + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 300, 1, 80)); + harness.scrollDOM.dispatchEvent(touchEvent("touchend", 100, 300, 0, 80)); + const initial = harness.controller.debugSnapshot(); + expect(initial.releaseVelocity).toBe(2500); + expect(initial.active).toBe(true); + for ( + let frame = 0; + frame < 200 && harness.controller.debugSnapshot().active; + frame++ + ) + harness.runFrame(40); + expect( + Math.abs(harness.scrollTop - 700 - initial.expectedDistance), + ).toBeLessThan(2); + expect(harness.controller.debugSnapshot().termination).toBe("completed"); + } finally { + harness.controller.destroy(); + } + }); + + it("follows a direction reversal without flinging in the previous direction", () => { + const harness = controlledScrollHarness( + { from: 0, to: 520 }, + { waitForRendering: false }, + ); + try { + harness.scrollDOM.dispatchEvent(touchEvent("touchstart", 100, 500, 1, 0)); + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 300, 1, 40)); + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 360, 1, 60)); + harness.scrollDOM.dispatchEvent(touchEvent("touchend", 100, 360, 0, 60)); + expect(harness.scrollTop).toBe(640); + expect(harness.controller.debugSnapshot().releaseVelocity).toBe(-3000); + harness.runFrame(); + expect(harness.scrollTop).toBeLessThan(640); + } finally { + harness.controller.destroy(); + } + }); + + it("releases stale highlight coverage when switching to plain text", () => { + const harness = controlledScrollHarness(); + try { + harness.controller.onHighlightCoverageReady(); + harness.controller.onHighlightCoverageInvalidated(); + harness.controller.onHighlightCoverageRemoved(); + harness.scrollDOM.dispatchEvent(touchEvent("touchstart", 100, 500)); + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 300)); + harness.runFrame(); + expect(harness.scrollTop).toBe(700); + } finally { + harness.controller.destroy(); + } + }); + + it("clears the actual highlighter handshake when a language is removed", () => { + const language = new Compartment(); + const view = new EditorView({ + doc: "const x = 1;\n".repeat(1000), + extensions: [ + language.of(javascript()), + syntaxHighlighting(defaultHighlightStyle), + ], + }); + const controller = ( + view as unknown as { + controlledTouchScrollController: { onHighlightCoverageRemoved(): void }; + } + ).controlledTouchScrollController; + const removed = vi.spyOn(controller, "onHighlightCoverageRemoved"); + try { + view.dispatch({ effects: language.reconfigure([]) }); + expect(removed).toHaveBeenCalled(); + } finally { + view.destroy(); + } + }); + + it("allows scrolling a read-only contentDOM while ignoring nested widgets", () => { + const harness = controlledScrollHarness( + { from: 0, to: 520 }, + { waitForRendering: false }, + ); + harness.contentDOM.setAttribute("contenteditable", "false"); + const line = document.createElement("div"); + harness.contentDOM.append(line); + try { + line.dispatchEvent(touchEvent("touchstart", 100, 500)); + line.dispatchEvent(touchEvent("touchmove", 100, 300)); + expect(harness.scrollTop).toBe(700); + } finally { + harness.controller.destroy(); + } + }); + + it("clamps movement at the rendered edge and discards the remainder", () => { + const harness = controlledScrollHarness({ from: 0, to: 520 }); + try { + harness.scrollDOM.dispatchEvent(touchEvent("touchstart", 100, 500)); + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 400)); + harness.runFrame(); + + expect(harness.scrollTop).toBe(520); + expect(harness.log).toContain("scroll:520"); + } finally { + harness.controller.destroy(); + } + }); + + it("never commits movement beyond published highlight coverage", () => { + const bounds = { from: 0, to: 600 }; + const harness = controlledScrollHarness(bounds); + try { + harness.controller.onHighlightCoverageReady(); + bounds.to = 1200; + harness.scrollDOM.dispatchEvent(touchEvent("touchstart", 100, 500)); + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 300)); + + expect(harness.scrollTop).toBe(600); + expect(harness.log).toContain("scroll:600"); + } finally { + harness.controller.destroy(); + } + }); + + it("pins wrapped-line anchor corrections to the last controlled offset", () => { + const harness = controlledScrollHarness({ from: 300, to: 700 }); + try { + harness.scrollDOM.dispatchEvent(touchEvent("touchstart", 100, 500)); + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 450)); + + expect(harness.controller.clampToRenderedOffset(400_000)).toBe(550); + expect(harness.controller.clampToRenderedOffset(-10_000)).toBe(550); + harness.runFrame(); + expect(harness.controller.clampToRenderedOffset(400_000)).toBe(550); + } finally { + harness.controller.destroy(); + } + }); + + it("cancels queued movement when the view is destroyed", () => { + const harness = controlledScrollHarness(); + expect(harness.scrollDOM.style.touchAction).toBe("pan-x"); + harness.scrollDOM.dispatchEvent(touchEvent("touchstart", 100, 500)); + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 480)); + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 450)); + harness.controller.destroy(); + harness.runFrame(); + + expect(harness.scrollDOM.style.touchAction).toBe(""); + expect(harness.scrollTop).toBe(520); + expect(harness.log.filter((entry) => entry.startsWith("scroll:"))).toEqual([ + "scroll:520", + ]); + }); + + it("cancels queued movement for composition and multi-touch", () => { + for (const cancel of [ + (harness: ReturnType) => + harness.contentDOM.dispatchEvent(new Event("compositionstart")), + (harness: ReturnType) => + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 450, 2)), + ]) { + const harness = controlledScrollHarness(); + try { + harness.scrollDOM.dispatchEvent(touchEvent("touchstart", 100, 500)); + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 480)); + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 450)); + cancel(harness); + harness.runFrame(); + + expect(harness.scrollTop).toBe(520); + } finally { + harness.controller.destroy(); + } + } + }); + + it("cancels active spline momentum for composition and destruction", () => { + for (const cancel of [ + (harness: ReturnType) => + harness.contentDOM.dispatchEvent(new Event("compositionstart")), + (harness: ReturnType) => + harness.controller.destroy(), + ]) { + const harness = controlledScrollHarness({ from: 0, to: 1200 }); + try { + startMomentum(harness); + expect(harness.controller.debugSnapshot().active).toBe(true); + cancel(harness); + const stoppedAt = harness.scrollTop; + harness.runFrame(); + + expect(harness.scrollTop).toBe(stoppedAt); + expect(harness.controller.debugSnapshot().active).toBe(false); + } finally { + harness.controller.destroy(); + } + } + }); + + it("advances Android spline momentum through owned animation frames", () => { + const harness = controlledScrollHarness({ from: 0, to: 1200 }); + try { + startMomentum(harness); + const started = harness.controller.debugSnapshot(); + expect(started.active).toBe(true); + expect(started.releaseVelocity).toBe(4687); + expect(started.expectedDistance).toBeGreaterThan(2500); + + harness.runFrame(); + expect(harness.scrollTop).toBeGreaterThan(700); + expect(harness.scrollTop).toBeLessThanOrEqual(1200); + expect(harness.log.some((entry) => entry.startsWith("smooth:"))).toBe( + false, + ); + } finally { + harness.controller.destroy(); + } + }); + + it("ignores scrollend settling queued during an active touch gesture", () => { + vi.useFakeTimers(); + const harness = controlledScrollHarness({ from: 0, to: 1200 }); + try { + harness.scrollDOM.dispatchEvent(touchEvent("touchstart", 100, 500)); + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 400)); + harness.runFrame(); + harness.scrollDOM.dispatchEvent(new Event("scrollend")); + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 300)); + harness.runFrame(); + harness.scrollDOM.dispatchEvent(touchEvent("touchend", 100, 300, 0)); + harness.runFrame(); + + expect(harness.controller.debugSnapshot().active).toBe(true); + vi.advanceTimersByTime(121); + expect(harness.controller.debugSnapshot().active).toBe(true); + } finally { + harness.controller.destroy(); + vi.useRealTimers(); + } + }); + + it("recognizes a delayed event from its last committed offset", () => { + const harness = controlledScrollHarness({ from: 0, to: 1200 }); + try { + harness.scrollDOM.dispatchEvent(touchEvent("touchstart", 100, 500)); + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 450)); + harness.runFrame(120); + harness.scrollDOM.dispatchEvent(touchEvent("touchend", 100, 450, 0)); + harness.runFrame(); + + expect(harness.controller.onScrollChanged()).toBe(true); + harness.scrollDOM.scrollTop = 700; + expect(harness.controller.onScrollChanged()).toBe(false); + } finally { + harness.controller.destroy(); + } + }); + + it("coalesces a post-settle highlight refresh after external scrolling", () => { + vi.useFakeTimers(); + const harness = controlledScrollHarness({ from: 0, to: 1200 }); + try { + harness.controller.onHighlightCoverageReady(); + harness.scrollDOM.scrollTop = 700; + expect(harness.controller.onScrollChanged()).toBe(false); + vi.advanceTimersByTime(121); + + expect(harness.log.filter((entry) => entry === "dispatch")).toEqual([ + "dispatch", + ]); + } finally { + harness.controller.destroy(); + vi.useRealTimers(); + } + }); + + it("does not fling after the finger pauses before release", () => { + const harness = controlledScrollHarness({ from: 0, to: 1200 }); + try { + harness.scrollDOM.dispatchEvent(touchEvent("touchstart", 100, 500)); + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 400)); + harness.runFrame(); + harness.runFrame(120); + harness.scrollDOM.dispatchEvent(touchEvent("touchend", 100, 400, 0)); + harness.runFrame(); + + expect(harness.controller.debugSnapshot().active).toBe(false); + expect(harness.controller.debugSnapshot().releaseVelocity).toBe(0); + } finally { + harness.controller.destroy(); + } + }); + + it("rolls a four-screen corridor through a longer fling", () => { + const bounds = { from: 0, to: 1200 }; + const harness = controlledScrollHarness(bounds, { + autoCover: true, + scrollHeight: 20_000, + }); + try { + startMomentum(harness); + for ( + let frame = 0; + frame < 200 && harness.controller.debugSnapshot().active; + frame++ + ) + harness.runFrame(); + + const result = harness.controller.debugSnapshot(); + expect(harness.scrollTop - 700).toBeGreaterThan(2400); + expect(result.corridorTo - (harness.scrollTop + 600)).toBeLessThanOrEqual( + 2400, + ); + expect(result.termination).toBe("completed"); + } finally { + harness.controller.destroy(); + } + }); + + it("limits delayed frames and permanently discards catch-up distance", () => { + const harness = controlledScrollHarness( + { from: 0, to: 10_000 }, + { scrollHeight: 20_000 }, + ); + try { + startMomentum(harness); + const beforeDelay = harness.scrollTop; + harness.runFrame(64); + const delayedStep = harness.scrollTop - beforeDelay; + expect(delayedStep).toBeGreaterThan(0); + expect(delayedStep).toBeLessThanOrEqual(256); + + const afterDelay = harness.scrollTop; + harness.runFrame(); + expect(harness.scrollTop - afterDelay).toBeLessThan(128); + for ( + let frame = 0; + frame < 200 && harness.controller.debugSnapshot().active; + frame++ + ) + harness.runFrame(); + expect(harness.controller.debugSnapshot().termination).toBe( + "render-limited", + ); + } finally { + harness.controller.destroy(); + } + }); + + it.each([ + 120, 250, 500, + ])("continues unrestricted momentum after a %ims frame", (delay) => { + const harness = controlledScrollHarness( + { from: 0, to: 1200 }, + { waitForRendering: false, scrollHeight: 100_000 }, + ); + try { + harness.scrollDOM.dispatchEvent(touchEvent("touchstart", 100, 500, 1, 0)); + harness.runFrame(); + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 400, 1, 16)); + harness.runFrame(); + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 300, 1, 32)); + harness.scrollDOM.dispatchEvent(touchEvent("touchend", 100, 300, 0, 32)); + const initial = harness.controller.debugSnapshot(); + expect(initial.active).toBe(true); + const before = harness.scrollTop; + harness.runFrame(delay); + const resumed = harness.controller.debugSnapshot(); + expect(resumed.active).toBe(true); + expect(resumed.termination).toBeNull(); + expect(harness.scrollTop).toBeGreaterThan(before); + for ( + let frame = 0; + frame < 300 && harness.controller.debugSnapshot().active; + frame++ + ) + harness.runFrame(frame % 5 === 0 ? delay : 16); + const result = harness.controller.debugSnapshot(); + expect(result.termination).toBe("completed"); + expect(result.committedDistance).toBeCloseTo( + initial.expectedDistance, + -1, + ); + } finally { + harness.controller.destroy(); + } + }); + + it("stops before moving after 120ms without covered progress", () => { + const harness = controlledScrollHarness( + { from: 0, to: 10_000 }, + { scrollHeight: 20_000 }, + ); + try { + startMomentum(harness); + const beforeStall = harness.scrollTop; + harness.runFrame(120); + + expect(harness.scrollTop).toBe(beforeStall); + expect(harness.controller.debugSnapshot().termination).toBe( + "render-limited", + ); + } finally { + harness.controller.destroy(); + } + }); + + it("ends momentum when rendered coverage cannot advance", () => { + const harness = controlledScrollHarness({ from: 0, to: 700 }); + try { + startMomentum(harness); + for (let frame = 0; frame < 8; frame++) harness.runFrame(); + + expect(harness.scrollTop).toBe(700); + expect(harness.controller.debugSnapshot().termination).toBe( + "render-limited", + ); + } finally { + harness.controller.destroy(); + } + }); + + it("stops momentum at the document edge without overscroll", () => { + const harness = controlledScrollHarness( + { from: 0, to: 700 }, + { scrollHeight: 1300 }, + ); + try { + harness.scrollDOM.dispatchEvent(touchEvent("touchstart", 100, 500)); + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 450)); + harness.runFrame(); + harness.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 400)); + harness.runFrame(); + harness.scrollDOM.dispatchEvent(touchEvent("touchend", 100, 400, 0)); + for (let frame = 0; frame < 20; frame++) harness.runFrame(); + + expect(harness.scrollTop).toBe(700); + expect(harness.controller.debugSnapshot().termination).toBe("edge"); + } finally { + harness.controller.destroy(); + } + }); + + it("keeps split editor controllers independent", () => { + const first = controlledScrollHarness(); + const second = controlledScrollHarness(); + try { + first.scrollDOM.dispatchEvent(touchEvent("touchstart", 100, 500)); + first.scrollDOM.dispatchEvent(touchEvent("touchmove", 100, 450)); + first.runFrame(); + + expect(first.scrollTop).toBe(550); + expect(second.scrollTop).toBe(500); + expect(second.log).toEqual([]); + } finally { + first.controller.destroy(); + second.controller.destroy(); + } + }); +}); diff --git a/tests/unit/codemirrorUpstream.test.js b/tests/unit/codemirrorUpstream.test.js new file mode 100644 index 0000000000..58bcb10cf0 --- /dev/null +++ b/tests/unit/codemirrorUpstream.test.js @@ -0,0 +1,180 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const temporaryRoots = []; +afterEach(() => { + for (const root of temporaryRoots.splice(0)) + fs.rmSync(root, { recursive: true, force: true }); +}); + +function fixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "acode-upstream-test-")); + temporaryRoots.push(root); + const upstream = path.join(root, "upstream"); + const vendor = path.join(root, "vendor", "codemirror-view"); + const script = path.join(root, "utils", "scripts", "codemirrorUpstream.mjs"); + fs.mkdirSync(path.dirname(script), { recursive: true }); + fs.copyFileSync( + new URL("../../utils/scripts/codemirrorUpstream.mjs", import.meta.url), + script, + ); + fs.mkdirSync(path.join(upstream, "src"), { recursive: true }); + fs.mkdirSync(path.join(upstream, "test")); + fs.writeFileSync( + path.join(upstream, "src", "index.ts"), + "export const value = 1;\n", + ); + for (const file of ["LICENSE", "README.md", "CHANGELOG.md"]) + fs.writeFileSync(path.join(upstream, file), file); + const manifest = { + name: "@codemirror/view", + version: "6.0.0", + files: ["dist"], + }; + fs.writeFileSync( + path.join(upstream, "package.json"), + JSON.stringify(manifest), + ); + function git(...args) { + return execFileSync("git", args, { + cwd: upstream, + encoding: "utf8", + }).trim(); + } + function commit() { + git("add", "."); + git( + "-c", + "user.name=Test", + "-c", + "user.email=test@example.test", + "-c", + "commit.gpgsign=false", + "commit", + "--quiet", + "-m", + "fixture", + ); + return git("rev-parse", "HEAD"); + } + git("init", "--quiet"); + const baseline = commit(); + fs.mkdirSync(vendor, { recursive: true }); + for (const file of [ + "src", + "test", + "LICENSE", + "README.md", + "CHANGELOG.md", + "package.json", + ]) { + fs.cpSync(path.join(upstream, file), path.join(vendor, file), { + recursive: true, + }); + } + fs.writeFileSync( + path.join(vendor, "src", "index.ts"), + "export const value = 2;\n", + ); + fs.writeFileSync( + path.join(vendor, "src", "mobile.ts"), + "export const mobile = true;\n", + ); + fs.writeFileSync( + path.join(root, "vendor", "upstream.json"), + JSON.stringify({ + view: { + version: "6.0.0", + commit: baseline, + repository: "https://example.test/view.git", + }, + }), + ); + fs.writeFileSync( + path.join(root, "package.json"), + JSON.stringify({ dependencies: { "@codemirror/view": "file:old.tgz" } }), + ); + function run(mode) { + return spawnSync(process.execPath, [script, mode, "view", upstream], { + encoding: "utf8", + }); + } + function upgrade(conflict = false) { + manifest.version = "6.0.1"; + fs.writeFileSync( + path.join(upstream, "package.json"), + JSON.stringify(manifest), + ); + if (conflict) + fs.writeFileSync( + path.join(upstream, "src", "index.ts"), + "export const value = 3;\n", + ); + else + fs.writeFileSync( + path.join(upstream, "src", "upstream.ts"), + "export const upstream = true;\n", + ); + return commit(); + } + return { root, vendor, upstream, run, upgrade }; +} + +describe("CodeMirror upstream patch workflow", () => { + it("replays edits and added files exactly and detects source drift", () => { + const f = fixture(); + expect(f.run("refresh").status).toBe(0); + expect(f.run("check").status).toBe(0); + fs.appendFileSync(path.join(f.vendor, "src", "index.ts"), "// drift\n"); + expect(f.run("check").stderr).toContain("Patch/source drift"); + }); + + it("upgrades in a disposable copy while retaining both upstream and local additions", () => { + const f = fixture(); + expect(f.run("refresh").status).toBe(0); + const commit = f.upgrade(); + const result = f.run("upgrade"); + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + expect( + fs.readFileSync(path.join(f.vendor, "src", "index.ts"), "utf8"), + ).toContain("value = 2"); + expect(fs.existsSync(path.join(f.vendor, "src", "upstream.ts"))).toBe(true); + expect(fs.existsSync(path.join(f.vendor, "src", "mobile.ts"))).toBe(true); + const metadata = JSON.parse( + fs.readFileSync(path.join(f.root, "vendor", "upstream.json"), "utf8"), + ); + expect(metadata.view.commit).toBe(commit); + expect(metadata.view.version).toBe("6.0.1"); + expect(f.run("check").status).toBe(0); + }); + + it("leaves source and metadata intact if the new upstream conflicts", () => { + const f = fixture(); + expect(f.run("refresh").status).toBe(0); + const metadataFile = path.join(f.root, "vendor", "upstream.json"); + const metadata = fs.readFileSync(metadataFile, "utf8"); + f.upgrade(true); + expect(f.run("upgrade").status).not.toBe(0); + expect(fs.readFileSync(metadataFile, "utf8")).toBe(metadata); + expect( + fs.readFileSync(path.join(f.vendor, "src", "index.ts"), "utf8"), + ).toContain("value = 2"); + }); + + it("refuses to discard local edits or use a dirty upstream checkout", () => { + const f = fixture(); + expect(f.run("refresh").status).toBe(0); + f.upgrade(); + fs.appendFileSync(path.join(f.vendor, "src", "mobile.ts"), "// unsaved\n"); + expect(f.run("upgrade").stderr).toContain("Vendored source has changed"); + fs.writeFileSync( + path.join(f.upstream, "untracked.txt"), + "work in progress", + ); + expect(f.run("upgrade").stderr).toContain("clean upstream checkout"); + }); +}); diff --git a/tests/unit/codemirrorVendorPackaging.test.js b/tests/unit/codemirrorVendorPackaging.test.js new file mode 100644 index 0000000000..5fa6eec9a1 --- /dev/null +++ b/tests/unit/codemirrorVendorPackaging.test.js @@ -0,0 +1,148 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { list } from "tar"; +import { describe, expect, it } from "vitest"; + +const root = fileURLToPath(new URL("../..", import.meta.url)); +const manifest = JSON.parse( + fs.readFileSync(path.join(root, "package.json"), "utf8"), +); +function packageArchive(name) { + return path.join(root, manifest.dependencies[name].replace(/^file:/, "")); +} +const packages = [ + { + name: "@codemirror/view", + files: [ + "package/package.json", + "package/README.md", + "package/CHANGELOG.md", + "package/LICENSE", + "package/LICENSE-APACHE-2.0", + "package/ANDROID-NOTICE.md", + "package/UPSTREAM.md", + "package/dist/index.js", + "package/dist/index.cjs", + "package/dist/index.d.ts", + "package/dist/index.d.cts", + ], + }, + { + name: "@codemirror/language", + files: [ + "package/package.json", + "package/README.md", + "package/CHANGELOG.md", + "package/LICENSE", + "package/UPSTREAM.md", + "package/dist/index.js", + "package/dist/index.cjs", + "package/dist/index.d.ts", + "package/dist/index.d.cts", + ], + }, +]; + +async function archiveEntries(archive) { + const entries = []; + await list({ + file: archive, + onReadEntry(entry) { + entries.push(entry.path); + entry.resume(); + }, + }); + return entries; +} + +describe("vendored CodeMirror packages", () => { + it.each([ + "view", + "language", + ])("has a current source receipt for %s", (name) => { + const packageRoot = path.join(root, "vendor", `codemirror-${name}`); + const receipt = JSON.parse( + fs.readFileSync( + path.join(root, "vendor", "patches", `${name}.source.json`), + "utf8", + ), + ); + const actual = {}; + function visit(relative) { + const file = path.join(packageRoot, relative); + if (fs.statSync(file).isDirectory()) { + for (const child of fs.readdirSync(file)) + visit(path.join(relative, child)); + } else + actual[relative] = createHash("sha256") + .update(fs.readFileSync(file)) + .digest("hex"); + } + for (const file of [ + "src", + "test", + "LICENSE", + "README.md", + "CHANGELOG.md", + "package.json", + ]) + visit(file); + expect(actual).toEqual(receipt); + }); + + it.each( + packages, + )("ships only compiled $name runtime and required metadata", async (pkg) => { + const archive = packageArchive(pkg.name); + const entries = await archiveEntries(archive); + + expect(entries.toSorted()).toEqual(pkg.files.toSorted()); + }); + + it("retains the complete Android license and Acode modification notice", () => { + const packageRoot = path.join(root, "vendor", "codemirror-view"); + const license = fs.readFileSync( + path.join(packageRoot, "LICENSE-APACHE-2.0"), + "utf8", + ); + const notice = fs.readFileSync( + path.join(packageRoot, "ANDROID-NOTICE.md"), + "utf8", + ); + expect(license).toContain( + "Apache License\n Version 2.0, January 2004", + ); + expect(license).toContain("END OF TERMS AND CONDITIONS"); + expect(notice).toContain("Android Open Source Project"); + expect(notice).toContain("Acode ported the calculations to TypeScript"); + }); + + it("matches the local archive integrity recorded in package-lock.json", () => { + const lock = JSON.parse( + fs.readFileSync(path.join(root, "package-lock.json"), "utf8"), + ); + for (const pkg of packages) { + const archive = fs.readFileSync(packageArchive(pkg.name)); + const integrity = `sha512-${createHash("sha512").update(archive).digest("base64")}`; + expect(lock.packages[`node_modules/${pkg.name}`].integrity).toBe( + integrity, + ); + const hash = createHash("sha256") + .update(archive) + .digest("hex") + .slice(0, 16); + expect(packageArchive(pkg.name)).toContain(`-acode.${hash}.tgz`); + } + }); + + it("does not leave emitted JavaScript beside upstream TypeScript tests", () => { + for (const packageName of ["codemirror-view", "codemirror-language"]) { + const testDirectory = path.join(root, "vendor", packageName, "test"); + expect( + fs.readdirSync(testDirectory).filter((file) => file.endsWith(".js")), + ).toEqual([]); + } + }); +}); diff --git a/tests/unit/compositorFrameAnalysis.test.js b/tests/unit/compositorFrameAnalysis.test.js new file mode 100644 index 0000000000..502a7ce1be --- /dev/null +++ b/tests/unit/compositorFrameAnalysis.test.js @@ -0,0 +1,42 @@ +import jpeg from "jpeg-js"; +import { describe, expect, it } from "vitest"; +import { + analyzeCompositorFrame, + parseCompositorColor, +} from "../../utils/benchmarks/compositorFrameAnalysis.mjs"; + +const setup = { + viewportWidth: 40, + viewportHeight: 40, + contentRect: { left: 0, top: 0, width: 40, height: 40 }, + backgrounds: ["rgba(0, 0, 0, 0)", "rgb(20, 20, 20)"], +}; + +function frameWithText(hasText) { + const width = 40; + const height = 40; + const data = Buffer.alloc(width * height * 4); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const offset = (y * width + x) * 4; + const text = hasText && x >= 4 && x < 22 && y >= 8 && y < 16; + data[offset] = data[offset + 1] = data[offset + 2] = text ? 230 : 20; + data[offset + 3] = 255; + } + } + return { + data: jpeg.encode({ data, width, height }, 100).data.toString("base64"), + }; +} + +describe("compositor frame analysis", () => { + it("ignores transparent candidates and parses the painted background", () => { + expect(parseCompositorColor("rgba(1, 2, 3, 0)")).toBeNull(); + expect(parseCompositorColor("rgb(20, 21, 22)")).toEqual([20, 21, 22]); + }); + + it("distinguishes background-only frames from rendered text", () => { + expect(analyzeCompositorFrame(frameWithText(false), setup).blank).toBe(true); + expect(analyzeCompositorFrame(frameWithText(true), setup).blank).toBe(false); + }); +}); diff --git a/tests/unit/editorBenchmarkCleanup.test.js b/tests/unit/editorBenchmarkCleanup.test.js new file mode 100644 index 0000000000..e51db3ae2b --- /dev/null +++ b/tests/unit/editorBenchmarkCleanup.test.js @@ -0,0 +1,98 @@ +import { EventEmitter } from "node:events"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + cleanupEditorFixture, + editorFixtureCleanupExpression, + installBenchmarkInterruptRecovery, + reportBenchmarkRecovery, +} from "../../utils/benchmarks/editorFixtures.mjs"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("editor benchmark fixture cleanup", () => { + it("restores the previous file, removes the fixture, and is idempotent", async () => { + const previousFile = { makeActive: vi.fn() }; + const file = { remove: vi.fn().mockResolvedValue(undefined) }; + const editorManager = { files: [previousFile, file] }; + const benchmark = { previousFile, file }; + + await expect(cleanupEditorFixture(benchmark, editorManager)).resolves.toBe(true); + expect(previousFile.makeActive).toHaveBeenCalledOnce(); + expect(file.remove).toHaveBeenCalledWith(true, { + ignorePinned: true, + suppressPanePlaceholder: true, + }); + + editorManager.files = [previousFile]; + await expect(cleanupEditorFixture(benchmark, editorManager)).resolves.toBe(true); + expect(file.remove).toHaveBeenCalledOnce(); + }); + + it("handles missing and partially initialized fixtures", async () => { + await expect(cleanupEditorFixture(null, null)).resolves.toBe(false); + await expect( + cleanupEditorFixture({ previousFile: null, file: null }, { files: [] }), + ).resolves.toBe(true); + }); + + it("still removes the fixture when restoring the previous file fails", async () => { + const restoreError = new Error("restore failed"); + const previousFile = { + makeActive: vi.fn(() => { + throw restoreError; + }), + }; + const file = { remove: vi.fn().mockResolvedValue(undefined) }; + const editorManager = { files: [previousFile, file] }; + + await expect( + cleanupEditorFixture({ previousFile, file }, editorManager), + ).rejects.toThrow("restore failed"); + expect(file.remove).toHaveBeenCalledOnce(); + }); + + it("clears benchmark globals even when fixture removal fails", async () => { + const cleanupError = new Error("remove failed"); + vi.stubGlobal("window", { + editorManager: { files: [] }, + __benchmark: { file: { remove: vi.fn().mockRejectedValue(cleanupError) } }, + __samples: [1], + }); + window.editorManager.files.push(window.__benchmark.file); + + const expression = editorFixtureCleanupExpression("__benchmark", ["__samples"]); + await expect(Function(`return ${expression}`)()).rejects.toThrow("remove failed"); + expect(window).not.toHaveProperty("__benchmark"); + expect(window).not.toHaveProperty("__samples"); + }); + + it("reports cleanup failures without replacing an existing benchmark error", () => { + const benchmarkError = new Error("benchmark failed"); + const cleanupError = new Error("cleanup failed"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + expect(() => reportBenchmarkRecovery(benchmarkError, [cleanupError])).not.toThrow(); + expect(consoleError).toHaveBeenCalledWith( + "Benchmark recovery failed:", + cleanupError, + ); + expect(() => reportBenchmarkRecovery(null, [cleanupError])).toThrow(AggregateError); + }); + + it("runs recovery before re-emitting an interrupt signal", async () => { + const processTarget = new EventEmitter(); + processTarget.pid = 123; + processTarget.kill = vi.fn(); + const recover = vi.fn().mockResolvedValue(undefined); + installBenchmarkInterruptRecovery(recover, processTarget); + + processTarget.emit("SIGINT"); + await vi.waitFor(() => expect(processTarget.kill).toHaveBeenCalledOnce()); + expect(recover).toHaveBeenCalledWith("SIGINT"); + expect(processTarget.kill).toHaveBeenCalledWith(123, "SIGINT"); + expect(processTarget.listenerCount("SIGTERM")).toBe(0); + }); +}); diff --git a/tests/unit/fileLanguage.test.ts b/tests/unit/fileLanguage.test.ts new file mode 100644 index 0000000000..67389d6b66 --- /dev/null +++ b/tests/unit/fileLanguage.test.ts @@ -0,0 +1,113 @@ +import { + getFileLanguageExtension, + preloadFileLanguage, + waitForFileLanguage, +} from "cm/fileLanguage"; +import { describe, expect, it, vi } from "vitest"; + +describe("restored file language preparation", () => { + it("loads in parallel with file I/O and supplies a synchronous extension for the first populated state", async () => { + let resolve!: (value: []) => void; + const extension: [] = []; + const provider = vi.fn( + () => + new Promise<[]>((done) => { + resolve = done; + }), + ); + const file = { currentLanguageExtension: provider }; + const preload = preloadFileLanguage(file); + let published = false; + const ready = waitForFileLanguage(file).then(() => { + published = true; + }); + await Promise.resolve(); + expect(provider).toHaveBeenCalledTimes(1); + expect(published).toBe(false); + resolve(extension); + await Promise.all([preload, ready]); + expect(getFileLanguageExtension(file)).toBe(extension); + expect(provider).toHaveBeenCalledTimes(1); + }); + it("bounds a never-settling provider without restarting it", async () => { + vi.useFakeTimers(); + try { + const pending = new Promise<[]>(() => {}); + const provider = vi.fn(() => pending); + const file = { currentLanguageExtension: provider }; + void preloadFileLanguage(file); + const published = vi.fn(); + const ready = waitForFileLanguage(file).then(published); + await vi.advanceTimersByTimeAsync(249); + expect(published).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + await ready; + expect(published).toHaveBeenCalledOnce(); + expect(getFileLanguageExtension(file)).toBe(pending); + expect(provider).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + it.each([ + false, + true, + ])("keeps late provider settlement handled (reject: %s)", async (rejects) => { + vi.useFakeTimers(); + try { + let resolve!: (value: []) => void; + let reject!: (error: Error) => void; + const pending = new Promise<[]>((done, fail) => { + resolve = done; + reject = fail; + }); + const file = { currentLanguageExtension: () => pending }; + const preload = preloadFileLanguage(file).catch(() => {}); + const ready = waitForFileLanguage(file); + await vi.advanceTimersByTimeAsync(250); + await ready; + const language = getFileLanguageExtension(file); + if (rejects) { + const handled = expect(language).rejects.toThrow("late failure"); + reject(new Error("late failure")); + await handled; + } else { + const extension: [] = []; + resolve(extension); + await expect(language).resolves.toBe(extension); + } + await preload; + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + it("does not reuse an old preparation after a mode change", async () => { + const oldExtension: [] = [], + nextExtension: [] = []; + const file = { currentLanguageExtension: () => oldExtension }; + await preloadFileLanguage(file); + file.currentLanguageExtension = () => nextExtension; + expect(getFileLanguageExtension(file)).toBe(nextExtension); + }); + it("does not cache startup extensions across later settings changes", async () => { + const extension: [] = []; + const provider = vi.fn(() => extension); + const file = { currentLanguageExtension: provider }; + await preloadFileLanguage(file); + getFileLanguageExtension(file); + getFileLanguageExtension(file); + expect(provider).toHaveBeenCalledTimes(2); + }); + it("lets restored text open if language loading fails", async () => { + const file = { + currentLanguageExtension: () => + Promise.reject(new Error("missing chunk")), + }; + const preload = preloadFileLanguage(file); + const ready = waitForFileLanguage(file); + await expect(preload).rejects.toThrow("missing chunk"); + await expect(ready).resolves.toBeUndefined(); + }); +}); diff --git a/tests/unit/keyboardInput.test.ts b/tests/unit/keyboardInput.test.ts new file mode 100644 index 0000000000..1260824f9d --- /dev/null +++ b/tests/unit/keyboardInput.test.ts @@ -0,0 +1,137 @@ +// @vitest-environment happy-dom +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { focusEditorIfEditable } from "cm/editorReadOnly"; +import { keyboardInput } from "cm/keyboardInput"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const show = vi.fn(); +let view: EditorView; +let apiDescriptor: PropertyDescriptor | undefined; +let policyDescriptor: PropertyDescriptor | undefined; +beforeEach(() => { + vi.spyOn(navigator, "userAgent", "get").mockReturnValue("Chrome/145 Android"); + apiDescriptor = Object.getOwnPropertyDescriptor(navigator, "virtualKeyboard"); + policyDescriptor = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + "virtualKeyboardPolicy", + ); + Object.defineProperty(navigator, "virtualKeyboard", { + configurable: true, + value: { show }, + }); + Object.defineProperty(HTMLElement.prototype, "virtualKeyboardPolicy", { + configurable: true, + value: "auto", + }); + show.mockClear(); +}); +afterEach(() => { + view?.destroy(); + document.body.replaceChildren(); + if (apiDescriptor) + Object.defineProperty(navigator, "virtualKeyboard", apiDescriptor); + else Reflect.deleteProperty(navigator, "virtualKeyboard"); + if (policyDescriptor) + Object.defineProperty( + HTMLElement.prototype, + "virtualKeyboardPolicy", + policyDescriptor, + ); + else Reflect.deleteProperty(HTMLElement.prototype, "virtualKeyboardPolicy"); + vi.restoreAllMocks(); +}); +function create(readOnly = false) { + view = new EditorView({ + parent: document.body, + state: EditorState.create({ + doc: "one\ntwo", + extensions: [keyboardInput, EditorState.readOnly.of(readOnly)], + }), + }); + return view; +} +function pointer(type: string, y = 100) { + view.contentDOM.dispatchEvent( + new PointerEvent(type, { + pointerId: 1, + pointerType: "touch", + isPrimary: true, + button: 0, + clientX: 20, + clientY: y, + bubbles: true, + }), + ); +} +function click() { + view.contentDOM.dispatchEvent( + new MouseEvent("click", { bubbles: true, detail: 1 }), + ); +} + +describe("explicit Android keyboard control", () => { + it("installs manual policy before finger-down and never requests the IME during a swipe", () => { + create(); + expect(view.contentDOM.getAttribute("virtualkeyboardpolicy")).toBe( + "manual", + ); + view.focus(); + const focus = vi.spyOn(view, "focus"); + const blur = vi.spyOn(view.contentDOM, "blur"); + pointer("pointerdown"); + expect(show).not.toHaveBeenCalled(); + pointer("pointermove", 200); + window.dispatchEvent(new Event("resize")); + pointer("pointermove", 100); // Returning to the start cannot turn a drag into a tap. + pointer("pointerup"); + click(); + view.dispatch({ selection: { anchor: 4 } }); + expect(show).not.toHaveBeenCalled(); + expect(focus).not.toHaveBeenCalled(); + expect(blur).not.toHaveBeenCalled(); + expect(view.hasFocus).toBe(true); + }); + it("shows on a completed tap, including when DOM focus survived keyboard dismissal", () => { + create(); + view.focus(); + for (let repeat = 0; repeat < 2; repeat++) { + pointer("pointerdown"); + expect(show).toHaveBeenCalledTimes(repeat); + pointer("pointerup"); + click(); + expect(show).toHaveBeenCalledTimes(repeat + 1); + } + }); + it("does not show after a cancelled touch or on a widget", () => { + create(); + pointer("pointerdown"); + pointer("pointercancel"); + click(); + const button = view.contentDOM.appendChild( + document.createElement("button"), + ); + button.click(); + expect(show).not.toHaveBeenCalled(); + }); + it("allows explicit editor commands and accessibility activation", () => { + create(); + focusEditorIfEditable(view); + expect(show).toHaveBeenCalledTimes(1); + view.contentDOM.click(); + expect(show).toHaveBeenCalledTimes(2); + }); + it("never requests the keyboard for read-only content", () => { + create(true); + pointer("pointerdown"); + pointer("pointerup"); + click(); + focusEditorIfEditable(view); + expect(show).not.toHaveBeenCalled(); + }); + it("keeps the browser default when the API is unavailable", () => { + Reflect.deleteProperty(navigator, "virtualKeyboard"); + create(); + expect(view.contentDOM.hasAttribute("virtualkeyboardpolicy")).toBe(false); + }); +}); diff --git a/tests/unit/keyboardScroll.test.ts b/tests/unit/keyboardScroll.test.ts new file mode 100644 index 0000000000..f09c014963 --- /dev/null +++ b/tests/unit/keyboardScroll.test.ts @@ -0,0 +1,185 @@ +// @vitest-environment happy-dom + +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { + keyboardScroll, + markKeyboardScrollBrowsing, + registerKeyboardCursorReveal, +} from "cm/keyboardScroll"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const views: EditorView[] = []; +afterEach(() => { + for (const view of views.splice(0)) view.destroy(); +}); + +function createView() { + const view = new EditorView({ + state: EditorState.create({ + doc: "one\ntwo\nthree", + extensions: [keyboardScroll], + }), + }); + views.push(view); + return view; +} + +function touch(type: string, y: number) { + const event = new Event(type, { bubbles: true }); + Object.defineProperty(event, "touches", { + value: [{ clientX: 100, clientY: y }], + }); + return event; +} + +function keyboardHarness(view: EditorView) { + const callbacks = new Map void>(); + const measurements: { + read: () => number | null; + write: (top: number | null) => void; + }[] = []; + Object.defineProperty(view, "hasFocus", { configurable: true, value: true }); + Object.defineProperty(view.scrollDOM, "clientHeight", { + configurable: true, + value: 300, + }); + vi.spyOn(view.scrollDOM, "getBoundingClientRect").mockReturnValue({ + top: 0, + } as DOMRect); + let initialTop: number | null = null; + const coords = vi.spyOn(view, "coordsAtPos").mockImplementation(() => { + initialTop ??= view.scrollDOM.scrollTop; + const delta = view.scrollDOM.scrollTop - initialTop; + return { top: 350 - delta, bottom: 370 - delta, left: 0, right: 1 }; + }); + vi.spyOn(view, "requestMeasure").mockImplementation((request: any) => { + if (request) measurements.push(request); + }); + let activeView: EditorView | null = view; + const cleanup = registerKeyboardCursorReveal({ + events: { + on: (name, callback) => { + callbacks.set(name, callback); + }, + off: (name) => { + callbacks.delete(name); + }, + }, + getView: () => activeView, + canReveal: () => true, + }); + return { + coords, + cleanup, + show: () => { + callbacks.get("keyboardShowStart")?.(); + callbacks.get("keyboardShow")?.(); + }, + hide: () => callbacks.get("keyboardHide")?.(), + changeView: (next: EditorView) => { + activeView = next; + }, + measure: () => { + for (const request of measurements.splice(0)) { + const top = request.read(); + // Writes must never call coordsAtPos (CodeMirror disallows layout reads there). + const reads = coords.mock.calls.length; + request.write(top); + expect(coords.mock.calls.length).toBe(reads); + } + }, + }; +} + +describe("keyboard scroll intent", () => { + it("preserves a browsed viewport through repeated keyboard open and close", () => { + const view = createView(); + const keyboard = keyboardHarness(view); + view.scrollDOM.dispatchEvent(touch("touchstart", 500)); + view.scrollDOM.dispatchEvent(touch("touchmove", 300)); + view.scrollDOM.dispatchEvent(touch("touchend", 300)); + view.scrollDOM.scrollTop = 900; + for (let i = 0; i < 3; i++) { + keyboard.show(); + keyboard.hide(); + keyboard.measure(); + } + expect(view.scrollDOM.scrollTop).toBe(900); + expect(keyboard.coords).not.toHaveBeenCalled(); + keyboard.cleanup(); + }); + + it("does not classify a tap as scrolling", () => { + const view = createView(); + view.scrollDOM.dispatchEvent(touch("touchstart", 500)); + view.scrollDOM.dispatchEvent(touch("touchmove", 499)); + view.scrollDOM.dispatchEvent(touch("touchend", 499)); + expect(view.plugin(keyboardScroll)?.browsing).toBe(false); + }); + + it("follows an explicit caret selection again and reveals only the obscured amount", () => { + const view = createView(); + const keyboard = keyboardHarness(view); + markKeyboardScrollBrowsing(view); + view.dispatch({ selection: { anchor: 5 } }); + view.scrollDOM.scrollTop = 500; + keyboard.show(); + keyboard.measure(); + expect(view.scrollDOM.scrollTop).toBe(594); + keyboard.hide(); + const top = view.scrollDOM.scrollTop; + keyboard.measure(); + expect(view.scrollDOM.scrollTop).toBe(top); + keyboard.cleanup(); + }); + + it("cancels a queued keyboard reveal when the user starts scrolling", () => { + const view = createView(); + const keyboard = keyboardHarness(view); + view.scrollDOM.scrollTop = 900; + keyboard.show(); + view.scrollDOM.dispatchEvent(new WheelEvent("wheel", { deltaY: 120 })); + keyboard.measure(); + expect(view.scrollDOM.scrollTop).toBe(900); + keyboard.cleanup(); + }); + + it("cancels a queued opening reveal if the keyboard closes before measurement", () => { + const view = createView(); + const keyboard = keyboardHarness(view); + view.scrollDOM.scrollTop = 900; + keyboard.show(); + keyboard.hide(); + keyboard.measure(); + expect(view.scrollDOM.scrollTop).toBe(900); + keyboard.cleanup(); + }); + + it("does not move a different pane or execute a disposed callback", () => { + const first = createView(), + second = createView(); + const keyboard = keyboardHarness(first); + first.scrollDOM.scrollTop = 500; + keyboard.show(); + keyboard.changeView(second); + keyboard.measure(); + expect(first.scrollDOM.scrollTop).toBe(500); + expect(second.scrollDOM.scrollTop).toBe(0); + keyboard.changeView(first); + keyboard.show(); + keyboard.cleanup(); + keyboard.measure(); + expect(first.scrollDOM.scrollTop).toBe(500); + }); + + it("clears browsing on typing while keeping other panes independent", () => { + const first = createView(), + second = createView(); + markKeyboardScrollBrowsing(first); + markKeyboardScrollBrowsing(second); + first.dispatch({ changes: { from: 0, insert: "x" } }); + expect(first.plugin(keyboardScroll)?.browsing).toBe(false); + expect(second.plugin(keyboardScroll)?.browsing).toBe(true); + }); +}); diff --git a/tests/unit/mainEditorExtensions.test.ts b/tests/unit/mainEditorExtensions.test.ts new file mode 100644 index 0000000000..fd006e1142 --- /dev/null +++ b/tests/unit/mainEditorExtensions.test.ts @@ -0,0 +1,45 @@ +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { + createMainEditorExtensions, + fixedHeightTheme, + renderingPerformanceExtensions, +} from "cm/mainEditorExtensions"; +import { describe, expect, it } from "vitest"; + +function themeRules(extension: unknown): string { + return (extension as Array<{ value?: { rules?: string[] } }>) + .flatMap((part) => part?.value?.rules ?? []) + .join("\n"); +} + +describe("main editor scroller theme", () => { + it("keeps the scroller free of persistent compositor containment", () => { + const rules = themeRules(fixedHeightTheme); + + expect(rules).toContain("height: 100%"); + expect(rules).toContain("overflow: auto"); + expect(rules).not.toContain("will-change"); + expect(rules).not.toContain("content-visibility"); + }); + + it("keeps movement independent of rendering with a two-screen directional buffer", () => { + const state = EditorState.create({ + extensions: renderingPerformanceExtensions, + }); + + expect(state.facet(EditorView.controlledTouchScroll)).toEqual({ + maxAhead: 2, + settleDelay: 120, + waitForRendering: false, + }); + }); + + it("can disable the rendering extensions for an upstream comparison", () => { + const state = EditorState.create({ + extensions: createMainEditorExtensions({ renderingExtensions: [] }), + }); + expect(state.facet(EditorView.controlledTouchScroll)).toBeNull(); + expect(state.facet(EditorView.viewportBuffer)).toBeNull(); + }); +}); diff --git a/tests/unit/readOnlySelection.test.ts b/tests/unit/readOnlySelection.test.ts new file mode 100644 index 0000000000..6414480e14 --- /dev/null +++ b/tests/unit/readOnlySelection.test.ts @@ -0,0 +1,99 @@ +// @vitest-environment happy-dom +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { createEditorReadOnlyExtension } from "cm/editorReadOnly"; +import createTouchSelectionMenu from "cm/touchSelectionMenu"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("lib/selectionMenu", () => ({ default: [] })); +let view: EditorView; +let menu: ReturnType; +afterEach(() => { + menu?.destroy(); + view?.destroy(); + document.body.replaceChildren(); + document.body.style.userSelect = ""; +}); +function create(readOnly = true) { + document.body.style.userSelect = "none"; + view = new EditorView({ + parent: document.body, + state: EditorState.create({ + doc: "select this text\nnext line", + extensions: [createEditorReadOnlyExtension(readOnly)], + }), + }); + menu = createTouchSelectionMenu(view); +} +function selectText() { + const text = view.contentDOM.querySelector(".cm-line")!.firstChild!; + const selection = document.getSelection()!; + selection.setBaseAndExtent(text, 0, text, 11); + document.dispatchEvent(new Event("selectionchange")); + return selection; +} + +describe("native read-only selection", () => { + it("overrides the app's non-selectable shell while staying non-editable", () => { + create(); + expect(getComputedStyle(view.contentDOM).userSelect).toBe("text"); + expect(view.contentDOM.getAttribute("contenteditable")).toBe("false"); + expect(view.state.readOnly).toBe(true); + }); + it("leaves long-press context selection and finger release to the browser", () => { + create(); + const selection = selectText(); + const context = new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + }); + view.contentDOM.dispatchEvent(context); + expect(context.defaultPrevented).toBe(false); + view.contentDOM.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + pointerId: 1, + isPrimary: true, + button: 0, + }), + ); + view.contentDOM.dispatchEvent( + new PointerEvent("pointerup", { + bubbles: true, + pointerId: 1, + isPrimary: true, + button: 0, + }), + ); + expect(selection.toString()).toBe("select this"); + expect(view.hasFocus).toBe(false); + expect(menu.isMenuVisible()).toBe(false); + }); + it("copies selected text while rejecting edit transactions", () => { + create(); + selectText(); + const data = new DataTransfer(); + view.contentDOM.dispatchEvent( + new ClipboardEvent("copy", { + bubbles: true, + cancelable: true, + clipboardData: data, + }), + ); + expect(data.getData("text/plain")).toBe("select this"); + view.dispatch({ + changes: { from: 0, to: 11, insert: "changed" }, + userEvent: "input.type", + }); + expect(view.state.doc.toString()).toBe("select this text\nnext line"); + }); + it("retains the app's custom context menu for editable text", () => { + create(false); + const context = new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + }); + view.contentDOM.dispatchEvent(context); + expect(context.defaultPrevented).toBe(true); + }); +}); diff --git a/tests/unit/restoreFiles.test.js b/tests/unit/restoreFiles.test.js index 339a347111..e531996a00 100644 --- a/tests/unit/restoreFiles.test.js +++ b/tests/unit/restoreFiles.test.js @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const runtime = vi.hoisted(() => ({ instances: [], + languageLoads: [], })); vi.mock("lib/editorFile", () => ({ @@ -9,6 +10,10 @@ vi.mock("lib/editorFile", () => ({ constructor(filename, options) { this.filename = filename; this.options = options; + this.currentLanguageExtension = () => { + runtime.languageLoads.push(filename); + return []; + }; this.loaded = new Promise((resolve) => { this.resolveLoad = resolve; }); @@ -26,6 +31,7 @@ import restoreFiles from "lib/restoreFiles"; describe("restored file loading", () => { beforeEach(() => { runtime.instances.length = 0; + runtime.languageLoads.length = 0; }); it("waits for every local tab and activates the last tab by default", async () => { @@ -38,6 +44,7 @@ describe("restored file loading", () => { }); expect(runtime.instances).toHaveLength(2); + expect(runtime.languageLoads).toEqual(["two.js"]); expect(runtime.instances.map((file) => file.options.render)).toEqual([ false, true, @@ -53,18 +60,20 @@ describe("restored file loading", () => { expect(completed).toBe(true); }); - it.each(["ftp", "sftp", "http", "https"])( - "does not block startup on an unresolved %s tab", - async (protocol) => { - await restoreFiles([ - { - id: "remote", - filename: "remote.js", - uri: `${protocol}://example.com/remote.js`, - }, - ]); + it.each([ + "ftp", + "sftp", + "http", + "https", + ])("does not block startup on an unresolved %s tab", async (protocol) => { + await restoreFiles([ + { + id: "remote", + filename: "remote.js", + uri: `${protocol}://example.com/remote.js`, + }, + ]); - expect(runtime.instances).toHaveLength(1); - }, - ); + expect(runtime.instances).toHaveLength(1); + }); }); diff --git a/utils/benchmarks/README.md b/utils/benchmarks/README.md new file mode 100644 index 0000000000..1f461241f3 --- /dev/null +++ b/utils/benchmarks/README.md @@ -0,0 +1,255 @@ +# CodeMirror rendering benchmarks + +## Current stabilization candidate + +The results below are historical measurements from the original experiment. +The current default uses direct finger tracking, touch-timestamp velocity, +two-screen directional buffering, and no movement gate. Keyboard transitions +preserve browsing intent. These changes have unit coverage but have not yet +been measured on an Android device. A rendering delay may expose incomplete +frames instead of shortening the gesture; record both latency and visual gaps. +The fling benchmark skips prewarming when movement gating is disabled. + +Before release, repeat the fling/edit/jump benchmarks on the same fixture, +CPU throttle, device, and WebView for baseline and candidate. Include a real +2–3 GB device. Compare gesture response and frame gaps as well as blank/plain +frames; preventing blanks by stopping movement is not a smoothness pass. + +Also exercise trusted touch interactions: tap to stop a fling and place the +caret, dismiss/reopen the keyboard, quick-tools focus, long-press selection and +handle dragging, horizontal gestures, a held vertical drag beyond one screen, +scrollbar jumps during momentum, go-to-line/search, tab and split-pane changes, +IME composition, and background/foreground transitions. Verify that caret +navigation wins over momentum and no delayed movement replays after release. +Use `renderingExtensions: []` in `createMainEditorExtensions` for an upstream +rendering comparison with the same application code. + +## Highlight-cache regression (2026-09-05) + +The provisional parser now keeps up to 12 completed 128-line windows and +prepares two windows beyond each edge. Viewport changes reuse these trees while +missing windows parse. Edits/language changes invalidate them, and canonical +syntax trees still take precedence. + +A standalone Chromium smoke test mounts the app's rendering extensions in a +30,000-line JavaScript fixture, jumps to the middle, and sends seven forward +and three reverse trusted-touch gestures. It samples visible DOM lines each +animation frame and fails for missing colors, blank viewports, runtime errors, +or no scroll movement. This checks DOM highlight coverage, not compositor +frames or Android keyboard behavior. + +```sh +node utils/benchmarks/editorHighlightScrollSmoke.mjs --browser=/path/to/chrome-headless-shell --cpu=4 +node utils/benchmarks/editorHighlightScrollSmoke.mjs --browser=/path/to/chrome-headless-shell --cpu=8 +``` + +Add `--focused=true` to keep DOM focus while scrolling away from the caret, +as happens when Android Back dismisses the IME. The test also fails if rendering +calls `focus()` or `blur()` after the initial explicit focus. Desktop emulation +cannot verify the physical keyboard's visibility. + +Use `--focused=true --resize-during-touch=true` to grow the viewport twice per +held gesture, before and after the scroll threshold. Each 480 px drag must +still move at least 400 px and produce no native click or focus/blur calls. +Use `--view-runtime=/absolute/path/to/previous/dist/index.js` for a view-package +comparison. This covers keyboard animation overlapping a drag, which the +original fixed-viewport focus check did not exercise. + +Use `--language-runtime=/absolute/path/to/previous/dist/index.js` to compare a +previous language package with identical view behavior. It builds in a temporary +directory and removes its fixture and browser profile afterward. + +Local Chromium results with a 412×720 Android-emulated viewport: + +| Language runtime | CPU throttle | Sampled frames | Frames with missing colors | +| --- | --- | --- | --- | +| Before window caching | 4× | 501 | 479 (entire viewport plain) | +| Window caching | 4× | 505 | 0 | +| Window caching | 8× | 431 | 0 | + +Canonical parsing remained before the test viewport throughout these runs, so +colors came from the provisional path. These are targeted desktop regression +measurements; real low-end Android results are still required. + +## Explicit keyboard input and restoration + +Main editors now use `virtualkeyboardpolicy="manual"` on Android when the +VirtualKeyboard API is available. Native focus and finger-down cannot request +the IME; a completed short tap, accessibility activation, or explicit editing +command calls `show()`. Pointer movement permanently disqualifies that gesture +as a tap, even if the finger returns to its starting position. Other WebViews +retain their default behavior. There is no polling, input-mode toggling, forced +blur/refocus, or change to keyboard viewport sizing. The extension uses public +CodeMirror APIs and requires no additional upstream patch. + +Reference: [Chromium virtual keyboard policy](https://developer.chrome.com/docs/web-platform/virtual-keyboard#the-virtual-keyboard-policy). + +The smoke test now mounts the actual main-editor extensions. Add +`--manual-keyboard=true --focused=true --resize-during-touch=true` to assert +that the manual policy is present, all swipes make zero keyboard requests, and +a subsequent trusted tap requests the keyboard exactly once. The desktop test +cannot display Android's real IME. The 4× throttled run passed all ten 480 px +drags, with zero keyboard requests and no plain frames in 599 samples; a +subsequent tap requested the keyboard once. + +Restore starts the foreground file's language provider alongside file reading. +The first populated state consumes the prepared language synchronously, rather +than first mounting plain text. Preparation is consumed once, so later mode or +settings changes are not stuck using the startup extension. Failures still let +the file open; remote files continue without blocking startup. + +Read-only editors explicitly override the application's `user-select: none` +with selectable text. Their context menu and selection handles are browser-owned; +the custom tap-to-caret handler and native-menu suppression timer no longer run +for read-only files. Document mutation guards and non-editable DOM stay enabled. +Run the smoke test with `--read-only-selection=true` to verify native pointer +selection across lines, matching editor selection state, unchanged text, and +zero keyboard requests. Native Android handle UI still needs device validation. + +## Running the benchmarks + +These scripts create disposable in-memory files and drive the real Acode +editor in a debuggable Android WebView. They restore the previously active +file and remove the fixture after each run. + +```sh +adb forward tcp:9222 localabstract:webview_devtools_remote_ +node utils/benchmarks/editorRenderingBenchmark.mjs --runs=10 --cpu=1 +node utils/benchmarks/editorFlingBenchmark.mjs --runs=10 --cpu=4 +node utils/benchmarks/editorEditHighlightBenchmark.mjs --runs=10 --cpu=1 +``` + +The rendering and fling scripts accept `--port`, `--cpu`, `--runs`, `--lines`, and +`--fixture=javascript|html|wrapped|long-line`. The rendering benchmark also +accepts `--wait=` and `--details=false`. The fling benchmark +accepts `--speed` and `--distance` in CSS pixels per second and CSS pixels. The +fling benchmark bounds the requested finger travel to 60% of the editor height +and sends trusted DevTools touch start/move/end events. It records DevTools +screencast frames and checks the actual composited editor region for +background-only frames; its older DOM-node counters remain as a secondary +diagnostic only. + +The edit/highlight benchmark accepts the shared fixture options plus `--edits`, +`--interval`, and `--wait`. It types into a disposable mid-document viewport +and records synchronous dispatch time, every-frame colored-line coverage, +provisional generation churn, and the exact-tree idle handoff. + +## 2026-08-31 Motorola edge 70 fusion results + +The pre-change APK used persistent `will-change: transform` and +`content-visibility: auto`. The retained candidate reports `auto` and +`visible`, respectively. + +| 120k-line JavaScript scrollbar jumps | Blank frames | Text p95 | Color p95 | Missing color | +| --- | ---: | ---: | ---: | ---: | +| Pre-change, native CPU, 10 runs | 0 | 79.7 ms | 265.0 ms | 9/30 | +| Retained candidate, native CPU, 10 runs | 0 | 137.2 ms | 137.2 ms | 0/30 | +| Rejected render-first candidate, native CPU, 10 runs | 0 | 149.2 ms | 149.2 ms | 0/30 | +| Rejected render-first candidate, 4x CPU, 10 runs | 0 | 231.0 ms | 503.0 ms | 0/30 | + +The synchronous render-first scrollbar candidate was removed because it +exceeded both the 100 ms p95 target and the 20% baseline-latency limit. + +| Retained candidate touch flings | Blank frames | Plain at rest | Largest sampled frame gap | +| --- | ---: | ---: | ---: | +| Native CPU, 10 runs | 0 | 0/10 | 397.8 ms | +| 4x CPU, 10 runs | 0 | 1/10 | 1416.7 ms | +| 6x CPU, 3 diagnostic runs | 0 | 0/3 | 623.2 ms | + +One-run disposable fixture probes produced no blank frames. At native CPU, +text/color latency was 147.1/187.7 ms for mixed HTML, 84.8/84.8 ms for +wrapped JavaScript, and 23.7/23.7 ms for very long lines. At 4x CPU it was +401.2/675.6 ms, 230.5/230.5 ms, and 103.2/103.2 ms, respectively. + +These measurements pass the native blank-frame gate but not the latency, +4x terminal-color, or five-second exact-tree handoff gates. The branch is an +experiment and is not release-ready. + +## Render-gated Android touch controller + +The final debug APK enables `controlledTouchScroll` with a four-screen cap. +DevTools confirmed computed `touch-action: pan-x`; `will-change` remained +`auto` and `content-visibility` remained `visible`. + +| Fixture | CPU | Runs | Composited blank frames | Plain at rest | Largest sampled gap | +| --- | ---: | ---: | ---: | ---: | ---: | +| 120k JavaScript | native | 10 | 0 | 0/10 | 419.7 ms | +| 120k JavaScript | 4x | 10 | 0 | 0/10 | 1237.9 ms | +| 120k mixed HTML/CSS/JS | native / 4x | 3 + 3 | 0 | 0/6 | 425.6 ms | +| 120k wrapped JavaScript | native / 4x | 3 + 3 | 0 | 6/6 | 11865.7 ms | +| 2000 very-long lines | native / 4x | 3 + 3 | 0 | 0/6 | 111.3 ms | +| 120k JavaScript diagnostic | 6x | 3 | 0 | 0/3 | 320.1 ms | + +The first wrapped-line candidate exposed a CodeMirror height-map correction +that converted a small controlled movement into a 395k-pixel scroll jump and +one background-only compositor frame. Clamping anchor corrections to the +already rendered bounds removed both the blank frame and the catch-up jump in +the retained build. The stress fixture still misses the input-latency and +provisional-highlighting gates, and native JavaScript gesture response was +90.8 ms p95 rather than the 50 ms target. These results support retaining the +experiment, not enabling it as a release default before testing on a real +2–3 GB Android device. + +## Android spline momentum candidate + +The owned momentum loop now follows Android API 36's `OverScroller` spline and +uses committed scroll positions for release velocity. Spline distance is +discarded after a frame delay above 32 ms or when prepared DOM ends; such runs +are reported as render-limited rather than being compared to the unconstrained +Android distance/duration model. CodeMirror scroll-anchor corrections are +pinned to the last controlled offset until the corridor is released. + +| Trusted-touch probe | Runs | Composited blank frames | Catch-up jumps | Plain at rest | Worst gesture response | +| --- | ---: | ---: | ---: | ---: | ---: | +| 120k JavaScript, native, medium | 10 | 0 | 0 | 0/10 | 543.8 ms | +| 120k JavaScript, 4x, medium | 10 | 0 | 0 | 0/10 | 935.9 ms | +| 120k JavaScript, 6x diagnostic, medium | 3 | 0 | 0 | 0/3 | 179.4 ms | +| HTML, wrapped, and long-line, native + 4x | 6 | 0 | 0 | 0/6 | 317.2 ms | + +Three fast native JavaScript flings also produced no compositor blanks or +catch-up jumps. All measured 120k-line spline runs encountered at least one +delayed frame and were therefore shortened or suppressed by the render gate; +there was no valid unconstrained physical run for the 15% distance/duration +comparison. Unit snapshots cover the exact Android model separately. + +The no-blank/no-catch-up objective passed these probes, but the 50 ms gesture +response and 100/150 ms stall gates did not. At 4x, the long-line fixture also +showed transient unhighlighted frames, though highlighting was present at +rest. This remains an experiment and still requires validation on a physical +2–3 GB Android device before release consideration. + +## 2026-08-31 Galaxy A51 stabilization candidate + +This candidate prewarms a one-screen symmetric DOM and highlight corridor, +retains mapped edit decorations until an atomic provisional replacement is +ready, and intersects momentum's safe range with both rendered and published +highlight coverage. Uncovered momentum is discarded rather than replayed. + +| Native 120k JavaScript probe | Runs | Blank frames | Plain frames | Catch-up jumps | +| --- | ---: | ---: | ---: | ---: | +| Alternating trusted-touch flings | 10 | 0 | 0 | 0 | +| Ten edits per run | 10 | n/a | 0 | n/a | + +Idle prewarming completed in all ten fling runs and took 325.6–553.1 ms after +the benchmark's stable-viewport gate. Every one of the 100 edit samples kept a +colored-line ratio of 1.0; maximum synchronous edit dispatch was 73.9 ms. +Single-run HTML, wrapped-line, and very-long-line fling probes also produced no +blank, plain, or catch-up frames. Their corresponding edit probes never showed +a completely plain frame. + +The visual-stability gates pass, but the release gates do not. Controller +first-movement p95 was approximately 76.0 ms (91.3 ms worst), browser-observed +gesture response was slower, and all ten momentum runs ended render-limited. +The largest sampled movement gap was 260.4 ms. Exact canonical highlighting did +not reach the midpoint within five seconds in any of the ten 120k JavaScript +runs, though the atomic provisional tree remained colored; the smaller +very-long-line fixture did hand off exactly. The Galaxy A51 used for this run +has roughly 6 GB RAM, so a genuine 2–3 GB device is still required before any +release decision. + +On the exact final APK, a one-run 2x CPU diagnostic retained zero blank/plain +frames but needed 109.9 ms for its first controlled movement. At 4x the idle +prewarm did not become ready within five seconds; the controller correctly +committed no movement and exposed no compositor blank, but the DOM sampler saw +21 plain initialization frames before colors recovered. The 4x diagnostic +therefore remains a failed stress gate. diff --git a/utils/benchmarks/compositorFrameAnalysis.mjs b/utils/benchmarks/compositorFrameAnalysis.mjs new file mode 100644 index 0000000000..66d2d6cdd2 --- /dev/null +++ b/utils/benchmarks/compositorFrameAnalysis.mjs @@ -0,0 +1,52 @@ +import jpeg from "jpeg-js"; + +export function parseCompositorColor(value) { + const match = /rgba?\(\s*(\d+)\D+(\d+)\D+(\d+)(?:\D+([\d.]+))?\s*\)/.exec( + value || "", + ); + if (!match || (match[4] != null && Number(match[4]) === 0)) return null; + return match.slice(1, 4).map(Number); +} + +export function analyzeCompositorFrame(frame, setup) { + const decoded = jpeg.decode(Buffer.from(frame.data, "base64"), { + useTArray: true, + formatAsRGBA: true, + }); + const scaleX = decoded.width / setup.viewportWidth; + const scaleY = decoded.height / setup.viewportHeight; + const rect = setup.contentRect; + const left = Math.max(0, Math.floor((rect.left + 4) * scaleX)); + const right = Math.min( + decoded.width, + Math.ceil((rect.left + rect.width * 0.85) * scaleX), + ); + const top = Math.max(0, Math.floor((rect.top + 6) * scaleY)); + const bottom = Math.min( + decoded.height, + Math.ceil((rect.top + rect.height - 6) * scaleY), + ); + const background = setup.backgrounds + .map(parseCompositorColor) + .find(Boolean); + if (!background || right <= left || bottom <= top) { + return { blank: false, nonBackgroundRatio: null }; + } + + let sampled = 0; + let nonBackground = 0; + for (let y = top; y < bottom; y += 2) { + for (let x = left; x < right; x += 2) { + const offset = (y * decoded.width + x) * 4; + const distance = Math.max( + Math.abs(decoded.data[offset] - background[0]), + Math.abs(decoded.data[offset + 1] - background[1]), + Math.abs(decoded.data[offset + 2] - background[2]), + ); + if (distance > 24) nonBackground++; + sampled++; + } + } + const nonBackgroundRatio = sampled ? nonBackground / sampled : 0; + return { blank: nonBackgroundRatio < 0.003, nonBackgroundRatio }; +} diff --git a/utils/benchmarks/editorEditHighlightBenchmark.mjs b/utils/benchmarks/editorEditHighlightBenchmark.mjs new file mode 100644 index 0000000000..06f6298de0 --- /dev/null +++ b/utils/benchmarks/editorEditHighlightBenchmark.mjs @@ -0,0 +1,306 @@ +#!/usr/bin/env node + +import WebSocket from "ws"; +import { + cleanupEditorFixture, + cleanupRemoteEditorFixture, + createEditorFixture, + editorFixtureNames, + installBenchmarkInterruptRecovery, + reportBenchmarkRecovery, +} from "./editorFixtures.mjs"; + +function readOption(name, fallback) { + const prefix = `--${name}=`; + const argument = process.argv.find((value) => value.startsWith(prefix)); + return argument ? argument.slice(prefix.length) : fallback; +} + +const port = Number(readOption("port", "9222")); +const cpuRate = Number(readOption("cpu", "1")); +const runs = Number(readOption("runs", "10")); +const edits = Number(readOption("edits", "10")); +const interval = Number(readOption("interval", "75")); +const lineCount = Number(readOption("lines", "120000")); +const maxWait = Number(readOption("wait", "5000")); +const fixture = readOption("fixture", "javascript"); +const includeMeasurements = readOption("details", "true") !== "false"; + +if (!editorFixtureNames.includes(fixture)) throw new Error(`Unknown fixture: ${fixture}`); +if ( + ![port, cpuRate, runs, edits, interval, lineCount, maxWait].every(Number.isFinite) || + port <= 0 || cpuRate < 1 || runs < 1 || edits < 1 || interval < 1 || + lineCount < 1 || maxWait < 1 +) { + throw new Error("Invalid numeric benchmark option"); +} + +class DevToolsClient { + constructor(url) { + this.nextId = 1; + this.pending = new Map(); + this.socket = new WebSocket(url); + } + + async open() { + await new Promise((resolve, reject) => { + this.socket.once("open", resolve); + this.socket.once("error", reject); + }); + this.socket.on("message", (data) => { + const message = JSON.parse(data.toString()); + const request = this.pending.get(message.id); + if (!request) return; + this.pending.delete(message.id); + if (message.error) request.reject(new Error(message.error.message)); + else request.resolve(message.result); + }); + } + + call(method, params = {}) { + const id = this.nextId++; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.socket.send(JSON.stringify({ id, method, params })); + }); + } + + close() { + this.socket.close(); + } +} + +async function findPage() { + const response = await fetch(`http://127.0.0.1:${port}/json/list`); + if (!response.ok) throw new Error(`DevTools endpoint returned ${response.status}`); + const targets = await response.json(); + const page = targets.find( + (target) => target.type === "page" && target.url?.includes("index.html"), + ); + if (!page?.webSocketDebuggerUrl) throw new Error("No debuggable Acode page found"); + return page; +} + +function benchmarkExpression() { + const options = JSON.stringify({ fixture, lineCount, runs, edits, interval, maxWait }); + const fixtureFactory = createEditorFixture.toString(); + const fixtureCleanup = cleanupEditorFixture.toString(); + return ` + (async () => { + const options = ${options}; + const cleanupEditorFixture = (${fixtureCleanup}); + const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const frame = () => new Promise((resolve) => requestAnimationFrame(resolve)); + const waitFor = async (predicate, timeout = 10000) => { + const started = performance.now(); + while (!predicate()) { + if (performance.now() - started > timeout) throw new Error("Timed out waiting for editor state"); + await wait(25); + } + }; + + await waitFor(() => window.editorManager?.editor && window.acode); + const previousFile = window.editorManager.activeFile; + const EditorFile = window.acode.require("editorFile"); + const language = window.acode.require("@codemirror/language"); + const fixture = (${fixtureFactory})(options.fixture, options.lineCount); + const file = new EditorFile(fixture.name, { + text: fixture.text, + render: true, + isUnsaved: false, + persistInSession: false, + }); + window.__acodeEditHighlightBenchmark = { file, previousFile }; + + let benchmarkError = null; + try { + await waitFor(() => window.editorManager.activeFile?.id === file.id); + await wait(1000); + const view = window.editorManager.editor; + const scroller = view.scrollDOM; + const debugSymbol = Symbol.for("@codemirror/language.viewportPriorityDebug"); + const sample = () => { + const viewport = scroller.getBoundingClientRect(); + const visible = Array.from(view.contentDOM.querySelectorAll(".cm-line")).filter((line) => { + const rect = line.getBoundingClientRect(); + return rect.bottom > viewport.top + 1 && rect.top < viewport.bottom - 1; + }); + const colored = visible.filter((line) => line.querySelector("span[class]")).length; + return { + at: performance.now(), + visible: visible.length, + colored, + coloredRatio: visible.length ? colored / visible.length : 0, + exact: language.syntaxTreeAvailable(view.state, view.viewport.to), + parse: view[debugSymbol]?.() || null, + }; + }; + + scroller.dispatchEvent(new Event("scroll")); + let scrollbar = null; + for (let attempt = 0; attempt < 20 && !scrollbar; attempt++) { + await frame(); + scrollbar = document.querySelector(".scrollbar-container.right"); + } + if (typeof scrollbar?.onScroll !== "function") + throw new Error("Acode vertical scrollbar is not available"); + scrollbar.onScroll(0.5); + await waitFor(() => sample().colored > 0, options.maxWait); + + const measurements = []; + for (let run = 0; run < options.runs; run++) { + const editSamples = []; + for (let edit = 0; edit < options.edits; edit++) { + const line = view.state.doc.lineAt(Math.max(1, view.viewport.from)); + const position = Math.min(line.to, line.from + 6); + const before = sample(); + const dispatchStarted = performance.now(); + view.dispatch({ + changes: { from: position, insert: "x" }, + selection: { anchor: position + 1 }, + userEvent: "input.type", + }); + const dispatchMs = performance.now() - dispatchStarted; + const frames = [{ phase: "immediate", ...sample() }]; + const until = performance.now() + options.interval; + while (performance.now() < until) { + await frame(); + frames.push({ phase: "frame", ...sample() }); + } + editSamples.push({ edit: edit + 1, dispatchMs, before, frames }); + } + + const idleStarted = performance.now(); + while (!sample().exact && performance.now() - idleStarted < options.maxWait) + await wait(25); + const final = sample(); + const frames = editSamples.flatMap((entry) => entry.frames); + const generations = editSamples.flatMap((entry) => [ + entry.before.parse?.generation, + ...entry.frames.map((item) => item.parse?.generation), + ]).filter(Number.isFinite); + measurements.push({ + run: run + 1, + plainFrames: frames.filter((item) => item.visible > 0 && item.colored === 0).length, + plainEdits: editSamples.filter((entry) => entry.frames.some( + (item) => item.visible > 0 && item.colored === 0, + )).map((entry) => entry.edit), + plainBeforeEdits: editSamples.filter( + (entry) => entry.before.visible > 0 && entry.before.colored === 0, + ).map((entry) => entry.edit), + minimumColoredRatio: frames.reduce( + (minimum, item) => item.visible ? Math.min(minimum, item.coloredRatio) : minimum, + 1, + ), + maximumDispatchMs: Math.max(...editSamples.map((item) => item.dispatchMs)), + generationDelta: generations.length + ? Math.max(...generations) - Math.min(...generations) + : null, + exactAfterIdle: final.exact, + exactHandoffMs: final.exact ? final.at - idleStarted : null, + finalParse: final.parse, + editSamples, + }); + } + + const result = { + fixture: options.fixture, + lineCount: view.state.doc.lines, + runs: measurements.map((item) => ({ + run: item.run, + plainFrames: item.plainFrames, + plainEdits: item.plainEdits, + plainBeforeEdits: item.plainBeforeEdits, + minimumColoredRatio: item.minimumColoredRatio, + maximumDispatchMs: item.maximumDispatchMs, + generationDelta: item.generationDelta, + exactAfterIdle: item.exactAfterIdle, + finalParse: item.finalParse, + ...(${includeMeasurements} ? {edits: item.editSamples.map((entry) => ({ + edit: entry.edit, + beforeRatio: entry.before.coloredRatio, + minimumRatio: entry.frames.reduce( + (minimum, frame) => Math.min(minimum, frame.coloredRatio), + 1, + ), + beforeParse: entry.before.parse, + immediateParse: entry.frames[0]?.parse || null, + finalParse: entry.frames.at(-1)?.parse || null, + }))} : {}), + })), + summary: { + plainFrames: measurements.reduce((sum, item) => sum + item.plainFrames, 0), + minimumColoredRatio: Math.min(...measurements.map((item) => item.minimumColoredRatio)), + maximumDispatchMs: Math.max(...measurements.map((item) => item.maximumDispatchMs)), + missingExactHandoffs: measurements.filter((item) => !item.exactAfterIdle).length, + }, + }; + if (${includeMeasurements}) result.measurements = measurements; + return result; + } catch (error) { + benchmarkError = error; + throw error; + } finally { + try { + await cleanupEditorFixture(window.__acodeEditHighlightBenchmark, window.editorManager); + } catch (cleanupError) { + if (benchmarkError) console.error("Benchmark fixture cleanup failed:", cleanupError); + else throw cleanupError; + } finally { + delete window.__acodeEditHighlightBenchmark; + } + } + })() + `; +} + +const page = await findPage(); +const client = new DevToolsClient(page.webSocketDebuggerUrl); +await client.open(); + +let benchmarkError = null; +let recoveryPromise = null; +const recover = () => recoveryPromise ??= (async () => { + const recoveryErrors = []; + try { + await cleanupRemoteEditorFixture(client, "__acodeEditHighlightBenchmark"); + } catch (error) { + recoveryErrors.push(error); + } + try { + await client.call("Emulation.setCPUThrottlingRate", { rate: 1 }); + } catch (error) { + recoveryErrors.push(error); + } + client.close(); + return recoveryErrors; +})(); +const removeInterruptRecovery = installBenchmarkInterruptRecovery( + async (signal) => { + const recoveryErrors = await recover(); + reportBenchmarkRecovery(new Error(`Benchmark interrupted by ${signal}`), recoveryErrors); + }, +); +try { + await client.call("Runtime.enable"); + await cleanupRemoteEditorFixture(client, "__acodeEditHighlightBenchmark"); + await client.call("Emulation.setCPUThrottlingRate", { rate: cpuRate }); + const evaluation = await client.call("Runtime.evaluate", { + expression: benchmarkExpression(), + awaitPromise: true, + returnByValue: true, + }); + if (evaluation.exceptionDetails) { + throw new Error( + evaluation.exceptionDetails.exception?.description || evaluation.exceptionDetails.text, + ); + } + console.log(JSON.stringify({ cpuRate, ...evaluation.result.value }, null, 2)); +} catch (error) { + benchmarkError = error; + throw error; +} finally { + removeInterruptRecovery(); + const recoveryErrors = await recover(); + reportBenchmarkRecovery(benchmarkError, recoveryErrors); +} diff --git a/utils/benchmarks/editorFixtures.mjs b/utils/benchmarks/editorFixtures.mjs new file mode 100644 index 0000000000..c8932c5b95 --- /dev/null +++ b/utils/benchmarks/editorFixtures.mjs @@ -0,0 +1,146 @@ +export const editorFixtureNames = ["javascript", "html", "wrapped", "long-line"]; + +export async function cleanupEditorFixture(benchmark, editorManager) { + if (!benchmark) return false; + const { file, previousFile } = benchmark; + const files = editorManager?.files; + const errors = []; + try { + if (previousFile && files?.includes(previousFile)) previousFile.makeActive(); + } catch (error) { + errors.push(error); + } + try { + if (file && (!files || files.includes(file))) { + await file.remove(true, { + ignorePinned: true, + suppressPanePlaceholder: true, + }); + } + } catch (error) { + errors.push(error); + } + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) { + throw new AggregateError(errors, "Editor fixture cleanup failed"); + } + return true; +} + +export function editorFixtureCleanupExpression(globalKey, extraGlobalKeys = []) { + const globalKeys = [globalKey, ...extraGlobalKeys]; + const deletes = globalKeys + .map((key) => `delete window[${JSON.stringify(key)}];`) + .join("\n"); + return ` + (async () => { + const cleanupEditorFixture = (${cleanupEditorFixture.toString()}); + const benchmark = window[${JSON.stringify(globalKey)}]; + try { + return await cleanupEditorFixture(benchmark, window.editorManager); + } finally { + ${deletes} + } + })() + `.trim(); +} + +export async function cleanupRemoteEditorFixture( + client, + globalKey, + extraGlobalKeys = [], +) { + const evaluation = await client.call("Runtime.evaluate", { + expression: editorFixtureCleanupExpression(globalKey, extraGlobalKeys), + awaitPromise: true, + returnByValue: true, + }); + if (evaluation.exceptionDetails) { + throw new Error( + evaluation.exceptionDetails.exception?.description || + evaluation.exceptionDetails.text, + ); + } + return evaluation.result?.value ?? false; +} + +export function reportBenchmarkRecovery(benchmarkError, recoveryErrors) { + if (!recoveryErrors.length) return; + if (benchmarkError) { + console.error("Benchmark recovery failed:", ...recoveryErrors); + return; + } + throw new AggregateError(recoveryErrors, "Benchmark recovery failed"); +} + +export function installBenchmarkInterruptRecovery(recover, processTarget = process) { + let handling = false; + const handlers = new Map(); + const remove = () => { + for (const [signal, handler] of handlers) { + processTarget.off(signal, handler); + } + }; + for (const signal of ["SIGINT", "SIGTERM"]) { + const handler = () => { + if (handling) return; + handling = true; + remove(); + Promise.resolve(recover(signal)) + .catch((error) => { + console.error("Benchmark interrupt recovery failed:", error); + }) + .finally(() => processTarget.kill(processTarget.pid, signal)); + }; + handlers.set(signal, handler); + processTarget.once(signal, handler); + } + return remove; +} + +export function createEditorFixture(fixture, lineCount) { + if (fixture === "html") { + const text = Array.from({ length: lineCount }, (_, index) => { + const row = Math.floor(index / 8) + 1; + switch (index % 8) { + case 0: + return ""; + case 3: + return '
{{ value' + row + " }}
"; + case 4: + return ""; + } + }).join("\n"); + return { name: "__cm_render_html__.html", text }; + } + if (fixture === "wrapped") { + const text = Array.from({ length: lineCount }, (_, index) => { + const row = index + 1; + return "const wrapped" + row + ' = "' + "content ".repeat(30) + row + '";'; + }).join("\n"); + return { name: "__cm_render_wrapped__.js", text }; + } + if (fixture === "long-line") { + const rows = Math.min(lineCount, 2000); + const text = Array.from({ length: rows }, (_, index) => { + const row = index + 1; + return "const long" + row + ' = "' + "x".repeat(8000) + '";'; + }).join("\n"); + return { name: "__cm_render_long-line__.js", text }; + } + const text = Array.from({ length: lineCount }, (_, index) => { + const row = index + 1; + return "const v" + row + " = fn(" + row + ', "x"); // ' + row; + }).join("\n"); + return { name: "__cm_render_javascript__.js", text }; +} diff --git a/utils/benchmarks/editorFlingBenchmark.mjs b/utils/benchmarks/editorFlingBenchmark.mjs new file mode 100644 index 0000000000..9ec207e2f3 --- /dev/null +++ b/utils/benchmarks/editorFlingBenchmark.mjs @@ -0,0 +1,557 @@ +#!/usr/bin/env node + +import WebSocket from "ws"; +import { analyzeCompositorFrame } from "./compositorFrameAnalysis.mjs"; +import { + cleanupRemoteEditorFixture, + createEditorFixture, + editorFixtureNames, + installBenchmarkInterruptRecovery, + reportBenchmarkRecovery, +} from "./editorFixtures.mjs"; + +function readOption(name, fallback) { + const prefix = `--${name}=`; + const argument = process.argv.find((value) => value.startsWith(prefix)); + return argument ? argument.slice(prefix.length) : fallback; +} + +const port = Number(readOption("port", "9222")); +const cpuRate = Number(readOption("cpu", "1")); +const runs = Number(readOption("runs", "10")); +const lineCount = Number(readOption("lines", "120000")); +const fixture = readOption("fixture", "javascript"); +const speed = Number(readOption("speed", "12000")); +const distance = Number(readOption("distance", "6000")); + +if ([port, cpuRate, runs, lineCount, speed, distance].some((value) => !Number.isFinite(value) || value <= 0)) { + throw new Error("Invalid numeric benchmark option"); +} +if (!editorFixtureNames.includes(fixture)) throw new Error(`Unknown fixture: ${fixture}`); + +class DevToolsClient { + constructor(url) { + this.nextId = 1; + this.pending = new Map(); + this.listeners = new Map(); + this.socket = new WebSocket(url); + } + + async open() { + await new Promise((resolve, reject) => { + this.socket.once("open", resolve); + this.socket.once("error", reject); + }); + this.socket.on("message", (data) => { + const message = JSON.parse(data.toString()); + if (message.method) { + for (const listener of this.listeners.get(message.method) || []) { + listener(message.params); + } + return; + } + const request = this.pending.get(message.id); + if (!request) return; + this.pending.delete(message.id); + if (message.error) request.reject(new Error(message.error.message)); + else request.resolve(message.result); + }); + } + + on(method, listener) { + const listeners = this.listeners.get(method) || []; + listeners.push(listener); + this.listeners.set(method, listeners); + } + + call(method, params = {}) { + const id = this.nextId++; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.socket.send(JSON.stringify({ id, method, params })); + }); + } + + close() { + this.socket.close(); + } +} + +async function evaluate(client, expression, awaitPromise = true) { + const result = await client.call("Runtime.evaluate", { + expression, + awaitPromise, + returnByValue: true, + }); + if (result.exceptionDetails) { + throw new Error( + result.exceptionDetails.exception?.description || result.exceptionDetails.text, + ); + } + return result.result.value; +} + +async function dispatchTouchSwipe(client, setup, run) { + const touchDistance = Math.min(distance, setup.scrollerHeight * 0.6); + const scrollDirection = run % 2 ? -1 : 1; + const startY = setup.y + scrollDirection * touchDistance / 2; + const endY = setup.y - scrollDirection * touchDistance / 2; + const duration = Math.max(48, touchDistance / speed * 1000); + const steps = Math.max(3, Math.ceil(duration / 16)); + const point = (y) => ({ + x: setup.x, + y, + radiusX: 1, + radiusY: 1, + rotationAngle: 0, + force: 1, + id: 0, + }); + await client.call("Input.dispatchTouchEvent", { + type: "touchStart", + touchPoints: [point(startY)], + }); + for (let step = 1; step <= steps; step++) { + await new Promise((resolve) => setTimeout(resolve, duration / steps)); + await client.call("Input.dispatchTouchEvent", { + type: "touchMove", + touchPoints: [point(startY + (endY - startY) * step / steps)], + }); + } + await client.call("Input.dispatchTouchEvent", { + type: "touchEnd", + touchPoints: [], + }); + return { touchDistance, duration }; +} + +const targets = await fetch(`http://127.0.0.1:${port}/json/list`).then((response) => response.json()); +const page = targets.find((target) => target.type === "page" && target.url?.includes("index.html")); +if (!page?.webSocketDebuggerUrl) throw new Error("No debuggable Acode page found"); + +const client = new DevToolsClient(page.webSocketDebuggerUrl); +await client.open(); +const fixtureFactory = createEditorFixture.toString(); + +let activeScreencast = null; +let benchmarkError = null; +client.on("Page.screencastFrame", (params) => { + client.call("Page.screencastFrameAck", { sessionId: params.sessionId }).catch( + () => {}, + ); + if (activeScreencast) { + activeScreencast.push({ + data: params.data, + at: performance.now(), + }); + } +}); + +let recoveryPromise = null; +const recover = () => recoveryPromise ??= (async () => { + const recoveryErrors = []; + activeScreencast = null; + try { + await cleanupRemoteEditorFixture(client, "__acodeFlingBenchmark", [ + "__acodeFlingSamples", + "__acodeFlingStartTop", + "__acodeFlingGeneration", + "__acodeFlingStarted", + ]); + } catch (error) { + recoveryErrors.push(error); + } + try { + await client.call("Page.stopScreencast"); + } catch (error) { + recoveryErrors.push(error); + } + try { + await client.call("Emulation.setCPUThrottlingRate", { rate: 1 }); + } catch (error) { + recoveryErrors.push(error); + } + client.close(); + return recoveryErrors; +})(); +const removeInterruptRecovery = installBenchmarkInterruptRecovery( + async (signal) => { + const recoveryErrors = await recover(); + reportBenchmarkRecovery(new Error(`Benchmark interrupted by ${signal}`), recoveryErrors); + }, +); + +try { + await client.call("Runtime.enable"); + await client.call("Page.enable"); + await cleanupRemoteEditorFixture(client, "__acodeFlingBenchmark", [ + "__acodeFlingSamples", + "__acodeFlingStartTop", + "__acodeFlingGeneration", + "__acodeFlingStarted", + ]); + await client.call("Emulation.setCPUThrottlingRate", { rate: cpuRate }); + const setup = await evaluate(client, ` + (async () => { + const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const waitFor = async (predicate) => { + const started = performance.now(); + while (!predicate()) { + if (performance.now() - started > 10000) throw new Error("Timed out waiting for Acode editor"); + await wait(50); + } + }; + await waitFor(() => window.editorManager?.editor && window.acode); + const previousFile = window.editorManager.activeFile; + const EditorFile = window.acode.require("editorFile"); + const fixture = (${fixtureFactory})(${JSON.stringify(fixture)}, ${lineCount}); + const file = new EditorFile(fixture.name, { + text: fixture.text, + render: true, + isUnsaved: false, + persistInSession: false, + }); + window.__acodeFlingBenchmark = { file, previousFile }; + await waitFor(() => window.editorManager.activeFile?.id === file.id); + await wait(1000); + const scroller = window.editorManager.editor.scrollDOM; + const rect = scroller.getBoundingClientRect(); + const contentRect = window.editorManager.editor.contentDOM.getBoundingClientRect(); + return { + x: Math.round(rect.left + rect.width / 2), + y: Math.round(rect.top + rect.height / 2), + scrollerHeight: rect.height, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + contentRect: { + left: contentRect.left, + top: contentRect.top, + width: contentRect.width, + height: contentRect.height, + }, + backgrounds: [ + getComputedStyle(window.editorManager.editor.contentDOM).backgroundColor, + getComputedStyle(scroller).backgroundColor, + getComputedStyle(window.editorManager.editor.dom).backgroundColor, + getComputedStyle(document.body).backgroundColor, + ], + touchAction: getComputedStyle(scroller).touchAction, + controlledTouchScroll: window.editorManager.editor.state.facet( + window.editorManager.editor.constructor.controlledTouchScroll, + ), + controllerDebug: { + present: !!window.editorManager.editor.controlledTouchScrollController, + snapshotType: typeof window.editorManager.editor.controlledTouchScrollController?.debugSnapshot, + }, + willChange: getComputedStyle(scroller).willChange, + contentVisibility: getComputedStyle(scroller).contentVisibility, + }; + })() + `); + await client.call("Page.startScreencast", { + format: "jpeg", + quality: 75, + maxWidth: Math.ceil(setup.viewportWidth * 2), + maxHeight: Math.ceil(setup.viewportHeight * 2), + everyNthFrame: 1, + }); + + const measurements = []; + for (let run = 0; run < runs; run++) { + const prewarm = await evaluate(client, ` + (async () => { + const view = window.editorManager.editor; + const scroller = view.scrollDOM; + const frame = () => new Promise((resolve) => requestAnimationFrame(resolve)); + scroller.scrollTop = (scroller.scrollHeight - scroller.clientHeight) * 0.5; + const started = performance.now(); + let previousTop = -1; + let previousHeight = -1; + let stableFrames = 0; + while (stableFrames < 4) { + await frame(); + const viewport = scroller.getBoundingClientRect(); + const hasVisibleText = Array.from(view.contentDOM.querySelectorAll(".cm-line")).some((line) => { + const rect = line.getBoundingClientRect(); + return rect.bottom > viewport.top + 1 && rect.top < viewport.bottom - 1; + }); + const stable = hasVisibleText && Math.abs(scroller.scrollTop - previousTop) < 1 && + Math.abs(scroller.scrollHeight - previousHeight) < 1; + stableFrames = stable ? stableFrames + 1 : 0; + previousTop = scroller.scrollTop; + previousHeight = scroller.scrollHeight; + if (performance.now() - started > 15000) + throw new Error("Timed out waiting for a stable rendered viewport"); + } + const prewarmStarted = performance.now(); + const prewarmRequired = view.controlledTouchScrollController?.debugSnapshot?.().waitForRendering !== false; + while ( + prewarmRequired && + !view.controlledTouchScrollController?.debugSnapshot?.().idlePrewarmReady && + performance.now() - prewarmStarted < 5000 + ) await frame(); + return { + required: prewarmRequired, + ready: !!view.controlledTouchScrollController?.debugSnapshot?.().idlePrewarmReady, + waitMs: performance.now() - prewarmStarted, + }; + })() + `); + const beforeGesture = await evaluate( + client, + "window.editorManager.editor.controlledTouchScrollController?.debugSnapshot?.() || null", + ); + await evaluate(client, ` + (() => { + const view = window.editorManager.editor; + const scroller = view.scrollDOM; + window.__acodeFlingSamples = []; + window.__acodeFlingStartTop = scroller.scrollTop; + const generation = window.__acodeFlingGeneration = (window.__acodeFlingGeneration || 0) + 1; + const started = window.__acodeFlingStarted = performance.now(); + const sample = () => { + if (window.__acodeFlingGeneration !== generation) return; + const elapsed = performance.now() - started; + const viewport = scroller.getBoundingClientRect(); + const lines = Array.from(view.contentDOM.querySelectorAll(".cm-line")).filter((line) => { + const rect = line.getBoundingClientRect(); + return rect.bottom > viewport.top + 1 && rect.top < viewport.bottom - 1; + }); + const momentum = view.controlledTouchScrollController?.debugSnapshot?.() || null; + window.__acodeFlingSamples.push({ + at: elapsed, + visibleLines: lines.length, + highlightedLines: lines.filter((line) => line.querySelector("span[class]")).length, + scrollTop: scroller.scrollTop, + clientHeight: scroller.clientHeight, + momentum, + }); + if (elapsed < 10000 && (elapsed < 800 || momentum?.active || !momentum?.termination)) + requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + })() + `, false); + + activeScreencast = []; + const compositorStarted = performance.now(); + const dispatchedGesture = await dispatchTouchSwipe(client, setup, run); + await evaluate(client, ` + (async () => { + const started = performance.now(); + while (performance.now() - started < 5000) { + const momentum = window.editorManager.editor.controlledTouchScrollController?.debugSnapshot?.(); + if (momentum?.termination && !momentum.active) break; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + await new Promise((resolve) => setTimeout(resolve, 150)); + })() + `); + const finalController = await evaluate( + client, + "window.editorManager.editor.controlledTouchScrollController?.debugSnapshot?.() || null", + ); + const compositorFrames = activeScreencast; + activeScreencast = null; + const compositorAnalysis = compositorFrames.map((frame) => ({ + at: frame.at - compositorStarted, + ...analyzeCompositorFrame(frame, setup), + })); + const samples = await evaluate(client, ` + (() => { + const view = window.editorManager.editor; + const scroller = view.scrollDOM; + const viewport = scroller.getBoundingClientRect(); + const lines = Array.from(view.contentDOM.querySelectorAll(".cm-line")).filter((line) => { + const rect = line.getBoundingClientRect(); + return rect.bottom > viewport.top + 1 && rect.top < viewport.bottom - 1; + }); + const samples = window.__acodeFlingSamples || []; + samples.push({ + at: performance.now() - window.__acodeFlingStarted, + visibleLines: lines.length, + highlightedLines: lines.filter((line) => line.querySelector("span[class]")).length, + scrollTop: scroller.scrollTop, + clientHeight: scroller.clientHeight, + momentum: view.controlledTouchScrollController?.debugSnapshot?.() || null, + }); + return samples; + })() + `); + const startTop = await evaluate(client, "window.__acodeFlingStartTop"); + const lastSample = samples[samples.length - 1]; + const firstMovement = samples.find((sample) => Math.abs(sample.scrollTop - startTop) > 0.5); + const momentumSamples = samples.filter((sample) => sample.momentum?.releaseVelocity); + const firstMomentum = momentumSamples.find((sample) => sample.momentum.active); + const sampledFinalMomentum = [...momentumSamples].reverse().find( + (sample) => sample.momentum.termination, + )?.momentum || momentumSamples.at(-1)?.momentum; + const finalMomentum = finalController?.releaseVelocity + ? finalController + : sampledFinalMomentum; + const terminationSample = [...momentumSamples].reverse().find( + (sample) => sample.momentum.termination && !sample.momentum.active, + ); + let maximumAccelerationRatio = 0; + let catchUpJumps = 0; + for (let index = 1; index < momentumSamples.length; index++) { + const previous = momentumSamples[index - 1]; + const current = momentumSamples[index]; + const previousVelocity = Math.abs(previous.momentum.currentVelocity); + const currentVelocity = Math.abs(current.momentum.currentVelocity); + if (previous.momentum.active && current.momentum.active && previousVelocity > 0) + maximumAccelerationRatio = Math.max( + maximumAccelerationRatio, + (currentVelocity - previousVelocity) / previousVelocity, + ); + const gap = current.at - previous.at; + const step = Math.abs(current.scrollTop - previous.scrollTop); + if (previous.momentum.active && gap > 40 && step > previousVelocity * 0.032 + 2) + catchUpJumps++; + } + const expectedDistance = Math.abs(finalMomentum?.expectedDistance || 0); + const expectedDuration = finalMomentum?.expectedDuration || 0; + const completed = finalMomentum?.termination === "completed"; + const momentumDuration = finalMomentum?.actualDuration || + (firstMomentum && terminationSample + ? terminationSample.at - firstMomentum.at + : null); + measurements.push({ + run: run + 1, + idlePrewarmReady: beforeGesture?.idlePrewarmReady ?? false, + idlePrewarmRequired: prewarm.required, + idlePrewarmWaitMs: prewarm.waitMs, + frames: samples.length, + blankFrames: samples.filter((sample) => sample.visibleLines === 0).length, + plainFrames: samples.filter((sample) => sample.visibleLines > 0 && sample.highlightedLines === 0).length, + plainAtEnd: !!lastSample && lastSample.visibleLines > 0 && lastSample.highlightedLines === 0, + gestureResponseMs: firstMovement?.at ?? null, + controllerFirstMovementMs: finalController?.firstMovementDelay ?? null, + maxPreparationDurationMs: samples.reduce( + (maximum, sample) => Math.max( + maximum, + sample.momentum?.preparationDuration || 0, + ), + 0, + ), + minimumPreparationChunkScreens: samples.reduce( + (minimum, sample) => Math.min( + minimum, + sample.momentum?.preparationChunkScreens ?? Infinity, + ), + Infinity, + ), + minimumRemainingSafeDistance: samples.reduce( + (minimum, sample) => sample.momentum?.active + ? Math.min(minimum, sample.momentum.remainingSafeDistance) + : minimum, + Infinity, + ), + touchDistance: dispatchedGesture.touchDistance, + touchDurationMs: dispatchedGesture.duration, + releaseVelocity: finalMomentum?.releaseVelocity ?? null, + expectedMomentumDistance: expectedDistance || null, + expectedMomentumDuration: expectedDuration || null, + committedMomentumDistance: finalMomentum?.committedDistance ?? null, + momentumDurationMs: momentumDuration, + momentumTermination: finalMomentum?.termination ?? null, + distanceErrorRatio: completed && expectedDistance + ? Math.abs(Math.abs(finalMomentum.committedDistance) - expectedDistance) / expectedDistance + : null, + durationErrorRatio: completed && expectedDuration && momentumDuration != null + ? Math.abs(momentumDuration - expectedDuration) / expectedDuration + : null, + maxPostReleaseAccelerationRatio: maximumAccelerationRatio, + catchUpJumps, + maxCorridorAheadScreens: momentumSamples.filter( + (sample) => sample.momentum.active, + ).reduce( + (maximum, sample) => { + const prepared = sample.momentum.releaseVelocity < 0 + ? (sample.scrollTop - sample.momentum.corridorFrom) / sample.clientHeight + : (sample.momentum.corridorTo - sample.scrollTop - sample.clientHeight) / + sample.clientHeight; + return Math.max(maximum, prepared); + }, + 0, + ), + scrollDistance: lastSample ? lastSample.scrollTop - startTop : 0, + maxScrollStep: samples.reduce( + (max, sample, index) => index + ? Math.max(max, Math.abs(sample.scrollTop - samples[index - 1].scrollTop)) + : max, + 0, + ), + maxFrameGapMs: samples.reduce((max, sample, index) => index ? Math.max(max, sample.at - samples[index - 1].at) : max, 0), + compositorFrames: compositorAnalysis.length, + compositorBlankFrames: compositorAnalysis.filter((sample) => sample.blank).length, + minNonBackgroundRatio: compositorAnalysis.reduce( + (min, sample) => sample.nonBackgroundRatio == null + ? min + : Math.min(min, sample.nonBackgroundRatio), + Infinity, + ), + maxCompositorFrameGapMs: compositorAnalysis.reduce( + (max, sample, index) => + index + ? Math.max(max, sample.at - compositorAnalysis[index - 1].at) + : max, + 0, + ), + }); + } + + console.log(JSON.stringify({ + cpuRate, + runs, + fixture, + lineCount, + speed, + distance, + scrollerStyle: { + touchAction: setup.touchAction, + willChange: setup.willChange, + contentVisibility: setup.contentVisibility, + }, + controlledTouchScroll: setup.controlledTouchScroll, + controllerDebug: setup.controllerDebug, + measurements, + summary: { + idlePrewarmReadyRuns: measurements.filter( + (item) => item.idlePrewarmReady, + ).length, + maxIdlePrewarmWaitMs: Math.max( + ...measurements.map((item) => item.idlePrewarmWaitMs), + ), + blankFrames: measurements.reduce((sum, item) => sum + item.blankFrames, 0), + compositorBlankFrames: measurements.reduce( + (sum, item) => sum + item.compositorBlankFrames, + 0, + ), + missingCompositorRuns: measurements.filter( + (item) => item.compositorFrames === 0, + ).length, + plainFrames: measurements.reduce((sum, item) => sum + item.plainFrames, 0), + plainAtEndRuns: measurements.filter((item) => item.plainAtEnd).length, + catchUpJumps: measurements.reduce((sum, item) => sum + item.catchUpJumps, 0), + momentumTerminations: Object.fromEntries( + ["completed", "edge", "render-limited", "cancelled"].map((reason) => [ + reason, + measurements.filter((item) => item.momentumTermination === reason).length, + ]), + ), + maxFrameGapMs: Math.max(...measurements.map((item) => item.maxFrameGapMs)), + maxCompositorFrameGapMs: Math.max( + ...measurements.map((item) => item.maxCompositorFrameGapMs), + ), + }, + }, null, 2)); + +} catch (error) { + benchmarkError = error; + throw error; +} finally { + removeInterruptRecovery(); + const recoveryErrors = await recover(); + reportBenchmarkRecovery(benchmarkError, recoveryErrors); +} diff --git a/utils/benchmarks/editorHighlightScrollSmoke.mjs b/utils/benchmarks/editorHighlightScrollSmoke.mjs new file mode 100644 index 0000000000..78d3a746b3 --- /dev/null +++ b/utils/benchmarks/editorHighlightScrollSmoke.mjs @@ -0,0 +1,357 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +// Standalone trusted-touch regression for published highlight coverage. +import fs from "node:fs"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { rspack } from "@rspack/core"; +import WebSocket from "ws"; + +const root = fileURLToPath(new URL("../..", import.meta.url)); +const require = createRequire(import.meta.url); +const option = (name, fallback) => + process.argv + .find((arg) => arg.startsWith(`--${name}=`)) + ?.slice(name.length + 3) ?? fallback; +const browser = option("browser", process.env.CHROME_BIN); +const cpu = Number(option("cpu", "4")); +const readOnlySelection = option("read-only-selection", "false") === "true"; +const manualKeyboard = option("manual-keyboard", "false") === "true"; +const focused = option("focused", "false") === "true"; +const resizeDuringTouch = option("resize-during-touch", "false") === "true"; +if (!browser || !Number.isFinite(cpu) || cpu < 1) + throw new Error( + "Usage: node utils/benchmarks/editorHighlightScrollSmoke.mjs --browser=/path/to/chromium --cpu=4", + ); +const directory = fs.mkdtempSync( + path.join(os.tmpdir(), "acode-highlight-smoke-"), +); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +let chrome, ws; +try { + fs.writeFileSync( + path.join(directory, "entry.ts"), + `import {EditorState} from '@codemirror/state'; +import {EditorView} from '@codemirror/view'; +import {javascript} from '@codemirror/lang-javascript'; +import {createEditorReadOnlyExtension} from ${JSON.stringify(path.join(root, "src/cm/editorReadOnly.ts"))}; +import {createMainEditorExtensions} from ${JSON.stringify(path.join(root, "src/cm/mainEditorExtensions.ts"))}; +import {syntaxHighlighting, defaultHighlightStyle, syntaxTree} from '@codemirror/language'; +const doc = Array.from({length: 30000}, (_, i) => \`const value\${i} = {name: "text", count: \${i}}; // a highlighted line\`).join('\\n'); +const view = new EditorView({parent: document.body, state: EditorState.create({doc, extensions: [javascript(), syntaxHighlighting(defaultHighlightStyle), ...createMainEditorExtensions(), createEditorReadOnlyExtension(${readOnlySelection})]})}); +Object.assign(window, {view, frames: [], monitoring: false, sample() { + const bounds = view.scrollDOM.getBoundingClientRect(); + const visible = [...document.querySelectorAll('.cm-line')].filter(line => {const r=line.getBoundingClientRect(); return r.bottom>bounds.top && r.top !line.querySelector('span[class]')).length, canonical:syntaxTree(view.state).length, viewport:view.viewport, parse:view[Symbol.for('@codemirror/language.viewportPriorityDebug')]?.()}; +}}); +function tick() {if(window.monitoring) window.frames.push(window.sample()); requestAnimationFrame(tick)} +requestAnimationFrame(tick); +`, + ); + fs.writeFileSync( + path.join(directory, "index.html"), + ` +`, + ); + const config = require(path.join(root, "rspack.config.js"))( + {}, + { mode: "development" }, + )[0]; + config.entry = path.join(directory, "entry.ts"); + config.output = { + ...config.output, + path: directory, + filename: "app.js", + clean: false, + }; + config.resolve.modules = [ + path.join(root, "node_modules"), + path.join(root, "src"), + ]; + const baseline = option("language-runtime"); + if (baseline) + config.resolve.alias = { "@codemirror/language$": path.resolve(baseline) }; + const viewBaseline = option("view-runtime"); + if (viewBaseline) + config.resolve.alias = { + ...config.resolve.alias, + "@codemirror/view$": path.resolve(viewBaseline), + }; + config.plugins = []; + await new Promise((resolve, reject) => { + const compiler = rspack(config); + compiler.run((error, stats) => + compiler.close((closeError) => { + if (error || closeError || stats.hasErrors()) + reject( + error || + closeError || + new Error(stats.toString({ all: false, errors: true })), + ); + else resolve(); + }), + ); + }); + const profile = path.join(directory, "profile"); + chrome = spawn( + browser, + [ + "--no-sandbox", + "--disable-gpu", + "--remote-debugging-port=0", + `--user-data-dir=${profile}`, + "--allow-file-access-from-files", + "about:blank", + ], + { stdio: "ignore" }, + ); + let launchError; + chrome.on("error", (error) => { + launchError = error; + }); + let port; + for (let i = 0; i < 100; i++) { + await sleep(100); + if (launchError) throw launchError; + if (fs.existsSync(path.join(profile, "DevToolsActivePort"))) { + port = fs + .readFileSync(path.join(profile, "DevToolsActivePort"), "utf8") + .split("\n")[0]; + break; + } + } + if (!port) throw new Error("Browser did not open a debugging port"); + const targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json(); + if (!targets[0]) throw new Error("Browser did not create an initial tab"); + ws = new WebSocket(targets[0].webSocketDebuggerUrl); + await new Promise((resolve, reject) => { + ws.once("open", resolve); + ws.once("error", reject); + }); + let sequence = 0; + const pending = new Map(), + errors = []; + ws.on("message", (data) => { + const message = JSON.parse(data); + if (message.id) { + const request = pending.get(message.id); + if (!request) return; + pending.delete(message.id); + clearTimeout(request.timer); + if (message.error) + request.reject(new Error(JSON.stringify(message.error))); + else request.resolve(message.result); + } else if (message.method === "Runtime.exceptionThrown") + errors.push(message.params); + }); + const send = (method, params = {}) => + new Promise((resolve, reject) => { + const id = ++sequence; + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(`Timed out: ${method}`)); + }, 15000); + pending.set(id, { resolve, reject, timer }); + ws.send(JSON.stringify({ id, method, params })); + }); + const evaluate = async (expression) => { + const result = await send("Runtime.evaluate", { + expression, + returnByValue: true, + awaitPromise: true, + }); + if (result.exceptionDetails) + throw new Error(JSON.stringify(result.exceptionDetails)); + return result.result.value; + }; + await send("Runtime.enable"); + await send("Emulation.setDeviceMetricsOverride", { + width: 412, + height: 720, + deviceScaleFactor: 1, + mobile: true, + }); + await send("Emulation.setTouchEmulationEnabled", { enabled: true }); + await send("Emulation.setUserAgentOverride", { + userAgent: + "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36", + }); + await send("Emulation.setCPUThrottlingRate", { rate: cpu }); + await send("Page.navigate", { + url: pathToFileURL(path.join(directory, "index.html")).href, + }); + for (let i = 0; i < 100; i++) { + await sleep(50); + if (await evaluate("!!window.view")) break; + } + await evaluate(`window.focusCalls = window.blurCalls = window.keyboardRequests = 0; + if (navigator.virtualKeyboard) { + const show = navigator.virtualKeyboard.show.bind(navigator.virtualKeyboard); + navigator.virtualKeyboard.show = () => {window.keyboardRequests++; show()}; + } + ${focused ? "view.focus();" : ""} + for (const method of ["focus", "blur"]) { + const original = view.contentDOM[method].bind(view.contentDOM); + view.contentDOM[method] = (...args) => {window[method + "Calls"]++; return original(...args)}; + } + view.scrollDOM.scrollTop = view.scrollDOM.scrollHeight / 2;`); + await sleep(800); + const initial = await evaluate("sample()"); + if ( + !initial.count || + !initial.top || + initial.canonical >= initial.viewport.from + ) + throw new Error( + "Fixture must show a mid-document viewport beyond canonical parsing", + ); + await evaluate(`monitoring = true; window.touchClicks = 0; + view.contentDOM.addEventListener("click", () => window.touchClicks++);`); + const dragDistances = []; + const resize = (height) => + send("Emulation.setDeviceMetricsOverride", { + width: 412, + height, + deviceScaleFactor: 1, + mobile: true, + }); + for (let gesture = 0; gesture < 10; gesture++) { + if (resizeDuringTouch) { + await resize(720); + await sleep(50); + } + const beforeDrag = await evaluate("view.scrollDOM.scrollTop"); + const direction = gesture < 7 ? 1 : -1, + start = direction > 0 ? 620 : 100; + await send("Input.dispatchTouchEvent", { + type: "touchStart", + touchPoints: [{ x: 220, y: start }], + }); + if (resizeDuringTouch) { + await resize(780); + await sleep(50); + } + for (let step = 1; step <= 10; step++) { + if (resizeDuringTouch && step === 5) { + await resize(840); + await sleep(50); + } + await sleep(16); + await send("Input.dispatchTouchEvent", { + type: "touchMove", + touchPoints: [{ x: 220, y: start - direction * step * 48 }], + }); + } + dragDistances.push( + (await evaluate("view.scrollDOM.scrollTop")) - beforeDrag, + ); + await send("Input.dispatchTouchEvent", { + type: "touchEnd", + touchPoints: [], + }); + await sleep(250); + } + await sleep(500); + const result = await evaluate(`monitoring = false; ({frames: frames.length, + plainFrames: frames.filter(frame => frame.plain > 0).length, + blankFrames: frames.filter(frame => !frame.count).length, + keyboardRequests, keyboardPolicy: view.contentDOM.getAttribute("virtualkeyboardpolicy"), focusCalls, blurCalls, touchClicks, focused: view.hasFocus, last: sample()})`); + if (readOnlySelection) { + const before = await evaluate("view.state.doc.toString()"); + await send("Input.dispatchMouseEvent", { + type: "mousePressed", + x: 70, + y: 300, + button: "left", + buttons: 1, + clickCount: 1, + }); + for (let step = 1; step <= 8; step++) { + await send("Input.dispatchMouseEvent", { + type: "mouseMoved", + x: 70 + step * 18, + y: 300 + step * 5, + button: "left", + buttons: 1, + }); + } + await send("Input.dispatchMouseEvent", { + type: "mouseReleased", + x: 214, + y: 340, + button: "left", + buttons: 0, + clickCount: 1, + }); + await sleep(100); + result.readOnlySelection = + await evaluate(`({text: document.getSelection().toString(), + stateSelection: view.state.sliceDoc(view.state.selection.main.from, view.state.selection.main.to), + selectable: getComputedStyle(view.contentDOM).userSelect, + editable: view.contentDOM.contentEditable, keyboardRequests})`); + if ( + !result.readOnlySelection.text || + result.readOnlySelection.text !== + result.readOnlySelection.stateSelection || + result.readOnlySelection.selectable !== "text" || + result.readOnlySelection.editable !== "false" || + result.readOnlySelection.keyboardRequests || + before !== (await evaluate("view.state.doc.toString()")) + ) + throw new Error( + "Read-only text selection failed: " + + JSON.stringify(result.readOnlySelection), + ); + } + if (manualKeyboard) { + await send("Input.dispatchTouchEvent", { + type: "touchStart", + touchPoints: [{ x: 220, y: 300 }], + }); + if ((await evaluate("keyboardRequests")) !== 0) + throw new Error("Finger-down requested the keyboard"); + await send("Input.dispatchTouchEvent", { + type: "touchEnd", + touchPoints: [], + }); + await sleep(100); + result.tapKeyboardRequests = await evaluate("keyboardRequests"); + } + console.log( + JSON.stringify( + { cpu, resizeDuringTouch, dragDistances, initial, ...result, errors }, + null, + 2, + ), + ); + if ( + errors.length || + (manualKeyboard && + (result.keyboardPolicy !== "manual" || + result.keyboardRequests !== 0 || + result.tapKeyboardRequests !== 1)) || + result.touchClicks || + (resizeDuringTouch && + dragDistances.some((distance) => Math.abs(distance) < 400)) || + result.focusCalls || + result.blurCalls || + (focused && !result.focused) || + result.plainFrames || + result.blankFrames || + !result.frames || + result.last.top === initial.top + ) + process.exitCode = 1; +} finally { + ws?.close(); + if (chrome && chrome.exitCode === null && chrome.pid) { + await new Promise((resolve) => { + chrome.once("exit", resolve); + chrome.kill(); + }); + } + fs.rmSync(directory, { recursive: true, force: true }); +} diff --git a/utils/benchmarks/editorRenderingBenchmark.mjs b/utils/benchmarks/editorRenderingBenchmark.mjs new file mode 100644 index 0000000000..9b840f09b6 --- /dev/null +++ b/utils/benchmarks/editorRenderingBenchmark.mjs @@ -0,0 +1,275 @@ +#!/usr/bin/env node + +import WebSocket from "ws"; +import { + cleanupRemoteEditorFixture, + createEditorFixture, + editorFixtureNames, + installBenchmarkInterruptRecovery, + reportBenchmarkRecovery, +} from "./editorFixtures.mjs"; + +function readOption(name, fallback) { + const prefix = `--${name}=`; + const argument = process.argv.find((value) => value.startsWith(prefix)); + return argument ? argument.slice(prefix.length) : fallback; +} + +const port = Number(readOption("port", "9222")); +const cpuRate = Number(readOption("cpu", "1")); +const runs = Number(readOption("runs", "10")); +const lineCount = Number(readOption("lines", "120000")); +const maxWait = Number(readOption("wait", "6000")); +const fixture = readOption("fixture", "javascript"); +const includeMeasurements = readOption("details", "true") !== "false"; +if (!editorFixtureNames.includes(fixture)) throw new Error(`Unknown fixture: ${fixture}`); + +if ( + !Number.isFinite(port) || + !Number.isFinite(cpuRate) || + !Number.isFinite(runs) || + !Number.isFinite(lineCount) || + !Number.isFinite(maxWait) || + port <= 0 || + cpuRate < 1 || + runs < 1 || + lineCount < 1 || + maxWait < 1 +) { + throw new Error("Invalid numeric benchmark option"); +} + +class DevToolsClient { + constructor(url) { + this.nextId = 1; + this.pending = new Map(); + this.socket = new WebSocket(url); + } + + async open() { + await new Promise((resolve, reject) => { + this.socket.once("open", resolve); + this.socket.once("error", reject); + }); + this.socket.on("message", (data) => { + const message = JSON.parse(data.toString()); + if (!message.id) return; + const request = this.pending.get(message.id); + if (!request) return; + this.pending.delete(message.id); + if (message.error) request.reject(new Error(message.error.message)); + else request.resolve(message.result); + }); + } + + call(method, params = {}) { + const id = this.nextId++; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.socket.send(JSON.stringify({ id, method, params })); + }); + } + + close() { + this.socket.close(); + } +} + +async function findPage() { + const response = await fetch(`http://127.0.0.1:${port}/json/list`); + if (!response.ok) { + throw new Error(`DevTools endpoint returned ${response.status}`); + } + const targets = await response.json(); + const page = targets.find( + (target) => target.type === "page" && target.url?.includes("index.html"), + ); + if (!page?.webSocketDebuggerUrl) { + throw new Error("No debuggable Acode page found"); + } + return page; +} + +function benchmarkExpression() { + const options = JSON.stringify({ fixture, lineCount, runs, maxWait }); + const fixtureFactory = createEditorFixture.toString(); + return ` + (async () => { + const options = ${options}; + const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const frame = () => new Promise((resolve) => requestAnimationFrame(resolve)); + const waitFor = async (predicate, timeout = 10000) => { + const started = performance.now(); + while (!predicate()) { + if (performance.now() - started > timeout) throw new Error("Timed out waiting for Acode editor"); + await wait(50); + } + }; + + await waitFor(() => window.editorManager?.editor && window.acode); + + const previousFile = window.editorManager.activeFile; + const EditorFile = window.acode.require("editorFile"); + const language = window.acode.require("@codemirror/language"); + const fixture = (${fixtureFactory})(options.fixture, options.lineCount); + const file = new EditorFile(fixture.name, { + text: fixture.text, + render: true, + isUnsaved: false, + persistInSession: false, + }); + window.__acodeRenderingBenchmark = { file, previousFile }; + await waitFor(() => window.editorManager.activeFile?.id === file.id); + await wait(1000); + + const view = window.editorManager.editor; + const scroller = view.scrollDOM; + const sample = () => { + const viewport = scroller.getBoundingClientRect(); + const visibleLines = Array.from(view.contentDOM.querySelectorAll(".cm-line")).filter((line) => { + const rect = line.getBoundingClientRect(); + return rect.bottom > viewport.top + 1 && rect.top < viewport.bottom - 1; + }); + const highlightedLines = visibleLines.filter((line) => line.querySelector("span[class]")).length; + return { + visibleLines: visibleLines.length, + highlightedLines, + exact: language.syntaxTreeAvailable(view.state, view.viewport.to), + viewportFrom: view.viewport.from, + viewportTo: view.viewport.to, + scrollTop: scroller.scrollTop, + }; + }; + + const scrollbars = async (ratio) => { + scroller.dispatchEvent(new Event("scroll")); + let scrollbar = null; + for (let attempt = 0; attempt < 20 && !scrollbar; attempt++) { + await frame(); + scrollbar = document.querySelector(".scrollbar-container.right"); + } + if (typeof scrollbar?.onScroll !== "function") { + throw new Error("Acode vertical scrollbar is not available"); + } + scrollbar.onScroll(ratio); + }; + + const measureJump = async (ratio) => { + const started = performance.now(); + await scrollbars(ratio); + let blankFrames = 0; + let textMs = null; + let highlightMs = null; + let exactMs = null; + let current = sample(); + while (performance.now() - started < options.maxWait) { + await frame(); + current = sample(); + const elapsed = performance.now() - started; + if (current.visibleLines === 0) blankFrames++; + else if (textMs == null) textMs = elapsed; + if (current.highlightedLines > 0 && highlightMs == null) highlightMs = elapsed; + if (current.exact && exactMs == null) exactMs = elapsed; + if (textMs != null && highlightMs != null && exactMs != null) break; + } + return { + ratio, + blankFrames, + textMs, + highlightMs, + exactMs, + ...current, + }; + }; + + const measurements = []; + const targets = [0.05, 0.5, 0.95]; + for (let run = 0; run < options.runs; run++) { + for (const ratio of run % 2 ? [...targets].reverse() : targets) { + measurements.push({ run: run + 1, ...(await measureJump(ratio)) }); + await wait(40); + } + } + + const percentile = (values, percentage) => { + if (!values.length) return null; + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * percentage) - 1)]; + }; + const values = (key) => measurements.map((item) => item[key]).filter(Number.isFinite); + const result = { + fixture: options.fixture, + lineCount: view.state.doc.lines, + documentLength: view.state.doc.length, + scrollerStyle: { + willChange: getComputedStyle(scroller).willChange, + contentVisibility: getComputedStyle(scroller).contentVisibility, + }, + summary: { + blankFrames: measurements.reduce((sum, item) => sum + item.blankFrames, 0), + textP95Ms: percentile(values("textMs"), 0.95), + highlightP95Ms: percentile(values("highlightMs"), 0.95), + exactP95Ms: percentile(values("exactMs"), 0.95), + missingText: measurements.filter((item) => item.textMs == null).length, + missingHighlight: measurements.filter((item) => item.highlightMs == null).length, + missingExact: measurements.filter((item) => item.exactMs == null).length, + }, + }; + if (${includeMeasurements}) result.measurements = measurements; + + return result; + })() + `; +} + +const page = await findPage(); +const client = new DevToolsClient(page.webSocketDebuggerUrl); +await client.open(); + +let benchmarkError = null; +let recoveryPromise = null; +const recover = () => recoveryPromise ??= (async () => { + const recoveryErrors = []; + try { + await cleanupRemoteEditorFixture(client, "__acodeRenderingBenchmark"); + } catch (error) { + recoveryErrors.push(error); + } + try { + await client.call("Emulation.setCPUThrottlingRate", { rate: 1 }); + } catch (error) { + recoveryErrors.push(error); + } + client.close(); + return recoveryErrors; +})(); +const removeInterruptRecovery = installBenchmarkInterruptRecovery( + async (signal) => { + const recoveryErrors = await recover(); + reportBenchmarkRecovery(new Error(`Benchmark interrupted by ${signal}`), recoveryErrors); + }, +); +try { + await client.call("Runtime.enable"); + await cleanupRemoteEditorFixture(client, "__acodeRenderingBenchmark"); + await client.call("Emulation.setCPUThrottlingRate", { rate: cpuRate }); + const evaluation = await client.call("Runtime.evaluate", { + expression: benchmarkExpression(), + awaitPromise: true, + returnByValue: true, + }); + if (evaluation.exceptionDetails) { + throw new Error( + evaluation.exceptionDetails.exception?.description || + evaluation.exceptionDetails.text, + ); + } + console.log(JSON.stringify({ cpuRate, ...evaluation.result.value }, null, 2)); +} catch (error) { + benchmarkError = error; + throw error; +} finally { + removeInterruptRecovery(); + const recoveryErrors = await recover(); + reportBenchmarkRecovery(benchmarkError, recoveryErrors); +} diff --git a/utils/scripts/buildCodemirrorVendor.mjs b/utils/scripts/buildCodemirrorVendor.mjs new file mode 100644 index 0000000000..b59b13fe9f --- /dev/null +++ b/utils/scripts/buildCodemirrorVendor.mjs @@ -0,0 +1,187 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + cp, + mkdir, + mkdtemp, + readFile, + rename, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const { build } = require("@marijn/buildtool"); +const { lezer } = require("@lezer/generator/rollup"); +const root = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../..", +); +const packages = ["codemirror-view", "codemirror-language"]; +const buildOptions = { + expandLink: (anchor) => `https://codemirror.net/6/docs/ref/#${anchor}`, + pureTopCalls: true, + outputPlugin: () => lezer(), +}; + +function run(command, args, cwd, env = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd, + env: { ...process.env, ...env }, + stdio: "inherit", + }); + child.once("error", reject); + child.once("exit", (code, signal) => { + if (code === 0) resolve(); + else reject(new Error(`${command} exited with ${signal || code}`)); + }); + }); +} + +function shouldCopyPackageFile(packageRoot, source) { + const relative = path.relative(packageRoot, source); + if (!relative) return true; + if (relative === "dist" || relative.startsWith(`dist${path.sep}`)) + return false; + if ( + relative.startsWith(`test${path.sep}`) && + path.extname(relative) !== ".ts" + ) { + return false; + } + return true; +} + +async function replaceDirectory(source, target) { + const staging = `${target}.next-${process.pid}`; + const backup = `${target}.previous-${process.pid}`; + await rm(staging, { recursive: true, force: true }); + await rm(backup, { recursive: true, force: true }); + await cp(source, staging, { recursive: true }); + + let hadTarget = true; + try { + await rename(target, backup); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + hadTarget = false; + } + + try { + await rename(staging, target); + } catch (error) { + if (hadTarget) await rename(backup, target); + throw error; + } + if (hadTarget) await rm(backup, { recursive: true, force: true }); +} + +async function replaceFile(source, target) { + const staging = `${target}.next-${process.pid}`; + await rm(staging, { force: true }); + await cp(source, staging); + await rename(staging, target); +} + +const temporaryRoot = await mkdtemp( + path.join(os.tmpdir(), "acode-codemirror-vendor-"), +); +const temporaryPackages = path.join(temporaryRoot, "packages"); + +try { + await mkdir(temporaryPackages, { recursive: true }); + await symlink( + path.join(root, "node_modules"), + path.join(temporaryRoot, "node_modules"), + process.platform === "win32" ? "junction" : "dir", + ); + const builtPackages = []; + + for (const packageName of packages) { + const packageRoot = path.join(root, "vendor", packageName); + const temporaryPackageRoot = path.join(temporaryRoot, packageName); + await cp(packageRoot, temporaryPackageRoot, { + recursive: true, + filter: (source) => shouldCopyPackageFile(packageRoot, source), + }); + const entry = path.join(temporaryPackageRoot, "src", "index.ts"); + if (!(await build(entry, buildOptions))) { + throw new Error(`Failed to build ${packageName}`); + } + const manifest = JSON.parse( + await readFile(path.join(temporaryPackageRoot, "package.json"), "utf8"), + ); + builtPackages.push({ + packageName, + packageRoot, + temporaryPackageRoot, + archiveName: `${manifest.name.replace("@", "").replace("/", "-")}-${manifest.version}.tgz`, + }); + } + + for (const builtPackage of builtPackages) { + await run( + "npm", + [ + "pack", + builtPackage.temporaryPackageRoot, + "--ignore-scripts", + "--pack-destination", + temporaryPackages, + ], + root, + { npm_config_cache: path.join(temporaryRoot, "npm-cache") }, + ); + const packed = path.join(temporaryPackages, builtPackage.archiveName); + const hash = createHash("sha256") + .update(await readFile(packed)) + .digest("hex") + .slice(0, 16); + builtPackage.fingerprintedArchive = builtPackage.archiveName.replace( + /\.tgz$/, + `-acode.${hash}.tgz`, + ); + await rename( + packed, + path.join(temporaryPackages, builtPackage.fingerprintedArchive), + ); + } + + for (const builtPackage of builtPackages) { + await replaceDirectory( + path.join(builtPackage.temporaryPackageRoot, "dist"), + path.join(builtPackage.packageRoot, "dist"), + ); + } + const manifestPath = path.join(root, "package.json"); + const appManifest = JSON.parse(await readFile(manifestPath, "utf8")); + for (const builtPackage of builtPackages) { + await replaceFile( + path.join(temporaryPackages, builtPackage.fingerprintedArchive), + path.join(root, "vendor", "packages", builtPackage.fingerprintedArchive), + ); + const name = builtPackage.packageName.replace( + "codemirror-", + "@codemirror/", + ); + appManifest.dependencies[name] = + `file:vendor/packages/${builtPackage.fingerprintedArchive}`; + } + // A new filename forces npm to replace a previously installed archive even + // when the upstream version stays the same. Reinstall to update the lockfile. + await writeFile( + `${manifestPath}.next`, + `${JSON.stringify(appManifest, null, 2)}\n`, + ); + await rename(`${manifestPath}.next`, manifestPath); +} finally { + await rm(temporaryRoot, { recursive: true, force: true }); +} diff --git a/utils/scripts/codemirrorUpstream.mjs b/utils/scripts/codemirrorUpstream.mjs new file mode 100644 index 0000000000..bff23a2e4e --- /dev/null +++ b/utils/scripts/codemirrorUpstream.mjs @@ -0,0 +1,201 @@ +#!/usr/bin/env node + +// Work only in disposable copies; never reset or modify the upstream checkout. +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("../..", import.meta.url)); +const [mode, name, upstreamPath] = process.argv.slice(2); +if ( + !["check", "refresh", "upgrade"].includes(mode) || + !["view", "language"].includes(name) || + !upstreamPath +) { + throw new Error( + "Usage: node utils/scripts/codemirrorUpstream.mjs ", + ); +} + +const upstream = path.resolve(upstreamPath); +const vendor = path.join(root, "vendor", `codemirror-${name}`); +const patchFile = path.join(root, "vendor", "patches", `${name}.patch`); +const metadataFile = path.join(root, "vendor", "upstream.json"); +const metadata = JSON.parse(fs.readFileSync(metadataFile, "utf8")); +const baseline = metadata[name]; +const sourceFiles = [ + "src", + "test", + "LICENSE", + "README.md", + "CHANGELOG.md", + "package.json", +]; + +function git(cwd, ...args) { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + }); +} + +function copySource(from, to) { + for (const file of sourceFiles) { + fs.cpSync(path.join(from, file), path.join(to, file), { recursive: true }); + } +} + +function packageManifest(directory) { + return JSON.parse( + fs.readFileSync(path.join(directory, "package.json"), "utf8"), + ); +} + +function writeJSON(file, value) { + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +function initialize(directory) { + git(directory, "init", "--quiet"); + git(directory, "add", "."); +} + +function snapshot(directory) { + const result = new Map(); + function visit(relative) { + const file = path.join(directory, relative); + const stat = fs.lstatSync(file); + if (stat.isSymbolicLink()) throw new Error(`Unexpected symlink: ${file}`); + if (stat.isDirectory()) { + for (const child of fs.readdirSync(file).sort()) + visit(path.join(relative, child)); + } else { + result.set(relative, fs.readFileSync(file)); + } + } + for (const file of sourceFiles) visit(file); + return result; +} + +const commit = git(upstream, "rev-parse", "HEAD").trim(); +if (git(upstream, "status", "--porcelain", "--untracked-files=all").trim()) { + throw new Error("Use a clean upstream checkout, including untracked files."); +} +const manifest = packageManifest(upstream); +if (manifest.name !== `@codemirror/${name}`) + throw new Error("Wrong upstream package."); +if ( + mode !== "upgrade" && + (commit !== baseline.commit || manifest.version !== baseline.version) +) { + throw new Error( + `Expected ${baseline.version} at ${baseline.commit}; found ${manifest.version} at ${commit}.`, + ); +} +// Refuse an upgrade that would silently discard edits not saved in the patch. +if (mode === "upgrade") { + const expected = JSON.parse( + fs.readFileSync( + path.join(root, "vendor", "patches", `${name}.source.json`), + "utf8", + ), + ); + const { createHash } = await import("node:crypto"); + const actual = Object.fromEntries( + [...snapshot(vendor)].map(([file, data]) => [ + file, + createHash("sha256").update(data).digest("hex"), + ]), + ); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + "Vendored source has changed. Run refresh against the old upstream revision before upgrading.", + ); + } +} + +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "acode-cm-patch-")); +try { + copySource(upstream, temporary); + initialize(temporary); + if (mode === "refresh") { + // Index contains pristine upstream. Working tree becomes the local source. + for (const file of sourceFiles) + fs.rmSync(path.join(temporary, file), { recursive: true, force: true }); + copySource(vendor, temporary); + git(temporary, "add", "--intent-to-add", "."); + const patch = git( + temporary, + "diff", + "--binary", + "--no-ext-diff", + "--no-renames", + ); + fs.mkdirSync(path.dirname(patchFile), { recursive: true }); + fs.writeFileSync(patchFile, patch); + } else { + // Check every hunk before applying anything. No fuzzy string replacement. + git(temporary, "apply", "--check", patchFile); + git(temporary, "apply", patchFile); + if (mode === "check") { + const local = snapshot(vendor), + replayed = snapshot(temporary); + const files = new Set([...local.keys(), ...replayed.keys()]); + const changed = [...files].filter( + (file) => + !local.has(file) || + !replayed.has(file) || + !local.get(file).equals(replayed.get(file)), + ); + if (changed.length) + throw new Error(`Patch/source drift: ${changed.join(", ")}`); + } else { + // All validation succeeded in the disposable directory before mutation. + for (const file of sourceFiles) { + fs.rmSync(path.join(vendor, file), { recursive: true, force: true }); + fs.cpSync(path.join(temporary, file), path.join(vendor, file), { + recursive: true, + }); + } + metadata[name] = { ...baseline, version: manifest.version, commit }; + writeJSON(metadataFile, metadata); + fs.writeFileSync( + path.join(vendor, "UPSTREAM.md"), + `# Upstream revision\n\n- Package: \`@codemirror/${name}\` ${manifest.version}\n- Repository: ${baseline.repository}\n- Commit: \`${commit}\`\n`, + ); + const appFile = path.join(root, "package.json"); + const app = packageManifest(root); + app.dependencies[manifest.name] = + `file:vendor/packages/codemirror-${name}-${manifest.version}.tgz`; + writeJSON(appFile, app); + // Regenerate patch context against this upstream revision. + git(temporary, "add", "--intent-to-add", "."); + fs.writeFileSync( + patchFile, + git(temporary, "diff", "--binary", "--no-ext-diff", "--no-renames"), + ); + } + } + if (mode !== "check") { + const { createHash } = await import("node:crypto"); + writeJSON( + path.join(root, "vendor", "patches", `${name}.source.json`), + Object.fromEntries( + [...snapshot(vendor)].map(([file, data]) => [ + file, + createHash("sha256").update(data).digest("hex"), + ]), + ), + ); + } + console.log(`${name}: ${mode} passed (${manifest.version}, ${commit}).`); + if (mode === "upgrade") + console.log( + "Next: build:codemirror-vendor, reinstall the local archives, then run CodeMirror tests and Android benchmarks. Remove superseded archives after validation.", + ); +} finally { + fs.rmSync(temporary, { recursive: true, force: true }); +} diff --git a/vendor/README.md b/vendor/README.md new file mode 100644 index 0000000000..67e08da718 --- /dev/null +++ b/vendor/README.md @@ -0,0 +1,119 @@ +# Vendored CodeMirror packages + +Acode carries narrow forks of `@codemirror/view` and +`@codemirror/language` for Android/WebView rendering behavior that cannot be +implemented through public CodeMirror extensions. + +- `codemirror-view` is based on 6.43.9 at + `d4e1656e1a0060f562695df93cb1775c0cdee24f`. +- `codemirror-language` is based on 6.12.4 at + `89974ce5d39539ce6c5cfea5278443fa9381cbf2`. + +The view fork adds bounded directional buffering and Android touch scrolling +with Android spline momentum. Acode uses `waitForRendering: false`: drag +distance follows the finger immediately, release velocity uses touch-event +timestamps, and normal viewport rendering runs independently. Near-diagonal +swipes retain vertical scrolling; only clearly horizontal swipes are handed +to native pan-x scrolling. Two screens of +directional buffering limit render work. The old four-screen coverage gate is +available with `waitForRendering: true` for diagnostic comparisons. +Unrestricted momentum follows elapsed spline time through slow frames; the +120 ms blocked-render cutoff applies only to the coverage-gated mode. +The language fork adds generation-scoped provisional +outer-language highlighting in stable 128-line windows. It retains up to 12 +completed windows and prepares two windows beyond each viewport edge. Scrolling +reuses published colors while missing windows parse; document/language changes +invalidate the cache. These visual trees never replace canonical syntax state. +Both features are absent unless their exported +extensions are installed. + +The controlled scroller releases ownership for taps, focus/blur, composition, +selection/navigation and external scroll changes. Keyboard viewport resizing +releases momentum and stale geometry but preserves an active finger drag. +A recognized scroll still consumes its touch release after cancellation, so +it cannot become a native tap that reopens the keyboard. +In this mode, selection repair never cycles DOM focus, including when an +offscreen caret lands inside a virtualized block gap. The delayed Android +Delete/Backspace focus-recovery workaround is also disabled so it cannot +reopen a dismissed keyboard. Explicit user-driven focus remains available. +Plain text or a removed highlighter clears stale coverage requirements. +Read-only editor content can scroll; nested interactive widgets retain their +own gestures. Provisional highlighting can run +during gestures (a 2 ms cooperative slice), while canonical background work +yields. The configured 4 ms budget also applies to initial/edit/background +parsing. A parser's individual `advance()` or tree finalization can exceed the +budget; this is not a hard frame-time guarantee. + +The app's `keyboardScroll` extension records browsing intent per editor. +Keyboard close cancels pending reveals. Keyboard open preserves a browsed +viewport until an explicit selection or edit resumes caret following. Geometry +reads and scroll writes run in separate CodeMirror measurement phases. + +These remain experimental changes. The historical Android measurements in +`utils/benchmarks/README.md` describe the original experiment, not this revision. +They must be rerun before release. `createMainEditorExtensions` accepts +`renderingExtensions: []` for comparisons with upstream rendering behavior. + +## Building local changes + +Run these after changing either package: + +```sh +npm run build:codemirror-vendor +npm install --ignore-scripts +npm run test:codemirror +npm run typecheck +``` + +The build writes deterministic archives with a content hash in the filename +and updates the root dependency paths. This prevents npm from retaining a stale +local package with the same upstream version. Commit `dist/`, the referenced +archives, `package.json`, and `package-lock.json`. Remove superseded archives +once validation passes. Dependencies and overrides ensure every consumer uses +the same CodeMirror module instance. Licenses and upstream tests are retained. + +## Updating upstream + +`upstream.json` records the exact upstream revisions. `patches/view.patch` and +`patches/language.patch` contain only Acode's differences; the source receipts +detect edits that have not been saved in those patches. Generated `dist/` and +archives are deliberately excluded from patches. + +Clone the canonical repositories once. Their GitHub mirrors may lag releases: + +```sh +git clone https://code.haverbeke.berlin/codemirror/view.git /tmp/cm-view +git clone https://code.haverbeke.berlin/codemirror/language.git /tmp/cm-language +git -C /tmp/cm-view checkout d4e1656e1a0060f562695df93cb1775c0cdee24f +git -C /tmp/cm-language checkout 89974ce5d39539ce6c5cfea5278443fa9381cbf2 +npm run codemirror:upstream -- check view /tmp/cm-view +npm run codemirror:upstream -- check language /tmp/cm-language +``` + +After editing vendored source, use `refresh` instead of `check` against the +recorded baseline, then run `check`. It saves the full local delta, including +added files and package metadata, without changing the upstream checkout. + +For an upstream release, fetch and check out its npm `gitHead` in the matching +clean upstream checkout. Then run, for example: + +```sh +npm run codemirror:upstream -- upgrade view /tmp/cm-view +npm run codemirror:upstream -- check view /tmp/cm-view +npm run build:codemirror-vendor +npm install --ignore-scripts +npm run test:codemirror +npm run typecheck +``` + +Upgrade applies all patches in a disposable copy before replacing source, +tests, metadata, or dependency paths. Conflicts stop the operation with the +current vendor files untouched. Resolve conflicting changes in a separate +checkout, port the resolved source into the vendor directory, update +`upstream.json` and `UPSTREAM.md` to that release, then `refresh`, `check`, and +rebuild. Read the patch diff and upstream changelog; patch application alone +does not prove behavioral compatibility. Run upstream tests as well as Acode's +regressions and Android benchmarks for each upgrade. + +Only `view` and `language` need this process. Other CodeMirror packages keep +their normal npm update workflow, subject to compatible dependency versions. diff --git a/vendor/codemirror-language/CHANGELOG.md b/vendor/codemirror-language/CHANGELOG.md new file mode 100644 index 0000000000..bef6a200e5 --- /dev/null +++ b/vendor/codemirror-language/CHANGELOG.md @@ -0,0 +1,418 @@ +## 6.12.4 (2026-06-25) + +### Bug fixes + +Make adding a lot of folded ranges in one transaction linear rather than quadratic in range-set-building complexity. + +## 6.12.3 (2026-03-25) + +### Bug fixes + +Fix a crash in `bracketMatching` when composing at end of document. + +## 6.12.2 (2026-02-25) + +### Bug fixes + +Make sure brackets are highlighted in the initial editor state. + +Pause bracket matching updates during composition, to avoid disrupting Mobile Safari's fragile composition handling. + +## 6.12.1 (2025-12-22) + +### Bug fixes + +Improve finding inner language in syntax tree when the nested parse has been marked as bracketed. + +## 6.11.3 (2025-08-15) + +### Bug fixes + +Make the stream parser user 4 times smaller chunks to reduce the amount of re-parsed code on changes. + +## 6.11.2 (2025-06-27) + +### Bug fixes + +Make sure folded ranges open when backspacing or deleting into them. + +## 6.11.1 (2025-06-02) + +### Bug fixes + +Fix an issue where indentation would sometimes miss nodes in mixed-language situations. + +## 6.11.0 (2025-03-13) + +### New features + +Stream parsers now support a `mergeTokens` option that can be used to turn off automatic merging of adjacent tokens. + +## 6.10.8 (2024-12-23) + +### Bug fixes + +Fix a regression introduced 6.10.7 that caused indention to sometimes crash on nested language boundaries. + +## 6.10.7 (2024-12-17) + +### Bug fixes + +Fix an issue where indentation for a stream language would fail to work when the parse covered only part of the document, far from the start. + +Make sure the inner mode gets a chance to indent when indenting right at the end of a nested language section. + +## 6.10.6 (2024-11-29) + +### Bug fixes + +Fix a crash in `StreamLanguage` when the input range is entirely before the editor viewport. + +## 6.10.5 (2024-11-27) + +### Bug fixes + +Fix an issue where a `StreamLanguage` could get confused when trying to reuse existing parse data when the parsed range changed. + +## 6.10.4 (2024-11-24) + +### Bug fixes + +Join adjacent tokens of the same type into a single token in . + +Call stream language indent functions even when the language is used as a nested parser. + +Fix a crash in `StreamParser` when a parse was resumed with different input ranges. + +## 6.10.3 (2024-09-19) + +### Bug fixes + +Fix a TypeScript error when using `HighlightStyle` with the `exactOptionalPropertyTypes` typechecking option enabled. + +Make `delimitedIndent` align to spaces after the opening token. + +## 6.10.2 (2024-06-03) + +### Bug fixes + +Fix an infinite loop that could occur when enabling `bidiIsolates` in documents with both bidirectional text and very long lines. + +## 6.10.1 (2024-02-02) + +### Bug fixes + +Fix an issue where, when a lot of code is visible in the initial editor, the bottom bit of code is shown without highlighting for one frame. + +## 6.10.0 (2023-12-28) + +### New features + +The new `bidiIsolates` extension can be used to wrap syntactic elements where this is appropriate in an element that isolates their text direction, avoiding weird ordering of neutral characters on direction boundaries. + +## 6.9.3 (2023-11-27) + +### Bug fixes + +Fix an issue in `StreamLanguage` where it ran out of node type ids if you repeatedly redefined a language with the same token table. + +## 6.9.2 (2023-10-24) + +### Bug fixes + +Allow `StreamParser` tokens get multiple highlighting tags. + +## 6.9.1 (2023-09-20) + +### Bug fixes + +Indentation now works a lot better in mixed-language documents that interleave the languages in a complex way. + +Code folding is now able to pick the right foldable syntax node when the line end falls in a mixed-parsing language that doesn't match the target node. + +## 6.9.0 (2023-08-16) + +### Bug fixes + +Make `getIndentation` return null, rather than 0, when there is no syntax tree available. + +### New features + +The new `preparePlaceholder` option to `codeFolding` makes it possible to display contextual information in a folded range placeholder widget. + +## 6.8.0 (2023-06-12) + +### New features + +The new `baseIndentFor` method in `TreeIndentContext` can be used to find the base indentation for an arbitrary node. + +## 6.7.0 (2023-05-19) + +### New features + +Export `DocInput` class for feeding editor documents to a Lezer parser. + +## 6.6.0 (2023-02-13) + +### New features + +Syntax-driven language data queries now support sublanguages, which make it possible to return different data for specific parts of the tree produced by a single language. + +## 6.5.0 (2023-02-07) + +### Bug fixes + +Make indentation for stream languages more reliable by having `StringStream.indentation` return overridden indentations from the indent context. + +### New features + +The `toggleFold` command folds or unfolds depending on whether there's an existing folded range on the current line. + +`indentUnit` now accepts any (repeated) whitespace character, not just spaces and tabs. + +## 6.4.0 (2023-01-12) + +### New features + +The `bracketMatchingHandle` node prop can now be used to limit bracket matching behavior for larger nodes to a single subnode (for example the tag name of an HTML tag). + +## 6.3.2 (2022-12-16) + +### Bug fixes + +Fix a bug that caused `ensureSyntaxTree` to return incomplete trees when using a viewport-aware parser like `StreamLanguage`. + +## 6.3.1 (2022-11-14) + +### Bug fixes + +Make syntax-based folding include syntax nodes that start right at the end of a line as potential fold targets. + +Fix the `indentService` protocol to allow a distinction between declining to handle the indentation and returning null to indicate the line has no definite indentation. + +## 6.3.0 (2022-10-24) + +### New features + +`HighlightStyle` objects now have a `specs` property holding the tag styles that were used to define them. + +`Language` objects now have a `name` field holding the language name. + +## 6.2.1 (2022-07-21) + +### Bug fixes + +Fix a bug where `bracketMatching` would incorrectly match nested brackets in syntax trees that put multiple pairs of brackets in the same parent node. + +Fix a bug that could cause `indentRange` to loop infinitely. + +## 6.2.0 (2022-06-30) + +### Bug fixes + +Fix a bug that prevented bracket matching to recognize plain brackets inside a language parsed as an overlay. + +### New features + +The `indentRange` function provides an easy way to programatically auto-indent a range of the document. + +## 6.1.0 (2022-06-20) + +### New features + +The `foldState` field is now public, and can be used to serialize and deserialize the fold state. + +## 6.0.0 (2022-06-08) + +### New features + +The `foldingChanged` option to `foldGutter` can now be used to trigger a recomputation of the fold markers. + +## 0.20.2 (2022-05-20) + +### Bug fixes + +List style-mod as a dependency. + +## 0.20.1 (2022-05-18) + +### Bug fixes + +Make sure `all` styles in the CSS generated for a `HighlightStyle` have a lower precedence than the other rules defined for the style. Use a shorthand property + +## 0.20.0 (2022-04-20) + +### Breaking changes + +`HighlightStyle.get` is now called `highlightingFor`. + +`HighlightStyles` no longer function as extensions (to improve tree shaking), and must be wrapped with `syntaxHighlighting` to add to an editor configuration. + +`Language` objects no longer have a `topNode` property. + +### New features + +`HighlightStyle` and `defaultHighlightStyle` from the now-removed @codemirror/highlight package now live in this package. + +The new `forceParsing` function can be used to run the parser forward on an editor view. + +The exports that used to live in @codemirror/matchbrackets are now exported from this package. + +The @codemirror/fold package has been merged into this one. + +The exports from the old @codemirror/stream-parser package now live in this package. + +## 0.19.10 (2022-03-31) + +### Bug fixes + +Autocompletion may now also trigger automatic indentation on input. + +## 0.19.9 (2022-03-30) + +### Bug fixes + +Make sure nodes that end at the end of a partial parse aren't treated as valid fold targets. + +Fix an issue where the parser sometimes wouldn't reuse parsing work done in the background on transactions. + +## 0.19.8 (2022-03-03) + +### Bug fixes + +Fix an issue that could cause indentation logic to use the wrong line content when indenting multiple lines at once. + +## 0.19.7 (2021-12-02) + +### Bug fixes + +Fix an issue where the parse worker could incorrectly stop working when the parse tree has skipped gaps in it. + +## 0.19.6 (2021-11-26) + +### Bug fixes + +Fixes an issue where the background parse work would be scheduled too aggressively, degrading responsiveness on a newly-created editor with a large document. + +Improve initial highlight for mixed-language editors and limit the amount of parsing done on state creation for faster startup. + +## 0.19.5 (2021-11-17) + +### New features + +The new function `syntaxTreeAvailable` can be used to check if a fully-parsed syntax tree is available up to a given document position. + +The module now exports `syntaxParserRunning`, which tells you whether the background parser is still planning to do more work for a given editor view. + +## 0.19.4 (2021-11-13) + +### New features + +`LanguageDescription.of` now takes an optional already-loaded extension. + +## 0.19.3 (2021-09-13) + +### Bug fixes + +Fix an issue where a parse that skipped content with `skipUntilInView` would in some cases not be restarted when the range came into view. + +## 0.19.2 (2021-08-11) + +### Bug fixes + +Fix a bug that caused `indentOnInput` to fire for the wrong kinds of transactions. + +Fix a bug that could cause `indentOnInput` to apply its changes incorrectly. + +## 0.19.1 (2021-08-11) + +### Bug fixes + +Fix incorrect versions for @lezer dependencies. + +## 0.19.0 (2021-08-11) + +### Breaking changes + +CodeMirror now uses lezer 0.15, which means different package names (scoped with @lezer) and some breaking changes in the library. + +`EditorParseContext` is now called `ParseContext`. It is no longer passed to parsers, but must be retrieved with `ParseContext.get`. + +`IndentContext.lineIndent` now takes a position, not a `Line` object, as argument. + +`LezerLanguage` was renamed to `LRLanguage` (because all languages must emit Lezer-style trees, the name was misleading). + +`Language.parseString` no longer exists. You can just call `.parser.parse(...)` instead. + +### New features + +New `IndentContext.lineAt` method to access lines in a way that is aware of simulated line breaks. + +`IndentContext` now provides a `simulatedBreak` property through which client code can query whether the context has a simulated line break. + +## 0.18.2 (2021-06-01) + +### Bug fixes + +Fix an issue where asynchronous re-parsing (with dynamically loaded languages) sometimes failed to fully happen. + +## 0.18.1 (2021-03-31) + +### Breaking changes + +`EditorParseContext.getSkippingParser` now replaces `EditorParseContext.skippingParser` and allows you to provide a promise that'll cause parsing to start again. (The old property remains available until the next major release.) + +### Bug fixes + +Fix an issue where nested parsers could see past the end of the nested region. + +## 0.18.0 (2021-03-03) + +### Breaking changes + +Update dependencies to 0.18. + +### Breaking changes + +The `Language` constructor takes an additional argument that provides the top node type. + +### New features + +`Language` instances now have a `topNode` property giving their top node type. + +`TreeIndentContext` now has a `continue` method that allows an indenter to defer to the indentation of the parent nodes. + +## 0.17.5 (2021-02-19) + +### New features + +This package now exports a `foldInside` helper function, a fold function that should work for most delimited node types. + +## 0.17.4 (2021-01-15) + +## 0.17.3 (2021-01-15) + +### Bug fixes + +Parse scheduling has been improved to reduce the likelyhood of the user looking at unparsed code in big documents. + +Prevent parser from running too far past the current viewport in huge documents. + +## 0.17.2 (2021-01-06) + +### New features + +The package now also exports a CommonJS module. + +## 0.17.1 (2020-12-30) + +### Bug fixes + +Fix a bug where changing the editor configuration wouldn't update the language parser used. + +## 0.17.0 (2020-12-29) + +### Breaking changes + +First numbered release. + diff --git a/vendor/codemirror-language/LICENSE b/vendor/codemirror-language/LICENSE new file mode 100644 index 0000000000..9a91f48619 --- /dev/null +++ b/vendor/codemirror-language/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (C) 2018-2021 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/vendor/codemirror-language/README.md b/vendor/codemirror-language/README.md new file mode 100644 index 0000000000..e2ad86278c --- /dev/null +++ b/vendor/codemirror-language/README.md @@ -0,0 +1,66 @@ +# @codemirror/language [![NPM version](https://img.shields.io/npm/v/@codemirror/language.svg)](https://www.npmjs.org/package/@codemirror/language) + +[ [**WEBSITE**](https://codemirror.net/) | [**DOCS**](https://codemirror.net/docs/ref/#language) | [**ISSUES**](https://code.haverbeke.berlin/codemirror/dev/issues) | [**FORUM**](https://discuss.codemirror.net/) | [**CHANGELOG**](https://code.haverbeke.berlin/codemirror/language/src/branch/main/CHANGELOG.md) ] + +This package implements the language support infrastructure for the +[CodeMirror](https://codemirror.net/) code editor. + +The [project page](https://codemirror.net/) has more information, a +number of [examples](https://codemirror.net/examples/) and the +[documentation](https://codemirror.net/docs/). + +This code is released under an +[MIT license](https://code.haverbeke.berlin/codemirror/language/tree/main/LICENSE). + +We aim to be an inclusive, welcoming community. To make that explicit, +we have a [code of +conduct](http://contributor-covenant.org/version/1/1/0/) that applies +to communication around the project. + +## Usage + +Setting up a language from a [Lezer](https://lezer.codemirror.net) +parser looks like this: + +```javascript +import {parser} from "@lezer/json" +import {LRLanguage, continuedIndent, indentNodeProp, + foldNodeProp, foldInside} from "@codemirror/language" + +export const jsonLanguage = LRLanguage.define({ + name: "json", + parser: parser.configure({ + props: [ + indentNodeProp.add({ + Object: continuedIndent({except: /^\s*\}/}), + Array: continuedIndent({except: /^\s*\]/}) + }), + foldNodeProp.add({ + "Object Array": foldInside + }) + ] + }), + languageData: { + closeBrackets: {brackets: ["[", "{", '"']}, + indentOnInput: /^\s*[\}\]]$/ + } +}) +``` + +Often, you'll also use this package just to access some specific +language-related features, such as accessing the editor's syntax +tree... + +```javascript +import {syntaxTree} from "@codemirror/language" + +const tree = syntaxTree(view) +``` + +... or computing the appriate indentation at a given point. + +```javascript +import {getIndentation} from "@codemirror/language" + +console.log(getIndentation(view.state, view.state.selection.main.head)) +``` diff --git a/vendor/codemirror-language/UPSTREAM.md b/vendor/codemirror-language/UPSTREAM.md new file mode 100644 index 0000000000..548118f468 --- /dev/null +++ b/vendor/codemirror-language/UPSTREAM.md @@ -0,0 +1,6 @@ +# Upstream revision + +- Package: `@codemirror/language` 6.12.4 +- Repository: https://code.haverbeke.berlin/codemirror/language.git +- Commit: `89974ce5d39539ce6c5cfea5278443fa9381cbf2` +- npm release `gitHead`: `89974ce5d39539ce6c5cfea5278443fa9381cbf2` diff --git a/vendor/codemirror-language/dist/index.cjs b/vendor/codemirror-language/dist/index.cjs new file mode 100644 index 0000000000..33087595c5 --- /dev/null +++ b/vendor/codemirror-language/dist/index.cjs @@ -0,0 +1,3196 @@ +'use strict'; + +var common = require('@lezer/common'); +var state = require('@codemirror/state'); +var view = require('@codemirror/view'); +var highlight = require('@lezer/highlight'); +var styleMod = require('style-mod'); + +var _a; +/** +Node prop stored in a parser's top syntax node to provide the +facet that stores language-specific data for that language. +*/ +const languageDataProp = new common.NodeProp(); +/** +Helper function to define a facet (to be added to the top syntax +node(s) for a language via +[`languageDataProp`](https://codemirror.net/6/docs/ref/#language.languageDataProp)), that will be +used to associate language data with the language. You +probably only need this when subclassing +[`Language`](https://codemirror.net/6/docs/ref/#language.Language). +*/ +function defineLanguageFacet(baseData) { + return state.Facet.define({ + combine: baseData ? values => values.concat(baseData) : undefined + }); +} +/** +Syntax node prop used to register sublanguages. Should be added to +the top level node type for the language. +*/ +const sublanguageProp = new common.NodeProp(); +/** +A language object manages parsing and per-language +[metadata](https://codemirror.net/6/docs/ref/#state.EditorState.languageDataAt). Parse data is +managed as a [Lezer](https://lezer.codemirror.net) tree. The class +can be used directly, via the [`LRLanguage`](https://codemirror.net/6/docs/ref/#language.LRLanguage) +subclass for [Lezer](https://lezer.codemirror.net/) LR parsers, or +via the [`StreamLanguage`](https://codemirror.net/6/docs/ref/#language.StreamLanguage) subclass +for stream parsers. +*/ +class Language { + /** + Construct a language object. If you need to invoke this + directly, first define a data facet with + [`defineLanguageFacet`](https://codemirror.net/6/docs/ref/#language.defineLanguageFacet), and then + configure your parser to [attach](https://codemirror.net/6/docs/ref/#language.languageDataProp) it + to the language's outer syntax node. + */ + constructor( + /** + The [language data](https://codemirror.net/6/docs/ref/#state.EditorState.languageDataAt) facet + used for this language. + */ + data, parser, extraExtensions = [], + /** + A language name. + */ + name = "") { + this.data = data; + this.name = name; + // Kludge to define EditorState.tree as a debugging helper, + // without the EditorState package actually knowing about + // languages and lezer trees. + if (!state.EditorState.prototype.hasOwnProperty("tree")) + Object.defineProperty(state.EditorState.prototype, "tree", { get() { return syntaxTree(this); } }); + this.parser = parser; + this.extension = [ + language.of(this), + state.EditorState.languageData.of((state, pos, side) => { + let top = topNodeAt(state, pos, side), data = top.type.prop(languageDataProp); + if (!data) + return []; + let base = state.facet(data), sub = top.type.prop(sublanguageProp); + if (sub) { + let innerNode = top.resolve(pos - top.from, side); + for (let sublang of sub) + if (sublang.test(innerNode, state)) { + let data = state.facet(sublang.facet); + return sublang.type == "replace" ? data : data.concat(base); + } + } + return base; + }) + ].concat(extraExtensions); + } + /** + Query whether this language is active at the given position. + */ + isActiveAt(state, pos, side = -1) { + return topNodeAt(state, pos, side).type.prop(languageDataProp) == this.data; + } + /** + Find the document regions that were parsed using this language. + The returned regions will _include_ any nested languages rooted + in this language, when those exist. + */ + findRegions(state) { + let lang = state.facet(language); + if ((lang === null || lang === void 0 ? void 0 : lang.data) == this.data) + return [{ from: 0, to: state.doc.length }]; + if (!lang || !lang.allowsNesting) + return []; + let result = []; + let explore = (tree, from) => { + if (tree.prop(languageDataProp) == this.data) { + result.push({ from, to: from + tree.length }); + return; + } + let mount = tree.prop(common.NodeProp.mounted); + if (mount) { + if (mount.tree.prop(languageDataProp) == this.data) { + if (mount.overlay) + for (let r of mount.overlay) + result.push({ from: r.from + from, to: r.to + from }); + else + result.push({ from: from, to: from + tree.length }); + return; + } + else if (mount.overlay) { + let size = result.length; + explore(mount.tree, mount.overlay[0].from + from); + if (result.length > size) + return; + } + } + for (let i = 0; i < tree.children.length; i++) { + let ch = tree.children[i]; + if (ch instanceof common.Tree) + explore(ch, tree.positions[i] + from); + } + }; + explore(syntaxTree(state), 0); + return result; + } + /** + Indicates whether this language allows nested languages. The + default implementation returns true. + */ + get allowsNesting() { return true; } +} +/** +@internal +*/ +Language.setState = state.StateEffect.define(); +function topNodeAt(state, pos, side) { + let topLang = state.facet(language), tree = syntaxTree(state).topNode; + if (!topLang || topLang.allowsNesting) { + for (let node = tree; node; node = node.enter(pos, side, common.IterMode.ExcludeBuffers | common.IterMode.EnterBracketed)) + if (node.type.isTop) + tree = node; + } + return tree; +} +/** +A subclass of [`Language`](https://codemirror.net/6/docs/ref/#language.Language) for use with Lezer +[LR parsers](https://lezer.codemirror.net/docs/ref#lr.LRParser). +*/ +class LRLanguage extends Language { + constructor(data, parser, name) { + super(data, parser, [], name); + this.parser = parser; + } + /** + Define a language from a parser. + */ + static define(spec) { + let data = defineLanguageFacet(spec.languageData); + return new LRLanguage(data, spec.parser.configure({ + props: [languageDataProp.add(type => type.isTop ? data : undefined)] + }), spec.name); + } + /** + Create a new instance of this language with a reconfigured + version of its parser and optionally a new name. + */ + configure(options, name) { + return new LRLanguage(this.data, this.parser.configure(options), name || this.name); + } + get allowsNesting() { return this.parser.hasWrappers(); } +} +/** +Get the syntax tree for a state, which is the current (possibly +incomplete) parse tree of the active +[language](https://codemirror.net/6/docs/ref/#language.Language), or the empty tree if there is no +language available. +*/ +function syntaxTree(state) { + let field = state.field(Language.state, false); + return field ? field.tree : common.Tree.empty; +} +/** +Try to get a parse tree that spans at least up to `upto`. The +method will do at most `timeout` milliseconds of work to parse +up to that point if the tree isn't already available. +*/ +function ensureSyntaxTree(state, upto, timeout = 50) { + var _a; + let parse = (_a = state.field(Language.state, false)) === null || _a === void 0 ? void 0 : _a.context; + if (!parse) + return null; + let oldVieport = parse.viewport; + parse.updateViewport({ from: 0, to: upto }); + let result = parse.isDone(upto) || parse.work(timeout, upto) ? parse.tree : null; + parse.updateViewport(oldVieport); + return result; +} +/** +Queries whether there is a full syntax tree available up to the +given document position. If there isn't, the background parse +process _might_ still be working and update the tree further, but +there is no guarantee of that—the parser will [stop +working](https://codemirror.net/6/docs/ref/#language.syntaxParserRunning) when it has spent a +certain amount of time or has moved beyond the visible viewport. +Always returns false if no language has been enabled. +*/ +function syntaxTreeAvailable(state, upto = state.doc.length) { + var _a; + return ((_a = state.field(Language.state, false)) === null || _a === void 0 ? void 0 : _a.context.isDone(upto)) || false; +} +/** +Move parsing forward, and update the editor state afterwards to +reflect the new tree. Will work for at most `timeout` +milliseconds. Returns true if the parser managed get to the given +position in that time. +*/ +function forceParsing(view, upto = view.viewport.to, timeout = 100) { + let success = ensureSyntaxTree(view.state, upto, timeout); + if (success != syntaxTree(view.state)) + view.dispatch({}); + return !!success; +} +/** +Tells you whether the language parser is planning to do more +parsing work (in a `requestIdleCallback` pseudo-thread) or has +stopped running, either because it parsed the entire document, +because it spent too much time and was cut off, or because there +is no language parser enabled. +*/ +function syntaxParserRunning(view) { + var _a; + return ((_a = view.plugin(parseWorker)) === null || _a === void 0 ? void 0 : _a.isWorking()) || false; +} +const viewportPriorityParsingFacet = state.Facet.define({ + combine(values) { + if (!values.length) + return null; + let value = values[0]; + return { + sliceMs: Math.max(1, Math.min(8, Number.isFinite(value.sliceMs) ? value.sliceMs : 4)), + approximate: "outer-language" + }; + } +}); +/** +Enable temporary viewport-priority highlighting while the canonical parser +is still working through an earlier part of a large document. The +provisional tree is never exposed through `syntaxTree` or other language +services. +*/ +function viewportPriorityParsing(config) { + return viewportPriorityParsingFacet.of(config); +} +/** +Lezer-style +[`Input`](https://lezer.codemirror.net/docs/ref#common.Input) +object for a [`Text`](https://codemirror.net/6/docs/ref/#state.Text) object. +*/ +class DocInput { + /** + Create an input object for the given document. + */ + constructor(doc) { + this.doc = doc; + this.cursorPos = 0; + this.string = ""; + this.cursor = doc.iter(); + } + get length() { return this.doc.length; } + syncTo(pos) { + this.string = this.cursor.next(pos - this.cursorPos).value; + this.cursorPos = pos + this.string.length; + return this.cursorPos - this.string.length; + } + chunk(pos) { + this.syncTo(pos); + return this.string; + } + get lineChunks() { return true; } + read(from, to) { + let stringStart = this.cursorPos - this.string.length; + if (from < stringStart || to >= this.cursorPos) + return this.doc.sliceString(from, to); + else + return this.string.slice(from - stringStart, to - stringStart); + } +} +let currentContext = null; +/** +A parse context provided to parsers working on the editor content. +*/ +class ParseContext { + constructor(parser, + /** + The current editor state. + */ + state, + /** + Tree fragments that can be reused by incremental re-parses. + */ + fragments = [], + /** + @internal + */ + tree, + /** + @internal + */ + treeLen, + /** + The current editor viewport (or some overapproximation + thereof). Intended to be used for opportunistically avoiding + work (in which case + [`skipUntilInView`](https://codemirror.net/6/docs/ref/#language.ParseContext.skipUntilInView) + should be called to make sure the parser is restarted when the + skipped region becomes visible). + */ + viewport, + /** + @internal + */ + skipped, + /** + This is where skipping parsers can register a promise that, + when resolved, will schedule a new parse. It is cleared when + the parse worker picks up the promise. @internal + */ + scheduleOn) { + this.parser = parser; + this.state = state; + this.fragments = fragments; + this.tree = tree; + this.treeLen = treeLen; + this.viewport = viewport; + this.skipped = skipped; + this.scheduleOn = scheduleOn; + this.parse = null; + /** + @internal + */ + this.tempSkipped = []; + } + /** + @internal + */ + static create(parser, state, viewport) { + return new ParseContext(parser, state, [], common.Tree.empty, 0, viewport, [], null); + } + startParse() { + return this.parser.startParse(new DocInput(this.state.doc), this.fragments); + } + /** + @internal + */ + work(until, upto) { + if (upto != null && upto >= this.state.doc.length) + upto = undefined; + if (this.tree != common.Tree.empty && this.isDone(upto !== null && upto !== void 0 ? upto : this.state.doc.length)) { + this.takeTree(); + return true; + } + return this.withContext(() => { + var _a; + if (typeof until == "number") { + let endTime = Date.now() + until; + until = () => Date.now() > endTime; + } + if (!this.parse) + this.parse = this.startParse(); + if (upto != null && (this.parse.stoppedAt == null || this.parse.stoppedAt > upto) && + upto < this.state.doc.length) + this.parse.stopAt(upto); + for (;;) { + let done = this.parse.advance(); + if (done) { + this.fragments = this.withoutTempSkipped(common.TreeFragment.addTree(done, this.fragments, this.parse.stoppedAt != null)); + this.treeLen = (_a = this.parse.stoppedAt) !== null && _a !== void 0 ? _a : this.state.doc.length; + this.tree = done; + this.parse = null; + if (this.treeLen < (upto !== null && upto !== void 0 ? upto : this.state.doc.length)) + this.parse = this.startParse(); + else + return true; + } + if (until()) + return false; + } + }); + } + /** + @internal + */ + takeTree() { + let pos, tree; + if (this.parse && (pos = this.parse.parsedPos) >= this.treeLen) { + if (this.parse.stoppedAt == null || this.parse.stoppedAt > pos) + this.parse.stopAt(pos); + this.withContext(() => { while (!(tree = this.parse.advance())) { } }); + this.treeLen = pos; + this.tree = tree; + this.fragments = this.withoutTempSkipped(common.TreeFragment.addTree(this.tree, this.fragments, true)); + this.parse = null; + } + } + withContext(f) { + let prev = currentContext; + currentContext = this; + try { + return f(); + } + finally { + currentContext = prev; + } + } + withoutTempSkipped(fragments) { + for (let r; r = this.tempSkipped.pop();) + fragments = cutFragments(fragments, r.from, r.to); + return fragments; + } + /** + @internal + */ + changes(changes, newState) { + let { fragments, tree, treeLen, viewport, skipped } = this; + this.takeTree(); + if (!changes.empty) { + let ranges = []; + changes.iterChangedRanges((fromA, toA, fromB, toB) => ranges.push({ fromA, toA, fromB, toB })); + fragments = common.TreeFragment.applyChanges(fragments, ranges); + tree = common.Tree.empty; + treeLen = 0; + viewport = { from: changes.mapPos(viewport.from, -1), to: changes.mapPos(viewport.to, 1) }; + if (this.skipped.length) { + skipped = []; + for (let r of this.skipped) { + let from = changes.mapPos(r.from, 1), to = changes.mapPos(r.to, -1); + if (from < to) + skipped.push({ from, to }); + } + } + } + return new ParseContext(this.parser, newState, fragments, tree, treeLen, viewport, skipped, this.scheduleOn); + } + /** + @internal + */ + updateViewport(viewport) { + if (this.viewport.from == viewport.from && this.viewport.to == viewport.to) + return false; + this.viewport = viewport; + let startLen = this.skipped.length; + for (let i = 0; i < this.skipped.length; i++) { + let { from, to } = this.skipped[i]; + if (from < viewport.to && to > viewport.from) { + this.fragments = cutFragments(this.fragments, from, to); + this.skipped.splice(i--, 1); + } + } + if (this.skipped.length >= startLen) + return false; + this.reset(); + return true; + } + /** + @internal + */ + reset() { + if (this.parse) { + this.takeTree(); + this.parse = null; + } + } + /** + Notify the parse scheduler that the given region was skipped + because it wasn't in view, and the parse should be restarted + when it comes into view. + */ + skipUntilInView(from, to) { + this.skipped.push({ from, to }); + } + /** + Returns a parser intended to be used as placeholder when + asynchronously loading a nested parser. It'll skip its input and + mark it as not-really-parsed, so that the next update will parse + it again. + + When `until` is given, a reparse will be scheduled when that + promise resolves. + */ + static getSkippingParser(until) { + return new class extends common.Parser { + createParse(input, fragments, ranges) { + let from = ranges[0].from, to = ranges[ranges.length - 1].to; + let parser = { + parsedPos: from, + advance() { + let cx = currentContext; + if (cx) { + for (let r of ranges) + cx.tempSkipped.push(r); + if (until) + cx.scheduleOn = cx.scheduleOn ? Promise.all([cx.scheduleOn, until]) : until; + } + this.parsedPos = to; + return new common.Tree(common.NodeType.none, [], [], to - from); + }, + stoppedAt: null, + stopAt() { } + }; + return parser; + } + }; + } + /** + @internal + */ + isDone(upto) { + upto = Math.min(upto, this.state.doc.length); + let frags = this.fragments; + return this.treeLen >= upto && frags.length && frags[0].from == 0 && frags[0].to >= upto; + } + /** + Get the context for the current parse, or `null` if no editor + parse is in progress. + */ + static get() { return currentContext; } +} +function cutFragments(fragments, from, to) { + return common.TreeFragment.applyChanges(fragments, [{ fromA: from, toA: to, fromB: from, toB: to }]); +} +class LanguageState { + constructor( + // A mutable parse state that is used to preserve work done during + // the lifetime of a state when moving to the next state. + context) { + this.context = context; + this.tree = context.tree; + } + apply(tr) { + var _a, _b; + if (!tr.docChanged && this.tree == this.context.tree) + return this; + let newCx = this.context.changes(tr.changes, tr.state); + // If the previous parse wasn't done, go forward only up to its + // end position or the end of the viewport, to avoid slowing down + // state updates with parse work beyond the viewport. + let upto = this.context.treeLen == tr.startState.doc.length ? undefined + : Math.max(tr.changes.mapPos(this.context.treeLen), newCx.viewport.to); + let budget = (_b = (_a = tr.state.facet(viewportPriorityParsingFacet)) === null || _a === void 0 ? void 0 : _a.sliceMs) !== null && _b !== void 0 ? _b : 20 /* Work.Apply */; + if (!newCx.work(budget, upto)) + newCx.takeTree(); + return new LanguageState(newCx); + } + static init(state) { + var _a, _b; + let vpTo = Math.min(3000 /* Work.InitViewport */, state.doc.length); + let parseState = ParseContext.create(state.facet(language).parser, state, { from: 0, to: vpTo }); + let budget = (_b = (_a = state.facet(viewportPriorityParsingFacet)) === null || _a === void 0 ? void 0 : _a.sliceMs) !== null && _b !== void 0 ? _b : 20 /* Work.Apply */; + if (!parseState.work(budget, vpTo)) + parseState.takeTree(); + return new LanguageState(parseState); + } +} +Language.state = state.StateField.define({ + create: LanguageState.init, + update(value, tr) { + for (let e of tr.effects) + if (e.is(Language.setState)) + return e.value; + if (tr.startState.facet(language) != tr.state.facet(language)) + return LanguageState.init(tr.state); + return value.apply(tr); + } +}); +let requestIdle = (callback, shortSlices = false) => { + let timeout = setTimeout(() => callback(), shortSlices ? 16 : 500 /* Work.MaxPause */); + return () => clearTimeout(timeout); +}; +if (typeof requestIdleCallback != "undefined") + requestIdle = (callback, shortSlices = false) => { + let pause = shortSlices ? 16 : 100 /* Work.MinPause */; + let idle = -1, timeout = setTimeout(() => { + idle = requestIdleCallback(callback, { timeout: 500 /* Work.MaxPause */ - pause }); + }, pause); + return () => idle < 0 ? clearTimeout(timeout) : cancelIdleCallback(idle); + }; +const isInputPending = typeof navigator != "undefined" && ((_a = navigator.scheduling) === null || _a === void 0 ? void 0 : _a.isInputPending) + ? () => navigator.scheduling.isInputPending() : null; +const ProvisionalEditDebounce = 50; +const ProvisionalDebug = Symbol.for("@codemirror/language.viewportPriorityDebug"); +/** +@internal +*/ +function provisionalParseIsCurrent(task, generation, doc, lang, destroyed) { + return !!(task === null || task === void 0 ? void 0 : task.parse) && !destroyed && task.generation == generation && + task.doc == doc && task.language == lang; +} +/** +@internal +*/ +class ParseWorker { + constructor(view) { + this.view = view; + this.working = null; + this.workScheduled = 0; + // End of the current time chunk + this.chunkEnd = -1; + // Milliseconds of budget left for this chunk + this.chunkBudget = -1; + this.provisional = null; + this.provisionalTimer = -1; + this.provisionalGeneration = 0; + this.provisionalVersion = 0; + // Completed windows survive viewport changes. Only document/language changes + // invalidate these visual trees; canonical syntax state remains independent. + this.provisionalCache = []; + this.destroyed = false; + this.work = this.work.bind(this); + this.workProvisional = this.workProvisional.bind(this); + this.scheduleWork(); + this.restartProvisional(); + Object.defineProperty(this.view, ProvisionalDebug, { + configurable: true, + value: () => { + var _a, _b; + return ({ + generation: this.provisionalGeneration, + version: this.provisionalVersion, + pending: !!((_a = this.provisional) === null || _a === void 0 ? void 0 : _a.parse), + published: this.provisionalCache.length > 0, + cachedWindows: this.provisionalCache.length, + failed: !!((_b = this.provisional) === null || _b === void 0 ? void 0 : _b.failed) + }); + } + }); + } + update(update) { + let cx = this.view.state.field(Language.state).context; + if (cx.updateViewport(update.view.viewport) || this.view.viewport.to > cx.treeLen) + this.scheduleWork(); + if (update.docChanged || update.selectionSet) { + if (this.view.hasFocus) + this.chunkBudget += 50 /* Work.ChangeBonus */; + this.scheduleWork(); + } + let languageChanged = update.startState.facet(language) != update.state.facet(language); + if (update.docChanged || languageChanged) + this.clearProvisional(); + this.restartProvisional(update.docChanged ? ProvisionalEditDebounce : 0); + if (update.viewportChanged && !update.docChanged && !languageChanged) + this.workProvisional(false); + this.checkAsyncSchedule(cx); + } + provisionalRanges() { + let ranges = this.view.visibleRanges; + if (!ranges.length) + return []; + let doc = this.view.state.doc, size = 128; + let first = Math.floor((doc.lineAt(ranges[0].from).number - 1) / size); + let last = Math.floor((doc.lineAt(ranges[ranges.length - 1].to).number - 1) / size); + let result = []; + let add = (block) => { + let line = block * size + 1; + if (line < 1 || line > doc.lines) + return; + let end = Math.min(doc.lines, line + size - 1); + result.push({ from: doc.line(line).from, + to: end == doc.lines ? doc.length : doc.line(end + 1).from }); + }; + // Finish stable windows instead of restarting on each pixel of movement. + // Visible windows come first, followed by two windows in each direction. + for (let block = first; block <= last; block++) + add(block); + for (let ahead = 1; ahead <= 2; ahead++) { + add(last + ahead); + add(first - ahead); + } + return result.filter(range => range.from < range.to).slice(0, 12); + } + clearProvisional(notify = false, keepCache = false) { + if (this.provisionalTimer > -1) + clearTimeout(this.provisionalTimer); + this.provisionalTimer = -1; + this.provisionalGeneration++; + let hadTree = this.provisionalCache.length > 0; + this.provisional = null; + if (!keepCache) + this.provisionalCache = []; + if (notify && hadTree) + this.provisionalVersion++; + } + restartProvisional(delay = 0) { + let config = this.view.state.facet(viewportPriorityParsingFacet); + let lang = this.view.state.facet(language); + let cx = this.view.state.field(Language.state).context; + if (!config || !lang) { + this.clearProvisional(true); + return; + } + let ranges = this.provisionalRanges(); + let missing = ranges.filter(range => !cx.isDone(range.to) && + !this.provisionalCache.some(tree => tree.from == range.from && tree.to == range.to)); + let current = this.provisional; + if (current && current.doc == this.view.state.doc && current.language == lang && + missing.some(range => range.from == current.from && range.to == current.to)) + return; + if (!current && !missing.length) + return; + this.clearProvisional(false, true); + let range = missing[0]; + if (!range) + return; + let generation = ++this.provisionalGeneration; + try { + this.provisional = { + generation, + doc: this.view.state.doc, + language: lang, + from: range.from, + to: range.to, + parse: lang.parser.startParse(new DocInput(this.view.state.doc), [], [range]), + tree: null, + published: false, + failed: false + }; + this.scheduleProvisional(delay); + } + catch (error) { + this.provisional = { + generation, + doc: this.view.state.doc, + language: lang, + from: range.from, + to: range.to, + parse: null, + tree: null, + published: false, + failed: true + }; + this.provisionalTimer = setTimeout(() => { + var _a; + this.provisionalTimer = -1; + if (!this.destroyed && ((_a = this.provisional) === null || _a === void 0 ? void 0 : _a.generation) == generation && this.provisional.failed) + this.view.dispatch({}); + }, 0); + view.logException(this.view.state, error, "viewport-priority parse"); + } + } + scheduleProvisional(delay = 0) { + if (this.provisionalTimer > -1 || !this.provisional || this.destroyed) + return; + this.provisionalTimer = setTimeout(this.workProvisional, Math.max(0, delay)); + } + renderCritical() { + var _a; + return !!((_a = this.view.controlledTouchScrollController) === null || _a === void 0 ? void 0 : _a.renderCritical); + } + publishProvisional(task, notify) { + task.published = true; + this.provisionalVersion++; + this.provisionalCache.push({ tree: task.tree, from: task.from, to: task.to, + version: this.provisionalVersion, approximate: "outer-language" }); + // Keep every requested window before nearby history. Character distance + // alone can evict a requested window containing an unusually long line, + // causing it to be parsed repeatedly without ever staying in the cache. + let wanted = new Set(this.provisionalRanges().map(range => range.from)); + let center = (this.view.viewport.from + this.view.viewport.to) / 2; + this.provisionalCache.sort((a, b) => Number(wanted.has(b.from)) - Number(wanted.has(a.from)) || + Math.abs((a.from + a.to) / 2 - center) - Math.abs((b.from + b.to) / 2 - center)); + this.provisionalCache.length = Math.min(this.provisionalCache.length, 12); + if (notify) { + this.view.dispatch({}); + this.restartProvisional(); + } + else { + let generation = task.generation; + this.provisionalTimer = setTimeout(() => { + var _a; + this.provisionalTimer = -1; + if (!this.destroyed && ((_a = this.provisional) === null || _a === void 0 ? void 0 : _a.generation) == generation && this.provisional.published) { + this.view.dispatch({}); + this.restartProvisional(); + } + }, 0); + } + } + workProvisional(notify = true) { + if (this.provisionalTimer > -1) + clearTimeout(this.provisionalTimer); + this.provisionalTimer = -1; + let task = this.provisional; + let config = this.view.state.facet(viewportPriorityParsingFacet); + if ((task === null || task === void 0 ? void 0 : task.tree) && !task.published) { + if (isInputPending === null || isInputPending === void 0 ? void 0 : isInputPending()) + this.scheduleProvisional(16); + else + this.publishProvisional(task, notify); + return; + } + if (!config || !provisionalParseIsCurrent(task, this.provisionalGeneration, this.view.state.doc, this.view.state.facet(language), this.destroyed)) + return; + // Keep canonical parsing deferred during gestures, but prepare visual + // windows in short slices so input and momentum can continue between them. + if (isInputPending === null || isInputPending === void 0 ? void 0 : isInputPending()) { + this.scheduleProvisional(16); + return; + } + let clock = this.view.win.performance; + let end = clock.now() + (this.renderCritical() ? Math.min(2, config.sliceMs) : config.sliceMs); + let tree = null; + do { + tree = task.parse.advance(); + if (tree || notify && isInputPending && isInputPending()) + break; + } while (clock.now() < end); + if (tree) { + task.tree = tree; + task.parse = null; + if (isInputPending === null || isInputPending === void 0 ? void 0 : isInputPending()) + this.scheduleProvisional(16); + else + this.publishProvisional(task, notify); + } + else { + this.scheduleProvisional(this.renderCritical() ? 16 : 0); + } + } + viewportTree() { + let trees = this.viewportTrees(); + let tree = trees.find(tree => tree.to > this.view.viewport.from && tree.from < this.view.viewport.to); + return tree ? Object.assign(Object.assign({}, tree), { version: this.provisionalVersion }) : null; + } + viewportTrees() { + return this.provisionalCache.slice().sort((a, b) => a.from - b.from); + } + provisionalPending() { + var _a, _b; + return !!(this.provisionalTimer > -1 || ((_a = this.provisional) === null || _a === void 0 ? void 0 : _a.parse) || + ((_b = this.provisional) === null || _b === void 0 ? void 0 : _b.tree) && !this.provisional.published); + } + scheduleWork() { + if (this.working) + return; + let { state } = this.view, field = state.field(Language.state); + if (field.tree != field.context.tree || !field.context.isDone(state.doc.length)) + this.working = requestIdle(this.work, !!state.facet(viewportPriorityParsingFacet)); + } + work(deadline) { + var _a; + this.working = null; + if (this.renderCritical()) { + this.scheduleWork(); + return; + } + let now = Date.now(); + if (this.chunkEnd < now && (this.chunkEnd < 0 || this.view.hasFocus)) { // Start a new chunk + this.chunkEnd = now + 30000 /* Work.ChunkTime */; + this.chunkBudget = 3000 /* Work.ChunkBudget */; + } + if (this.chunkBudget <= 0) + return; // No more budget + let { state, viewport: { to: vpTo } } = this.view, field = state.field(Language.state); + if (field.tree == field.context.tree && field.context.isDone(vpTo + 100000 /* Work.MaxParseAhead */)) + return; + let config = state.facet(viewportPriorityParsingFacet); + let endTime = Date.now() + Math.min(this.chunkBudget, (_a = config === null || config === void 0 ? void 0 : config.sliceMs) !== null && _a !== void 0 ? _a : 100 /* Work.Slice */, deadline && !isInputPending ? Math.max(config ? 0 : 25 /* Work.MinSlice */, deadline.timeRemaining() - 5) : 1e9); + let viewportFirst = field.context.treeLen < vpTo && state.doc.length > vpTo + 1000; + let done = field.context.work(() => { + return this.renderCritical() || isInputPending && isInputPending() || Date.now() > endTime; + }, vpTo + (viewportFirst ? 0 : 100000 /* Work.MaxParseAhead */)); + this.chunkBudget -= Date.now() - now; + if (done || this.chunkBudget <= 0) { + field.context.takeTree(); + this.view.dispatch({ effects: Language.setState.of(new LanguageState(field.context)) }); + } + if (this.chunkBudget > 0 && !(done && !viewportFirst)) + this.scheduleWork(); + this.checkAsyncSchedule(field.context); + } + checkAsyncSchedule(cx) { + if (cx.scheduleOn) { + this.workScheduled++; + cx.scheduleOn + .then(() => this.scheduleWork()) + .catch(err => view.logException(this.view.state, err)) + .then(() => this.workScheduled--); + cx.scheduleOn = null; + } + } + destroy() { + this.destroyed = true; + if (this.working) + this.working(); + this.clearProvisional(); + delete this.view[ProvisionalDebug]; + } + isWorking() { + var _a; + return !!(this.working || this.workScheduled > 0 || this.provisionalTimer > -1 || ((_a = this.provisional) === null || _a === void 0 ? void 0 : _a.parse)); + } +} +const parseWorker = view.ViewPlugin.fromClass(ParseWorker, { + eventHandlers: { focus() { this.scheduleWork(); } } +}); +/** +@internal +*/ +function viewportPriorityTree(view) { + var _a; + return ((_a = view.plugin(parseWorker)) === null || _a === void 0 ? void 0 : _a.viewportTree()) || null; +} +/** +@internal +*/ +function viewportPriorityTrees(view) { + var _a; + return ((_a = view.plugin(parseWorker)) === null || _a === void 0 ? void 0 : _a.viewportTrees()) || []; +} +/** +@internal +*/ +function viewportPriorityParsingPending(view) { + var _a; + return !!((_a = view.plugin(parseWorker)) === null || _a === void 0 ? void 0 : _a.provisionalPending()); +} +/** +@internal +*/ +function viewportPriorityParsingFailed(view) { + var _a, _b; + return !!((_b = (_a = view.plugin(parseWorker)) === null || _a === void 0 ? void 0 : _a.provisional) === null || _b === void 0 ? void 0 : _b.failed); +} +/** +The facet used to associate a language with an editor state. Used +by `Language` object's `extension` property (so you don't need to +manually wrap your languages in this). Can be used to access the +current language on a state. +*/ +const language = state.Facet.define({ + combine(languages) { return languages.length ? languages[0] : null; }, + enables: language => [ + Language.state, + parseWorker, + view.EditorView.contentAttributes.compute([language], state => { + let lang = state.facet(language); + return lang && lang.name ? { "data-language": lang.name } : {}; + }) + ] +}); +/** +This class bundles a [language](https://codemirror.net/6/docs/ref/#language.Language) with an +optional set of supporting extensions. Language packages are +encouraged to export a function that optionally takes a +configuration object and returns a `LanguageSupport` instance, as +the main way for client code to use the package. +*/ +class LanguageSupport { + /** + Create a language support object. + */ + constructor( + /** + The language object. + */ + language, + /** + An optional set of supporting extensions. When nesting a + language in another language, the outer language is encouraged + to include the supporting extensions for its inner languages + in its own set of support extensions. + */ + support = []) { + this.language = language; + this.support = support; + this.extension = [language, support]; + } +} +/** +Language descriptions are used to store metadata about languages +and to dynamically load them. Their main role is finding the +appropriate language for a filename or dynamically loading nested +parsers. +*/ +class LanguageDescription { + constructor( + /** + The name of this language. + */ + name, + /** + Alternative names for the mode (lowercased, includes `this.name`). + */ + alias, + /** + File extensions associated with this language. + */ + extensions, + /** + Optional filename pattern that should be associated with this + language. + */ + filename, loadFunc, + /** + If the language has been loaded, this will hold its value. + */ + support = undefined) { + this.name = name; + this.alias = alias; + this.extensions = extensions; + this.filename = filename; + this.loadFunc = loadFunc; + this.support = support; + this.loading = null; + } + /** + Start loading the the language. Will return a promise that + resolves to a [`LanguageSupport`](https://codemirror.net/6/docs/ref/#language.LanguageSupport) + object when the language successfully loads. + */ + load() { + return this.loading || (this.loading = this.loadFunc().then(support => this.support = support, err => { this.loading = null; throw err; })); + } + /** + Create a language description. + */ + static of(spec) { + let { load, support } = spec; + if (!load) { + if (!support) + throw new RangeError("Must pass either 'load' or 'support' to LanguageDescription.of"); + load = () => Promise.resolve(support); + } + return new LanguageDescription(spec.name, (spec.alias || []).concat(spec.name).map(s => s.toLowerCase()), spec.extensions || [], spec.filename, load, support); + } + /** + Look for a language in the given array of descriptions that + matches the filename. Will first match + [`filename`](https://codemirror.net/6/docs/ref/#language.LanguageDescription.filename) patterns, + and then [extensions](https://codemirror.net/6/docs/ref/#language.LanguageDescription.extensions), + and return the first language that matches. + */ + static matchFilename(descs, filename) { + for (let d of descs) + if (d.filename && d.filename.test(filename)) + return d; + let ext = /\.([^.]+)$/.exec(filename); + if (ext) + for (let d of descs) + if (d.extensions.indexOf(ext[1]) > -1) + return d; + return null; + } + /** + Look for a language whose name or alias matches the the given + name (case-insensitively). If `fuzzy` is true, and no direct + matchs is found, this'll also search for a language whose name + or alias occurs in the string (for names shorter than three + characters, only when surrounded by non-word characters). + */ + static matchLanguageName(descs, name, fuzzy = true) { + name = name.toLowerCase(); + for (let d of descs) + if (d.alias.some(a => a == name)) + return d; + if (fuzzy) + for (let d of descs) + for (let a of d.alias) { + let found = name.indexOf(a); + if (found > -1 && (a.length > 2 || !/\w/.test(name[found - 1]) && !/\w/.test(name[found + a.length]))) + return d; + } + return null; + } +} + +/** +Facet that defines a way to provide a function that computes the +appropriate indentation depth, as a column number (see +[`indentString`](https://codemirror.net/6/docs/ref/#language.indentString)), at the start of a given +line. A return value of `null` indicates no indentation can be +determined, and the line should inherit the indentation of the one +above it. A return value of `undefined` defers to the next indent +service. +*/ +const indentService = state.Facet.define(); +/** +Facet for overriding the unit by which indentation happens. Should +be a string consisting entirely of the same whitespace character. +When not set, this defaults to 2 spaces. +*/ +const indentUnit = state.Facet.define({ + combine: values => { + if (!values.length) + return " "; + let unit = values[0]; + if (!unit || /\S/.test(unit) || Array.from(unit).some(e => e != unit[0])) + throw new Error("Invalid indent unit: " + JSON.stringify(values[0])); + return unit; + } +}); +/** +Return the _column width_ of an indent unit in the state. +Determined by the [`indentUnit`](https://codemirror.net/6/docs/ref/#language.indentUnit) +facet, and [`tabSize`](https://codemirror.net/6/docs/ref/#state.EditorState^tabSize) when that +contains tabs. +*/ +function getIndentUnit(state) { + let unit = state.facet(indentUnit); + return unit.charCodeAt(0) == 9 ? state.tabSize * unit.length : unit.length; +} +/** +Create an indentation string that covers columns 0 to `cols`. +Will use tabs for as much of the columns as possible when the +[`indentUnit`](https://codemirror.net/6/docs/ref/#language.indentUnit) facet contains +tabs. +*/ +function indentString(state, cols) { + let result = "", ts = state.tabSize, ch = state.facet(indentUnit)[0]; + if (ch == "\t") { + while (cols >= ts) { + result += "\t"; + cols -= ts; + } + ch = " "; + } + for (let i = 0; i < cols; i++) + result += ch; + return result; +} +/** +Get the indentation, as a column number, at the given position. +Will first consult any [indent services](https://codemirror.net/6/docs/ref/#language.indentService) +that are registered, and if none of those return an indentation, +this will check the syntax tree for the [indent node +prop](https://codemirror.net/6/docs/ref/#language.indentNodeProp) and use that if found. Returns a +number when an indentation could be determined, and null +otherwise. +*/ +function getIndentation(context, pos) { + if (context instanceof state.EditorState) + context = new IndentContext(context); + for (let service of context.state.facet(indentService)) { + let result = service(context, pos); + if (result !== undefined) + return result; + } + let tree = syntaxTree(context.state); + return tree.length >= pos ? syntaxIndentation(context, tree, pos) : null; +} +/** +Create a change set that auto-indents all lines touched by the +given document range. +*/ +function indentRange(state, from, to) { + let updated = Object.create(null); + let context = new IndentContext(state, { overrideIndentation: start => { var _a; return (_a = updated[start]) !== null && _a !== void 0 ? _a : -1; } }); + let changes = []; + for (let pos = from; pos <= to;) { + let line = state.doc.lineAt(pos); + pos = line.to + 1; + let indent = getIndentation(context, line.from); + if (indent == null) + continue; + if (!/\S/.test(line.text)) + indent = 0; + let cur = /^\s*/.exec(line.text)[0]; + let norm = indentString(state, indent); + if (cur != norm) { + updated[line.from] = indent; + changes.push({ from: line.from, to: line.from + cur.length, insert: norm }); + } + } + return state.changes(changes); +} +/** +Indentation contexts are used when calling [indentation +services](https://codemirror.net/6/docs/ref/#language.indentService). They provide helper utilities +useful in indentation logic, and can selectively override the +indentation reported for some lines. +*/ +class IndentContext { + /** + Create an indent context. + */ + constructor( + /** + The editor state. + */ + state, + /** + @internal + */ + options = {}) { + this.state = state; + this.options = options; + this.unit = getIndentUnit(state); + } + /** + Get a description of the line at the given position, taking + [simulated line + breaks](https://codemirror.net/6/docs/ref/#language.IndentContext.constructor^options.simulateBreak) + into account. If there is such a break at `pos`, the `bias` + argument determines whether the part of the line line before or + after the break is used. + */ + lineAt(pos, bias = 1) { + let line = this.state.doc.lineAt(pos); + let { simulateBreak, simulateDoubleBreak } = this.options; + if (simulateBreak != null && simulateBreak >= line.from && simulateBreak <= line.to) { + if (simulateDoubleBreak && simulateBreak == pos) + return { text: "", from: pos }; + else if (bias < 0 ? simulateBreak < pos : simulateBreak <= pos) + return { text: line.text.slice(simulateBreak - line.from), from: simulateBreak }; + else + return { text: line.text.slice(0, simulateBreak - line.from), from: line.from }; + } + return line; + } + /** + Get the text directly after `pos`, either the entire line + or the next 100 characters, whichever is shorter. + */ + textAfterPos(pos, bias = 1) { + if (this.options.simulateDoubleBreak && pos == this.options.simulateBreak) + return ""; + let { text, from } = this.lineAt(pos, bias); + return text.slice(pos - from, Math.min(text.length, pos + 100 - from)); + } + /** + Find the column for the given position. + */ + column(pos, bias = 1) { + let { text, from } = this.lineAt(pos, bias); + let result = this.countColumn(text, pos - from); + let override = this.options.overrideIndentation ? this.options.overrideIndentation(from) : -1; + if (override > -1) + result += override - this.countColumn(text, text.search(/\S|$/)); + return result; + } + /** + Find the column position (taking tabs into account) of the given + position in the given string. + */ + countColumn(line, pos = line.length) { + return state.countColumn(line, this.state.tabSize, pos); + } + /** + Find the indentation column of the line at the given point. + */ + lineIndent(pos, bias = 1) { + let { text, from } = this.lineAt(pos, bias); + let override = this.options.overrideIndentation; + if (override) { + let overriden = override(from); + if (overriden > -1) + return overriden; + } + return this.countColumn(text, text.search(/\S|$/)); + } + /** + Returns the [simulated line + break](https://codemirror.net/6/docs/ref/#language.IndentContext.constructor^options.simulateBreak) + for this context, if any. + */ + get simulatedBreak() { + return this.options.simulateBreak || null; + } +} +/** +A syntax tree node prop used to associate indentation strategies +with node types. Such a strategy is a function from an indentation +context to a column number (see also +[`indentString`](https://codemirror.net/6/docs/ref/#language.indentString)) or null, where null +indicates that no definitive indentation can be determined. +*/ +const indentNodeProp = new common.NodeProp(); +// Compute the indentation for a given position from the syntax tree. +function syntaxIndentation(cx, ast, pos) { + let stack = ast.resolveStack(pos); + let inner = ast.resolveInner(pos, -1).resolve(pos, 0).enterUnfinishedNodesBefore(pos); + if (inner != stack.node) { + let add = []; + for (let cur = inner; cur && !(cur.from < stack.node.from || cur.to > stack.node.to || + cur.from == stack.node.from && cur.type == stack.node.type); cur = cur.parent) + add.push(cur); + for (let i = add.length - 1; i >= 0; i--) + stack = { node: add[i], next: stack }; + } + return indentFor(stack, cx, pos); +} +function indentFor(stack, cx, pos) { + for (let cur = stack; cur; cur = cur.next) { + let strategy = indentStrategy(cur.node); + if (strategy) + return strategy(TreeIndentContext.create(cx, pos, cur)); + } + return 0; +} +function ignoreClosed(cx) { + return cx.pos == cx.options.simulateBreak && cx.options.simulateDoubleBreak; +} +function indentStrategy(tree) { + let strategy = tree.type.prop(indentNodeProp); + if (strategy) + return strategy; + let first = tree.firstChild, close; + if (first && (close = first.type.prop(common.NodeProp.closedBy))) { + let last = tree.lastChild, closed = last && close.indexOf(last.name) > -1; + return cx => delimitedStrategy(cx, true, 1, undefined, closed && !ignoreClosed(cx) ? last.from : undefined); + } + return tree.parent == null ? topIndent : null; +} +function topIndent() { return 0; } +/** +Objects of this type provide context information and helper +methods to indentation functions registered on syntax nodes. +*/ +class TreeIndentContext extends IndentContext { + constructor(base, + /** + The position at which indentation is being computed. + */ + pos, + /** + @internal + */ + context) { + super(base.state, base.options); + this.base = base; + this.pos = pos; + this.context = context; + } + /** + The syntax tree node to which the indentation strategy + applies. + */ + get node() { return this.context.node; } + /** + @internal + */ + static create(base, pos, context) { + return new TreeIndentContext(base, pos, context); + } + /** + Get the text directly after `this.pos`, either the entire line + or the next 100 characters, whichever is shorter. + */ + get textAfter() { + return this.textAfterPos(this.pos); + } + /** + Get the indentation at the reference line for `this.node`, which + is the line on which it starts, unless there is a node that is + _not_ a parent of this node covering the start of that line. If + so, the line at the start of that node is tried, again skipping + on if it is covered by another such node. + */ + get baseIndent() { + return this.baseIndentFor(this.node); + } + /** + Get the indentation for the reference line of the given node + (see [`baseIndent`](https://codemirror.net/6/docs/ref/#language.TreeIndentContext.baseIndent)). + */ + baseIndentFor(node) { + let line = this.state.doc.lineAt(node.from); + // Skip line starts that are covered by a sibling (or cousin, etc) + for (;;) { + let atBreak = node.resolve(line.from); + while (atBreak.parent && atBreak.parent.from == atBreak.from) + atBreak = atBreak.parent; + if (isParent(atBreak, node)) + break; + line = this.state.doc.lineAt(atBreak.from); + } + return this.lineIndent(line.from); + } + /** + Continue looking for indentations in the node's parent nodes, + and return the result of that. + */ + continue() { + return indentFor(this.context.next, this.base, this.pos); + } +} +function isParent(parent, of) { + for (let cur = of; cur; cur = cur.parent) + if (parent == cur) + return true; + return false; +} +// Check whether a delimited node is aligned (meaning there are +// non-skipped nodes on the same line as the opening delimiter). And +// if so, return the opening token. +function bracketedAligned(context) { + let tree = context.node; + let openToken = tree.childAfter(tree.from), last = tree.lastChild; + if (!openToken) + return null; + let sim = context.options.simulateBreak; + let openLine = context.state.doc.lineAt(openToken.from); + let lineEnd = sim == null || sim <= openLine.from ? openLine.to : Math.min(openLine.to, sim); + for (let pos = openToken.to;;) { + let next = tree.childAfter(pos); + if (!next || next == last) + return null; + if (!next.type.isSkipped) { + if (next.from >= lineEnd) + return null; + let space = /^ */.exec(openLine.text.slice(openToken.to - openLine.from))[0].length; + return { from: openToken.from, to: openToken.to + space }; + } + pos = next.to; + } +} +/** +An indentation strategy for delimited (usually bracketed) nodes. +Will, by default, indent one unit more than the parent's base +indent unless the line starts with a closing token. When `align` +is true and there are non-skipped nodes on the node's opening +line, the content of the node will be aligned with the end of the +opening node, like this: + + foo(bar, + baz) +*/ +function delimitedIndent({ closing, align = true, units = 1 }) { + return (context) => delimitedStrategy(context, align, units, closing); +} +function delimitedStrategy(context, align, units, closing, closedAt) { + let after = context.textAfter, space = after.match(/^\s*/)[0].length; + let closed = closing && after.slice(space, space + closing.length) == closing || closedAt == context.pos + space; + let aligned = align ? bracketedAligned(context) : null; + if (aligned) + return closed ? context.column(aligned.from) : context.column(aligned.to); + return context.baseIndent + (closed ? 0 : context.unit * units); +} +/** +An indentation strategy that aligns a node's content to its base +indentation. +*/ +const flatIndent = (context) => context.baseIndent; +/** +Creates an indentation strategy that, by default, indents +continued lines one unit more than the node's base indentation. +You can provide `except` to prevent indentation of lines that +match a pattern (for example `/^else\b/` in `if`/`else` +constructs), and you can change the amount of units used with the +`units` option. +*/ +function continuedIndent({ except, units = 1 } = {}) { + return (context) => { + let matchExcept = except && except.test(context.textAfter); + return context.baseIndent + (matchExcept ? 0 : units * context.unit); + }; +} +const DontIndentBeyond = 200; +/** +Enables reindentation on input. When a language defines an +`indentOnInput` field in its [language +data](https://codemirror.net/6/docs/ref/#state.EditorState.languageDataAt), which must hold a regular +expression, the line at the cursor will be reindented whenever new +text is typed and the input from the start of the line up to the +cursor matches that regexp. + +To avoid unneccesary reindents, it is recommended to start the +regexp with `^` (usually followed by `\s*`), and end it with `$`. +For example, `/^\s*\}$/` will reindent when a closing brace is +added at the start of a line. +*/ +function indentOnInput() { + return state.EditorState.transactionFilter.of(tr => { + if (!tr.docChanged || !tr.isUserEvent("input.type") && !tr.isUserEvent("input.complete")) + return tr; + let rules = tr.startState.languageDataAt("indentOnInput", tr.startState.selection.main.head); + if (!rules.length) + return tr; + let doc = tr.newDoc, { head } = tr.newSelection.main, line = doc.lineAt(head); + if (head > line.from + DontIndentBeyond) + return tr; + let lineStart = doc.sliceString(line.from, head); + if (!rules.some(r => r.test(lineStart))) + return tr; + let { state } = tr, last = -1, changes = []; + for (let { head } of state.selection.ranges) { + let line = state.doc.lineAt(head); + if (line.from == last) + continue; + last = line.from; + let indent = getIndentation(state, line.from); + if (indent == null) + continue; + let cur = /^\s*/.exec(line.text)[0]; + let norm = indentString(state, indent); + if (cur != norm) + changes.push({ from: line.from, to: line.from + cur.length, insert: norm }); + } + return changes.length ? [tr, { changes, sequential: true }] : tr; + }); +} + +/** +A facet that registers a code folding service. When called with +the extent of a line, such a function should return a foldable +range that starts on that line (but continues beyond it), if one +can be found. +*/ +const foldService = state.Facet.define(); +/** +This node prop is used to associate folding information with +syntax node types. Given a syntax node, it should check whether +that tree is foldable and return the range that can be collapsed +when it is. +*/ +const foldNodeProp = new common.NodeProp(); +/** +[Fold](https://codemirror.net/6/docs/ref/#language.foldNodeProp) function that folds everything but +the first and the last child of a syntax node. Useful for nodes +that start and end with delimiters. +*/ +function foldInside(node) { + let first = node.firstChild, last = node.lastChild; + return first && first.to < last.from ? { from: first.to, to: last.type.isError ? node.to : last.from } : null; +} +function syntaxFolding(state, start, end) { + let tree = syntaxTree(state); + if (tree.length < end) + return null; + let stack = tree.resolveStack(end, 1); + let found = null; + for (let iter = stack; iter; iter = iter.next) { + let cur = iter.node; + if (cur.to <= end || cur.from > end) + continue; + if (found && cur.from < start) + break; + let prop = cur.type.prop(foldNodeProp); + if (prop && (cur.to < tree.length - 50 || tree.length == state.doc.length || !isUnfinished(cur))) { + let value = prop(cur, state); + if (value && value.from <= end && value.from >= start && value.to > end) + found = value; + } + } + return found; +} +function isUnfinished(node) { + let ch = node.lastChild; + return ch && ch.to == node.to && ch.type.isError; +} +/** +Check whether the given line is foldable. First asks any fold +services registered through +[`foldService`](https://codemirror.net/6/docs/ref/#language.foldService), and if none of them return +a result, tries to query the [fold node +prop](https://codemirror.net/6/docs/ref/#language.foldNodeProp) of syntax nodes that cover the end +of the line. +*/ +function foldable(state, lineStart, lineEnd) { + for (let service of state.facet(foldService)) { + let result = service(state, lineStart, lineEnd); + if (result) + return result; + } + return syntaxFolding(state, lineStart, lineEnd); +} +function mapRange(range, mapping) { + let from = mapping.mapPos(range.from, 1), to = mapping.mapPos(range.to, -1); + return from >= to ? undefined : { from, to }; +} +/** +State effect that can be attached to a transaction to fold the +given range. (You probably only need this in exceptional +circumstances—usually you'll just want to let +[`foldCode`](https://codemirror.net/6/docs/ref/#language.foldCode) and the [fold +gutter](https://codemirror.net/6/docs/ref/#language.foldGutter) create the transactions.) +*/ +const foldEffect = state.StateEffect.define({ map: mapRange }); +/** +State effect that unfolds the given range (if it was folded). +*/ +const unfoldEffect = state.StateEffect.define({ map: mapRange }); +function selectedLines(view) { + let lines = []; + for (let { head } of view.state.selection.ranges) { + if (lines.some(l => l.from <= head && l.to >= head)) + continue; + lines.push(view.lineBlockAt(head)); + } + return lines; +} +/** +The state field that stores the folded ranges (as a [decoration +set](https://codemirror.net/6/docs/ref/#view.DecorationSet)). Can be passed to +[`EditorState.toJSON`](https://codemirror.net/6/docs/ref/#state.EditorState.toJSON) and +[`fromJSON`](https://codemirror.net/6/docs/ref/#state.EditorState^fromJSON) to serialize the fold +state. +*/ +const foldState = state.StateField.define({ + create() { + return view.Decoration.none; + }, + update(folded, tr) { + if (tr.isUserEvent("delete")) + tr.changes.iterChangedRanges((fromA, toA) => folded = clearTouchedFolds(folded, fromA, toA)); + folded = folded.map(tr.changes); + let rangesToFold = []; + for (let e of tr.effects) { + if (e.is(foldEffect) && !foldExists(folded, e.value.from, e.value.to)) { + rangesToFold.push(e.value); + } + else if (e.is(unfoldEffect)) { + folded = folded.update({ filter: (from, to) => e.value.from != from || e.value.to != to, + filterFrom: e.value.from, filterTo: e.value.to }); + } + } + if (rangesToFold.length) { + let { preparePlaceholder } = tr.state.facet(foldConfig); + let decorations = rangesToFold.map(value => { + let widget = !preparePlaceholder ? foldWidget : + view.Decoration.replace({ widget: new PreparedFoldWidget(preparePlaceholder(tr.state, value)) }); + return widget.range(value.from, value.to); + }); + folded = folded.update({ add: decorations }); + } + // Clear folded ranges that cover the selection head + if (tr.selection) + folded = clearTouchedFolds(folded, tr.selection.main.head); + return folded; + }, + provide: f => view.EditorView.decorations.from(f), + toJSON(folded, state) { + let ranges = []; + folded.between(0, state.doc.length, (from, to) => { ranges.push(from, to); }); + return ranges; + }, + fromJSON(value) { + if (!Array.isArray(value) || value.length % 2) + throw new RangeError("Invalid JSON for fold state"); + let ranges = []; + for (let i = 0; i < value.length;) { + let from = value[i++], to = value[i++]; + if (typeof from != "number" || typeof to != "number") + throw new RangeError("Invalid JSON for fold state"); + ranges.push(foldWidget.range(from, to)); + } + return view.Decoration.set(ranges, true); + } +}); +function clearTouchedFolds(folded, from, to = from) { + let touched = false; + folded.between(from, to, (a, b) => { if (a < to && b > from) + touched = true; }); + return !touched ? folded : folded.update({ + filterFrom: from, + filterTo: to, + filter: (a, b) => a >= to || b <= from + }); +} +/** +Get a [range set](https://codemirror.net/6/docs/ref/#state.RangeSet) containing the folded ranges +in the given state. +*/ +function foldedRanges(state$1) { + return state$1.field(foldState, false) || state.RangeSet.empty; +} +function findFold(state, from, to) { + var _a; + let found = null; + (_a = state.field(foldState, false)) === null || _a === void 0 ? void 0 : _a.between(from, to, (from, to) => { + if (!found || found.from > from) + found = { from, to }; + }); + return found; +} +function foldExists(folded, from, to) { + let found = false; + folded.between(from, from, (a, b) => { if (a == from && b == to) + found = true; }); + return found; +} +function maybeEnable(state$1, other) { + return state$1.field(foldState, false) ? other : other.concat(state.StateEffect.appendConfig.of(codeFolding())); +} +/** +Fold the lines that are selected, if possible. +*/ +const foldCode = view => { + for (let line of selectedLines(view)) { + let range = foldable(view.state, line.from, line.to); + if (range) { + view.dispatch({ effects: maybeEnable(view.state, [foldEffect.of(range), announceFold(view, range)]) }); + return true; + } + } + return false; +}; +/** +Unfold folded ranges on selected lines. +*/ +const unfoldCode = view => { + if (!view.state.field(foldState, false)) + return false; + let effects = []; + for (let line of selectedLines(view)) { + let folded = findFold(view.state, line.from, line.to); + if (folded) + effects.push(unfoldEffect.of(folded), announceFold(view, folded, false)); + } + if (effects.length) + view.dispatch({ effects }); + return effects.length > 0; +}; +function announceFold(view$1, range, fold = true) { + let lineFrom = view$1.state.doc.lineAt(range.from).number, lineTo = view$1.state.doc.lineAt(range.to).number; + return view.EditorView.announce.of(`${view$1.state.phrase(fold ? "Folded lines" : "Unfolded lines")} ${lineFrom} ${view$1.state.phrase("to")} ${lineTo}.`); +} +/** +Fold all top-level foldable ranges. Note that, in most cases, +folding information will depend on the [syntax +tree](https://codemirror.net/6/docs/ref/#language.syntaxTree), and folding everything may not work +reliably when the document hasn't been fully parsed (either +because the editor state was only just initialized, or because the +document is so big that the parser decided not to parse it +entirely). +*/ +const foldAll = view => { + let { state } = view, effects = []; + for (let pos = 0; pos < state.doc.length;) { + let line = view.lineBlockAt(pos), range = foldable(state, line.from, line.to); + if (range) + effects.push(foldEffect.of(range)); + pos = (range ? view.lineBlockAt(range.to) : line).to + 1; + } + if (effects.length) + view.dispatch({ effects: maybeEnable(view.state, effects) }); + return !!effects.length; +}; +/** +Unfold all folded code. +*/ +const unfoldAll = view => { + let field = view.state.field(foldState, false); + if (!field || !field.size) + return false; + let effects = []; + field.between(0, view.state.doc.length, (from, to) => { effects.push(unfoldEffect.of({ from, to })); }); + view.dispatch({ effects }); + return true; +}; +// Find the foldable region containing the given line, if one exists +function foldableContainer(view, lineBlock) { + // Look backwards through line blocks until we find a foldable region that + // intersects with the line + for (let line = lineBlock;;) { + let foldableRegion = foldable(view.state, line.from, line.to); + if (foldableRegion && foldableRegion.to > lineBlock.from) + return foldableRegion; + if (!line.from) + return null; + line = view.lineBlockAt(line.from - 1); + } +} +/** +Toggle folding at cursors. Unfolds if there is an existing fold +starting in that line, tries to find a foldable range around it +otherwise. +*/ +const toggleFold = (view) => { + let effects = []; + for (let line of selectedLines(view)) { + let folded = findFold(view.state, line.from, line.to); + if (folded) { + effects.push(unfoldEffect.of(folded), announceFold(view, folded, false)); + } + else { + let foldRange = foldableContainer(view, line); + if (foldRange) + effects.push(foldEffect.of(foldRange), announceFold(view, foldRange)); + } + } + if (effects.length > 0) + view.dispatch({ effects: maybeEnable(view.state, effects) }); + return !!effects.length; +}; +/** +Default fold-related key bindings. + + - Ctrl-Shift-[ (Cmd-Alt-[ on macOS): [`foldCode`](https://codemirror.net/6/docs/ref/#language.foldCode). + - Ctrl-Shift-] (Cmd-Alt-] on macOS): [`unfoldCode`](https://codemirror.net/6/docs/ref/#language.unfoldCode). + - Ctrl-Alt-[: [`foldAll`](https://codemirror.net/6/docs/ref/#language.foldAll). + - Ctrl-Alt-]: [`unfoldAll`](https://codemirror.net/6/docs/ref/#language.unfoldAll). +*/ +const foldKeymap = [ + { key: "Ctrl-Shift-[", mac: "Cmd-Alt-[", run: foldCode }, + { key: "Ctrl-Shift-]", mac: "Cmd-Alt-]", run: unfoldCode }, + { key: "Ctrl-Alt-[", run: foldAll }, + { key: "Ctrl-Alt-]", run: unfoldAll } +]; +const defaultConfig = { + placeholderDOM: null, + preparePlaceholder: null, + placeholderText: "…" +}; +const foldConfig = state.Facet.define({ + combine(values) { return state.combineConfig(values, defaultConfig); } +}); +/** +Create an extension that configures code folding. +*/ +function codeFolding(config) { + let result = [foldState, baseTheme$1]; + if (config) + result.push(foldConfig.of(config)); + return result; +} +function widgetToDOM(view, prepared) { + let { state } = view, conf = state.facet(foldConfig); + let onclick = (event) => { + let line = view.lineBlockAt(view.posAtDOM(event.target)); + let folded = findFold(view.state, line.from, line.to); + if (folded) + view.dispatch({ effects: unfoldEffect.of(folded) }); + event.preventDefault(); + }; + if (conf.placeholderDOM) + return conf.placeholderDOM(view, onclick, prepared); + let element = document.createElement("span"); + element.textContent = conf.placeholderText; + element.setAttribute("aria-label", state.phrase("folded code")); + element.title = state.phrase("unfold"); + element.className = "cm-foldPlaceholder"; + element.onclick = onclick; + return element; +} +const foldWidget = view.Decoration.replace({ widget: new class extends view.WidgetType { + toDOM(view) { return widgetToDOM(view, null); } + } }); +class PreparedFoldWidget extends view.WidgetType { + constructor(value) { + super(); + this.value = value; + } + eq(other) { return this.value == other.value; } + toDOM(view) { return widgetToDOM(view, this.value); } +} +const foldGutterDefaults = { + openText: "⌄", + closedText: "›", + markerDOM: null, + domEventHandlers: {}, + foldingChanged: () => false +}; +class FoldMarker extends view.GutterMarker { + constructor(config, open) { + super(); + this.config = config; + this.open = open; + } + eq(other) { return this.config == other.config && this.open == other.open; } + toDOM(view) { + if (this.config.markerDOM) + return this.config.markerDOM(this.open); + let span = document.createElement("span"); + span.textContent = this.open ? this.config.openText : this.config.closedText; + span.title = view.state.phrase(this.open ? "Fold line" : "Unfold line"); + return span; + } +} +/** +Create an extension that registers a fold gutter, which shows a +fold status indicator before foldable lines (which can be clicked +to fold or unfold the line). +*/ +function foldGutter(config = {}) { + let fullConfig = Object.assign(Object.assign({}, foldGutterDefaults), config); + let canFold = new FoldMarker(fullConfig, true), canUnfold = new FoldMarker(fullConfig, false); + let markers = view.ViewPlugin.fromClass(class { + constructor(view) { + this.from = view.viewport.from; + this.markers = this.buildMarkers(view); + } + update(update) { + if (update.docChanged || update.viewportChanged || + update.startState.facet(language) != update.state.facet(language) || + update.startState.field(foldState, false) != update.state.field(foldState, false) || + syntaxTree(update.startState) != syntaxTree(update.state) || + fullConfig.foldingChanged(update)) + this.markers = this.buildMarkers(update.view); + } + buildMarkers(view) { + let builder = new state.RangeSetBuilder(); + for (let line of view.viewportLineBlocks) { + let mark = findFold(view.state, line.from, line.to) ? canUnfold + : foldable(view.state, line.from, line.to) ? canFold : null; + if (mark) + builder.add(line.from, line.from, mark); + } + return builder.finish(); + } + }); + let { domEventHandlers } = fullConfig; + return [ + markers, + view.gutter({ + class: "cm-foldGutter", + markers(view) { var _a; return ((_a = view.plugin(markers)) === null || _a === void 0 ? void 0 : _a.markers) || state.RangeSet.empty; }, + initialSpacer() { + return new FoldMarker(fullConfig, false); + }, + domEventHandlers: Object.assign(Object.assign({}, domEventHandlers), { click: (view, line, event) => { + if (domEventHandlers.click && domEventHandlers.click(view, line, event)) + return true; + let folded = findFold(view.state, line.from, line.to); + if (folded) { + view.dispatch({ effects: unfoldEffect.of(folded) }); + return true; + } + let range = foldable(view.state, line.from, line.to); + if (range) { + view.dispatch({ effects: foldEffect.of(range) }); + return true; + } + return false; + } }) + }), + codeFolding() + ]; +} +const baseTheme$1 = view.EditorView.baseTheme({ + ".cm-foldPlaceholder": { + backgroundColor: "#eee", + border: "1px solid #ddd", + color: "#888", + borderRadius: ".2em", + margin: "0 1px", + padding: "0 1px", + cursor: "pointer" + }, + ".cm-foldGutter span": { + padding: "0 1px", + cursor: "pointer" + } +}); + +/** +A highlight style associates CSS styles with highlighting +[tags](https://lezer.codemirror.net/docs/ref#highlight.Tag). +*/ +class HighlightStyle { + constructor( + /** + The tag styles used to create this highlight style. + */ + specs, options) { + this.specs = specs; + let modSpec; + function def(spec) { + let cls = styleMod.StyleModule.newName(); + (modSpec || (modSpec = Object.create(null)))["." + cls] = spec; + return cls; + } + const all = typeof options.all == "string" ? options.all : options.all ? def(options.all) : undefined; + const scopeOpt = options.scope; + this.scope = scopeOpt instanceof Language ? (type) => type.prop(languageDataProp) == scopeOpt.data + : scopeOpt ? (type) => type == scopeOpt : undefined; + this.style = highlight.tagHighlighter(specs.map(style => ({ + tag: style.tag, + class: style.class || def(Object.assign({}, style, { tag: null })) + })), { + all, + }).style; + this.module = modSpec ? new styleMod.StyleModule(modSpec) : null; + this.themeType = options.themeType; + } + /** + Create a highlighter style that associates the given styles to + the given tags. The specs must be objects that hold a style tag + or array of tags in their `tag` property, and either a single + `class` property providing a static CSS class (for highlighter + that rely on external styling), or a + [`style-mod`](https://code.haverbeke.berlin/marijn/style-mod#documentation)-style + set of CSS properties (which define the styling for those tags). + + The CSS rules created for a highlighter will be emitted in the + order of the spec's properties. That means that for elements that + have multiple tags associated with them, styles defined further + down in the list will have a higher CSS precedence than styles + defined earlier. + */ + static define(specs, options) { + return new HighlightStyle(specs, options || {}); + } +} +const highlighterFacet = state.Facet.define(); +const fallbackHighlighter = state.Facet.define({ + combine(values) { return values.length ? [values[0]] : null; } +}); +function getHighlighters(state) { + let main = state.facet(highlighterFacet); + return main.length ? main : state.facet(fallbackHighlighter); +} +/** +Wrap a highlighter in an editor extension that uses it to apply +syntax highlighting to the editor content. + +When multiple (non-fallback) styles are provided, the styling +applied is the union of the classes they emit. +*/ +function syntaxHighlighting(highlighter, options) { + let ext = [treeHighlighter], themeType; + if (highlighter instanceof HighlightStyle) { + if (highlighter.module) + ext.push(view.EditorView.styleModule.of(highlighter.module)); + themeType = highlighter.themeType; + } + if (options === null || options === void 0 ? void 0 : options.fallback) + ext.push(fallbackHighlighter.of(highlighter)); + else if (themeType) + ext.push(highlighterFacet.computeN([view.EditorView.darkTheme], state => { + return state.facet(view.EditorView.darkTheme) == (themeType == "dark") ? [highlighter] : []; + })); + else + ext.push(highlighterFacet.of(highlighter)); + return ext; +} +/** +Returns the CSS classes (if any) that the highlighters active in +the state would assign to the given style +[tags](https://lezer.codemirror.net/docs/ref#highlight.Tag) and +(optional) language +[scope](https://codemirror.net/6/docs/ref/#language.HighlightStyle^define^options.scope). +*/ +function highlightingFor(state, tags, scope) { + let highlighters = getHighlighters(state); + let result = null; + if (highlighters) + for (let highlighter of highlighters) { + if (!highlighter.scope || scope && highlighter.scope(scope)) { + let cls = highlighter.style(tags); + if (cls) + result = result ? result + " " + cls : cls; + } + } + return result; +} +class TreeHighlighter { + constructor(view) { + var _a; + this.view = view; + this.markCache = Object.create(null); + this.provisionalVersion = -1; + this.usedProvisional = false; + this.mappedFallback = false; + this.fallbackExpired = false; + this.fallbackGeneration = 0; + this.fallbackTimer = -1; + this.tree = syntaxTree(view.state); + this.decorations = this.buildDeco(view, getHighlighters(view.state)); + this.decoratedFrom = view.viewport.from; + this.decoratedTo = view.viewport.to; + let provisional = viewportPriorityTree(view); + this.provisionalVersion = (_a = provisional === null || provisional === void 0 ? void 0 : provisional.version) !== null && _a !== void 0 ? _a : -1; + this.usedProvisional = !!provisional; + this.publishControlledCoverage(view); + } + update(update) { + var _a, _b; + let tree = syntaxTree(update.state), highlighters = getHighlighters(update.state); + let styleChange = highlighters != getHighlighters(update.startState); + let provisional = viewportPriorityTree(update.view); + let provisionalFailed = viewportPriorityParsingFailed(update.view); + let provisionalVersion = (_a = provisional === null || provisional === void 0 ? void 0 : provisional.version) !== null && _a !== void 0 ? _a : -1; + let { viewport } = update.view; + let decoratedFromMapped = update.changes.mapPos(this.decoratedFrom, -1); + let decoratedToMapped = update.changes.mapPos(this.decoratedTo, 1); + let sameLanguage = update.startState.facet(language) == update.state.facet(language); + let retainMapped = retainMappedHighlighting(update.docChanged, this.usedProvisional, this.fallbackExpired, sameLanguage, styleChange, tree.length, viewport.from, viewport.to, decoratedFromMapped, decoratedToMapped); + if (retainMapped) { + this.tree = tree; + this.decorations = this.decorations.map(update.changes); + this.decoratedFrom = decoratedFromMapped; + this.decoratedTo = decoratedToMapped; + this.provisionalVersion = -1; + this.usedProvisional = this.mappedFallback = true; + this.scheduleFallbackExpiry(update.view); + } + else if (retainMappedHighlightingWhileParsing(this.mappedFallback, this.fallbackExpired, sameLanguage, styleChange, !!provisional, provisionalFailed, tree.length, viewport.from, viewport.to, decoratedFromMapped, decoratedToMapped)) { + // Canonical parsing may publish several incomplete trees while an edit's + // replacement provisional tree is still pending. Keep the mapped visual + // state through those updates instead of rebuilding a plain viewport. + this.tree = tree; + if (!update.changes.empty) + this.decorations = this.decorations.map(update.changes); + this.decoratedFrom = decoratedFromMapped; + this.decoratedTo = decoratedToMapped; + } + else if (retainHighlightingDuringControlledPreparation(!!((_b = update.view.viewState) === null || _b === void 0 ? void 0 : _b.controlledScrollActive), this.usedProvisional, sameLanguage, styleChange, !!provisional, provisionalFailed, tree.length, viewport.to)) { + // A rolling corridor first expands the DOM, then publishes its + // provisional parse. Retain decorations for the already safe range so + // preparing the next chunk cannot flash the current viewport plain. + this.tree = tree; + } + else if (!this.mappedFallback && tree.length < viewport.to && !styleChange && + provisionalVersion == this.provisionalVersion && tree.type == this.tree.type && + decoratedFromMapped <= viewport.from && decoratedToMapped >= viewport.to) { + this.decorations = this.decorations.map(update.changes); + this.decoratedFrom = decoratedFromMapped; + this.decoratedTo = decoratedToMapped; + } + else if (this.mappedFallback && this.fallbackExpired || + provisionalFailed || + tree != this.tree || update.viewportChanged || styleChange || + provisionalVersion != this.provisionalVersion) { + this.tree = tree; + this.decorations = this.buildDeco(update.view, highlighters); + this.decoratedFrom = viewport.from; + this.decoratedTo = viewport.to; + this.provisionalVersion = provisionalVersion; + this.usedProvisional = !!provisional; + this.clearFallback(); + } + this.publishControlledCoverage(update.view, styleChange || !sameLanguage || provisionalFailed); + } + scheduleFallbackExpiry(view) { + if (this.fallbackTimer > -1) + clearTimeout(this.fallbackTimer); + let generation = ++this.fallbackGeneration; + this.fallbackExpired = false; + this.fallbackTimer = setTimeout(() => { + this.fallbackTimer = -1; + if (generation != this.fallbackGeneration || !this.mappedFallback) + return; + if (viewportPriorityParsingPending(view)) { + this.scheduleFallbackExpiry(view); + return; + } + this.fallbackExpired = true; + view.dispatch({}); + }, 5000); + } + clearFallback() { + this.mappedFallback = this.fallbackExpired = false; + this.fallbackGeneration++; + if (this.fallbackTimer > -1) + clearTimeout(this.fallbackTimer); + this.fallbackTimer = -1; + } + publishControlledCoverage(view, invalidate = false) { + var _a, _b, _c; + let controller = view.controlledTouchScrollController; + if (!view.state.facet(language) || !getHighlighters(view.state)) { + (_a = controller === null || controller === void 0 ? void 0 : controller.onHighlightCoverageRemoved) === null || _a === void 0 ? void 0 : _a.call(controller); + return; + } + let { viewport } = view; + let coveredTo = Math.max(viewport.from, this.tree.length); + for (let tree of viewportPriorityTrees(view)) { + if (tree.from <= coveredTo) + coveredTo = Math.max(coveredTo, tree.to); + } + let complete = coveredTo >= viewport.to || + (this.mappedFallback && this.decoratedFrom <= viewport.from && this.decoratedTo >= viewport.to); + if (complete) + (_b = controller === null || controller === void 0 ? void 0 : controller.onHighlightCoverageReady) === null || _b === void 0 ? void 0 : _b.call(controller); + else if (invalidate) + (_c = controller === null || controller === void 0 ? void 0 : controller.onHighlightCoverageInvalidated) === null || _c === void 0 ? void 0 : _c.call(controller); + } + destroy() { + var _a, _b; + this.clearFallback(); + (_b = (_a = this.view.controlledTouchScrollController) === null || _a === void 0 ? void 0 : _a.onHighlightCoverageRemoved) === null || _b === void 0 ? void 0 : _b.call(_a); + } + buildDeco(view$1, highlighters) { + if (!highlighters) + return view.Decoration.none; + let builder = new state.RangeSetBuilder(); + let provisionalTrees = viewportPriorityTrees(view$1); + for (let { from, to } of view$1.visibleRanges) { + let canonicalTo = Math.min(to, this.tree.length); + if (from < canonicalTo) + this.addTreeHighlights(builder, this.tree, highlighters, from, canonicalTo); + for (let provisional of provisionalTrees) { + let priority = provisionalHighlightRange(from, to, this.tree.length, provisional.from, provisional.to); + if (priority) + this.addTreeHighlights(builder, provisional.tree, outerLanguageHighlighters(provisional, highlighters), priority.from - provisional.from, priority.to - provisional.from, provisional.from); + } + } + return builder.finish(); + } + addTreeHighlights(builder, tree, highlighters, from, to, offset = 0) { + highlight.highlightTree(tree, highlighters, (from, to, style) => { + builder.add(from + offset, to + offset, this.markCache[style] || (this.markCache[style] = view.Decoration.mark({ class: style }))); + }, from, to); + } +} +/** +@internal +*/ +function retainMappedHighlighting(docChanged, usedProvisional, fallbackExpired, sameLanguage, styleChanged, canonicalLength, viewportFrom, viewportTo, decoratedFrom, decoratedTo) { + return docChanged && usedProvisional && !fallbackExpired && sameLanguage && !styleChanged && + canonicalLength < viewportTo && decoratedFrom <= viewportFrom && decoratedTo >= viewportTo; +} +/** +@internal +*/ +function retainMappedHighlightingWhileParsing(mappedFallback, fallbackExpired, sameLanguage, styleChanged, hasPublishedProvisional, provisionalFailed, canonicalLength, viewportFrom, viewportTo, decoratedFrom, decoratedTo) { + return mappedFallback && !fallbackExpired && sameLanguage && !styleChanged && + !hasPublishedProvisional && !provisionalFailed && canonicalLength < viewportTo && + decoratedFrom <= viewportFrom && decoratedTo >= viewportTo; +} +/** +@internal +*/ +function retainHighlightingDuringControlledPreparation(controlledActive, usedProvisional, sameLanguage, styleChanged, hasPublishedProvisional, provisionalFailed, canonicalLength, viewportTo) { + return controlledActive && usedProvisional && sameLanguage && !styleChanged && + !hasPublishedProvisional && !provisionalFailed && canonicalLength < viewportTo; +} +/** +@internal +*/ +function provisionalHighlightRange(from, to, canonicalLength, provisionalFrom, provisionalTo) { + let priorityFrom = Math.max(from, Math.min(to, canonicalLength), provisionalFrom); + let priorityTo = Math.min(to, provisionalTo); + return priorityFrom < priorityTo ? { from: priorityFrom, to: priorityTo } : null; +} +/** +@internal +*/ +function outerLanguageHighlighters(provisional, highlighters) { + let top = provisional.tree.topNode.type; + return highlighters.map(highlighter => ({ + style: highlighter.style, + scope: (type) => type == top && (!highlighter.scope || highlighter.scope(type)) + })); +} +const treeHighlighter = state.Prec.high(view.ViewPlugin.fromClass(TreeHighlighter, { + decorations: v => v.decorations +})); +/** +A default highlight style (works well with light themes). +*/ +const defaultHighlightStyle = HighlightStyle.define([ + { tag: highlight.tags.meta, + color: "#404740" }, + { tag: highlight.tags.link, + textDecoration: "underline" }, + { tag: highlight.tags.heading, + textDecoration: "underline", + fontWeight: "bold" }, + { tag: highlight.tags.emphasis, + fontStyle: "italic" }, + { tag: highlight.tags.strong, + fontWeight: "bold" }, + { tag: highlight.tags.strikethrough, + textDecoration: "line-through" }, + { tag: highlight.tags.keyword, + color: "#708" }, + { tag: [highlight.tags.atom, highlight.tags.bool, highlight.tags.url, highlight.tags.contentSeparator, highlight.tags.labelName], + color: "#219" }, + { tag: [highlight.tags.literal, highlight.tags.inserted], + color: "#164" }, + { tag: [highlight.tags.string, highlight.tags.deleted], + color: "#a11" }, + { tag: [highlight.tags.regexp, highlight.tags.escape, highlight.tags.special(highlight.tags.string)], + color: "#e40" }, + { tag: highlight.tags.definition(highlight.tags.variableName), + color: "#00f" }, + { tag: highlight.tags.local(highlight.tags.variableName), + color: "#30a" }, + { tag: [highlight.tags.typeName, highlight.tags.namespace], + color: "#085" }, + { tag: highlight.tags.className, + color: "#167" }, + { tag: [highlight.tags.special(highlight.tags.variableName), highlight.tags.macroName], + color: "#256" }, + { tag: highlight.tags.definition(highlight.tags.propertyName), + color: "#00c" }, + { tag: highlight.tags.comment, + color: "#940" }, + { tag: highlight.tags.invalid, + color: "#f00" } +]); + +const baseTheme = view.EditorView.baseTheme({ + "&.cm-focused .cm-matchingBracket": { backgroundColor: "#328c8252" }, + "&.cm-focused .cm-nonmatchingBracket": { backgroundColor: "#bb555544" } +}); +const DefaultScanDist = 10000, DefaultBrackets = "()[]{}"; +const bracketMatchingConfig = state.Facet.define({ + combine(configs) { + return state.combineConfig(configs, { + afterCursor: true, + brackets: DefaultBrackets, + maxScanDistance: DefaultScanDist, + renderMatch: defaultRenderMatch + }); + } +}); +const matchingMark = view.Decoration.mark({ class: "cm-matchingBracket" }), nonmatchingMark = view.Decoration.mark({ class: "cm-nonmatchingBracket" }); +function defaultRenderMatch(match) { + let decorations = []; + let mark = match.matched ? matchingMark : nonmatchingMark; + decorations.push(mark.range(match.start.from, match.start.to)); + if (match.end) + decorations.push(mark.range(match.end.from, match.end.to)); + return decorations; +} +function bracketDeco(state) { + let decorations = []; + let config = state.facet(bracketMatchingConfig); + for (let range of state.selection.ranges) { + if (!range.empty) + continue; + let match = matchBrackets(state, range.head, -1, config) + || (range.head > 0 && matchBrackets(state, range.head - 1, 1, config)) + || (config.afterCursor && + (matchBrackets(state, range.head, 1, config) || + (range.head < state.doc.length && matchBrackets(state, range.head + 1, -1, config)))); + if (match) + decorations = decorations.concat(config.renderMatch(match, state)); + } + return view.Decoration.set(decorations, true); +} +const bracketMatcher = view.ViewPlugin.fromClass(class { + constructor(view) { + this.paused = false; + this.decorations = bracketDeco(view.state); + } + update(update) { + if (update.docChanged || update.selectionSet || this.paused) { + if (update.view.composing) { + this.decorations = this.decorations.map(update.changes); + this.paused = true; + } + else { + this.decorations = bracketDeco(update.state); + this.paused = false; + } + } + } +}, { + decorations: v => v.decorations +}); +const bracketMatchingUnique = [ + bracketMatcher, + baseTheme +]; +/** +Create an extension that enables bracket matching. Whenever the +cursor is next to a bracket, that bracket and the one it matches +are highlighted. Or, when no matching bracket is found, another +highlighting style is used to indicate this. +*/ +function bracketMatching(config = {}) { + return [bracketMatchingConfig.of(config), bracketMatchingUnique]; +} +/** +When larger syntax nodes, such as HTML tags, are marked as +opening/closing, it can be a bit messy to treat the whole node as +a matchable bracket. This node prop allows you to define, for such +a node, a ‘handle’—the part of the node that is highlighted, and +that the cursor must be on to activate highlighting in the first +place. +*/ +const bracketMatchingHandle = new common.NodeProp(); +function matchingNodes(node, dir, brackets) { + let byProp = node.prop(dir < 0 ? common.NodeProp.openedBy : common.NodeProp.closedBy); + if (byProp) + return byProp; + if (node.name.length == 1) { + let index = brackets.indexOf(node.name); + if (index > -1 && index % 2 == (dir < 0 ? 1 : 0)) + return [brackets[index + dir]]; + } + return null; +} +function findHandle(node) { + let hasHandle = node.type.prop(bracketMatchingHandle); + return hasHandle ? hasHandle(node.node) : node; +} +/** +Find the matching bracket for the token at `pos`, scanning +direction `dir`. Only the `brackets` and `maxScanDistance` +properties are used from `config`, if given. Returns null if no +bracket was found at `pos`, or a match result otherwise. +*/ +function matchBrackets(state, pos, dir, config = {}) { + let maxScanDistance = config.maxScanDistance || DefaultScanDist, brackets = config.brackets || DefaultBrackets; + let tree = syntaxTree(state), node = tree.resolveInner(pos, dir); + for (let cur = node; cur; cur = cur.parent) { + let matches = matchingNodes(cur.type, dir, brackets); + if (matches && cur.from < cur.to) { + let handle = findHandle(cur); + if (handle && (dir > 0 ? pos >= handle.from && pos < handle.to : pos > handle.from && pos <= handle.to)) + return matchMarkedBrackets(state, pos, dir, cur, handle, matches, brackets); + } + } + return matchPlainBrackets(state, pos, dir, tree, node.type, maxScanDistance, brackets); +} +function matchMarkedBrackets(_state, _pos, dir, token, handle, matching, brackets) { + let parent = token.parent, firstToken = { from: handle.from, to: handle.to }; + let depth = 0, cursor = parent === null || parent === void 0 ? void 0 : parent.cursor(); + if (cursor && (dir < 0 ? cursor.childBefore(token.from) : cursor.childAfter(token.to))) + do { + if (dir < 0 ? cursor.to <= token.from : cursor.from >= token.to) { + if (depth == 0 && matching.indexOf(cursor.type.name) > -1 && cursor.from < cursor.to) { + let endHandle = findHandle(cursor); + return { start: firstToken, end: endHandle ? { from: endHandle.from, to: endHandle.to } : undefined, matched: true }; + } + else if (matchingNodes(cursor.type, dir, brackets)) { + depth++; + } + else if (matchingNodes(cursor.type, -dir, brackets)) { + if (depth == 0) { + let endHandle = findHandle(cursor); + return { + start: firstToken, + end: endHandle && endHandle.from < endHandle.to ? { from: endHandle.from, to: endHandle.to } : undefined, + matched: false + }; + } + depth--; + } + } + } while (dir < 0 ? cursor.prevSibling() : cursor.nextSibling()); + return { start: firstToken, matched: false }; +} +function matchPlainBrackets(state, pos, dir, tree, tokenType, maxScanDistance, brackets) { + if (dir < 0 ? !pos : pos == state.doc.length) + return null; + let startCh = dir < 0 ? state.sliceDoc(pos - 1, pos) : state.sliceDoc(pos, pos + 1); + let bracket = brackets.indexOf(startCh); + if (bracket < 0 || (bracket % 2 == 0) != (dir > 0)) + return null; + let startToken = { from: dir < 0 ? pos - 1 : pos, to: dir > 0 ? pos + 1 : pos }; + let iter = state.doc.iterRange(pos, dir > 0 ? state.doc.length : 0), depth = 0; + for (let distance = 0; !(iter.next()).done && distance <= maxScanDistance;) { + let text = iter.value; + if (dir < 0) + distance += text.length; + let basePos = pos + distance * dir; + for (let pos = dir > 0 ? 0 : text.length - 1, end = dir > 0 ? text.length : -1; pos != end; pos += dir) { + let found = brackets.indexOf(text[pos]); + if (found < 0 || tree.resolveInner(basePos + pos, 1).type != tokenType) + continue; + if ((found % 2 == 0) == (dir > 0)) { + depth++; + } + else if (depth == 1) { // Closing + return { start: startToken, end: { from: basePos + pos, to: basePos + pos + 1 }, matched: (found >> 1) == (bracket >> 1) }; + } + else { + depth--; + } + } + if (dir > 0) + distance += text.length; + } + return iter.done ? { start: startToken, matched: false } : null; +} + +// Counts the column offset in a string, taking tabs into account. +// Used mostly to find indentation. +function countCol(string, end, tabSize, startIndex = 0, startValue = 0) { + if (end == null) { + end = string.search(/[^\s\u00a0]/); + if (end == -1) + end = string.length; + } + let n = startValue; + for (let i = startIndex; i < end; i++) { + if (string.charCodeAt(i) == 9) + n += tabSize - (n % tabSize); + else + n++; + } + return n; +} +/** +Encapsulates a single line of input. Given to stream syntax code, +which uses it to tokenize the content. +*/ +class StringStream { + /** + Create a stream. + */ + constructor( + /** + The line. + */ + string, tabSize, + /** + The current indent unit size. + */ + indentUnit, overrideIndent) { + this.string = string; + this.tabSize = tabSize; + this.indentUnit = indentUnit; + this.overrideIndent = overrideIndent; + /** + The current position on the line. + */ + this.pos = 0; + /** + The start position of the current token. + */ + this.start = 0; + this.lastColumnPos = 0; + this.lastColumnValue = 0; + } + /** + True if we are at the end of the line. + */ + eol() { return this.pos >= this.string.length; } + /** + True if we are at the start of the line. + */ + sol() { return this.pos == 0; } + /** + Get the next code unit after the current position, or undefined + if we're at the end of the line. + */ + peek() { return this.string.charAt(this.pos) || undefined; } + /** + Read the next code unit and advance `this.pos`. + */ + next() { + if (this.pos < this.string.length) + return this.string.charAt(this.pos++); + } + /** + Match the next character against the given string, regular + expression, or predicate. Consume and return it if it matches. + */ + eat(match) { + let ch = this.string.charAt(this.pos); + let ok; + if (typeof match == "string") + ok = ch == match; + else + ok = ch && (match instanceof RegExp ? match.test(ch) : match(ch)); + if (ok) { + ++this.pos; + return ch; + } + } + /** + Continue matching characters that match the given string, + regular expression, or predicate function. Return true if any + characters were consumed. + */ + eatWhile(match) { + let start = this.pos; + while (this.eat(match)) { } + return this.pos > start; + } + /** + Consume whitespace ahead of `this.pos`. Return true if any was + found. + */ + eatSpace() { + let start = this.pos; + while (/[\s\u00a0]/.test(this.string.charAt(this.pos))) + ++this.pos; + return this.pos > start; + } + /** + Move to the end of the line. + */ + skipToEnd() { this.pos = this.string.length; } + /** + Move to directly before the given character, if found on the + current line. + */ + skipTo(ch) { + let found = this.string.indexOf(ch, this.pos); + if (found > -1) { + this.pos = found; + return true; + } + } + /** + Move back `n` characters. + */ + backUp(n) { this.pos -= n; } + /** + Get the column position at `this.pos`. + */ + column() { + if (this.lastColumnPos < this.start) { + this.lastColumnValue = countCol(this.string, this.start, this.tabSize, this.lastColumnPos, this.lastColumnValue); + this.lastColumnPos = this.start; + } + return this.lastColumnValue; + } + /** + Get the indentation column of the current line. + */ + indentation() { + var _a; + return (_a = this.overrideIndent) !== null && _a !== void 0 ? _a : countCol(this.string, null, this.tabSize); + } + /** + Match the input against the given string or regular expression + (which should start with a `^`). Return true or the regexp match + if it matches. + + Unless `consume` is set to `false`, this will move `this.pos` + past the matched text. + + When matching a string `caseInsensitive` can be set to true to + make the match case-insensitive. + */ + match(pattern, consume, caseInsensitive) { + if (typeof pattern == "string") { + let cased = (str) => caseInsensitive ? str.toLowerCase() : str; + let substr = this.string.substr(this.pos, pattern.length); + if (cased(substr) == cased(pattern)) { + if (consume !== false) + this.pos += pattern.length; + return true; + } + else + return null; + } + else { + let match = this.string.slice(this.pos).match(pattern); + if (match && match.index > 0) + return null; + if (match && consume !== false) + this.pos += match[0].length; + return match; + } + } + /** + Get the current token. + */ + current() { return this.string.slice(this.start, this.pos); } +} + +function fullParser(spec) { + return { + name: spec.name || "", + token: spec.token, + blankLine: spec.blankLine || (() => { }), + startState: spec.startState || (() => true), + copyState: spec.copyState || defaultCopyState, + indent: spec.indent || (() => null), + languageData: spec.languageData || {}, + tokenTable: spec.tokenTable || noTokens, + mergeTokens: spec.mergeTokens !== false + }; +} +function defaultCopyState(state) { + if (typeof state != "object") + return state; + let newState = {}; + for (let prop in state) { + let val = state[prop]; + newState[prop] = (val instanceof Array ? val.slice() : val); + } + return newState; +} +const IndentedFrom = new WeakMap(); +/** +A [language](https://codemirror.net/6/docs/ref/#language.Language) class based on a CodeMirror +5-style [streaming parser](https://codemirror.net/6/docs/ref/#language.StreamParser). +*/ +class StreamLanguage extends Language { + constructor(parser) { + let data = defineLanguageFacet(parser.languageData); + let p = fullParser(parser), self; + let impl = new class extends common.Parser { + createParse(input, fragments, ranges) { + return new Parse(self, input, fragments, ranges); + } + }; + super(data, impl, [], parser.name); + this.topNode = docID(data, this); + self = this; + this.streamParser = p; + this.stateAfter = new common.NodeProp({ perNode: true }); + this.tokenTable = parser.tokenTable ? new TokenTable(p.tokenTable) : defaultTokenTable; + } + /** + Define a stream language. + */ + static define(spec) { return new StreamLanguage(spec); } + /** + @internal + */ + getIndent(cx) { + let from = undefined; + let { overrideIndentation } = cx.options; + if (overrideIndentation) { + from = IndentedFrom.get(cx.state); + if (from != null && from < cx.pos - 1e4) + from = undefined; + } + let start = findState(this, cx.node.tree, cx.node.from, cx.node.from, from !== null && from !== void 0 ? from : cx.pos), statePos, state; + if (start) { + state = start.state; + statePos = start.pos + 1; + } + else { + state = this.streamParser.startState(cx.unit); + statePos = cx.node.from; + } + if (cx.pos - statePos > 10000 /* C.MaxIndentScanDist */) + return null; + while (statePos < cx.pos) { + let line = cx.state.doc.lineAt(statePos), end = Math.min(cx.pos, line.to); + if (line.length) { + let indentation = overrideIndentation ? overrideIndentation(line.from) : -1; + let stream = new StringStream(line.text, cx.state.tabSize, cx.unit, indentation < 0 ? undefined : indentation); + while (stream.pos < end - line.from) + readToken(this.streamParser.token, stream, state); + } + else { + this.streamParser.blankLine(state, cx.unit); + } + if (end == cx.pos) + break; + statePos = line.to + 1; + } + let line = cx.lineAt(cx.pos); + if (overrideIndentation && from == null) + IndentedFrom.set(cx.state, line.from); + return this.streamParser.indent(state, /^\s*(.*)/.exec(line.text)[1], cx); + } + get allowsNesting() { return false; } +} +function findState(lang, tree, off, startPos, before) { + let state = off >= startPos && off + tree.length <= before && tree.prop(lang.stateAfter); + if (state) + return { state: lang.streamParser.copyState(state), pos: off + tree.length }; + for (let i = tree.children.length - 1; i >= 0; i--) { + let child = tree.children[i], pos = off + tree.positions[i]; + let found = child instanceof common.Tree && pos < before && findState(lang, child, pos, startPos, before); + if (found) + return found; + } + return null; +} +function cutTree(lang, tree, from, to, inside) { + if (inside && from <= 0 && to >= tree.length) + return tree; + if (!inside && from == 0 && tree.type == lang.topNode) + inside = true; + for (let i = tree.children.length - 1; i >= 0; i--) { + let pos = tree.positions[i], child = tree.children[i], inner; + if (pos < to && child instanceof common.Tree) { + if (!(inner = cutTree(lang, child, from - pos, to - pos, inside))) + break; + return !inside ? inner + : new common.Tree(tree.type, tree.children.slice(0, i).concat(inner), tree.positions.slice(0, i + 1), pos + inner.length); + } + } + return null; +} +function findStartInFragments(lang, fragments, startPos, endPos, editorState) { + for (let f of fragments) { + let from = f.from + (f.openStart ? 25 : 0), to = f.to - (f.openEnd ? 25 : 0); + let found = from <= startPos && to > startPos && findState(lang, f.tree, 0 - f.offset, startPos, to), tree; + if (found && found.pos <= endPos && (tree = cutTree(lang, f.tree, startPos + f.offset, found.pos + f.offset, false))) + return { state: found.state, tree }; + } + return { state: lang.streamParser.startState(editorState ? getIndentUnit(editorState) : 4), tree: common.Tree.empty }; +} +class Parse { + constructor(lang, input, fragments, ranges) { + this.lang = lang; + this.input = input; + this.fragments = fragments; + this.ranges = ranges; + this.stoppedAt = null; + this.chunks = []; + this.chunkPos = []; + this.chunk = []; + this.chunkReused = undefined; + this.rangeIndex = 0; + this.to = ranges[ranges.length - 1].to; + let context = ParseContext.get(), from = ranges[0].from; + let { state, tree } = findStartInFragments(lang, fragments, from, this.to, context === null || context === void 0 ? void 0 : context.state); + this.state = state; + this.parsedPos = this.chunkStart = from + tree.length; + for (let i = 0; i < tree.children.length; i++) { + this.chunks.push(tree.children[i]); + this.chunkPos.push(tree.positions[i]); + } + if (context && this.parsedPos < context.viewport.from - 100000 /* C.MaxDistanceBeforeViewport */ && + ranges.some(r => r.from <= context.viewport.from && r.to >= context.viewport.from)) { + this.state = this.lang.streamParser.startState(getIndentUnit(context.state)); + context.skipUntilInView(this.parsedPos, context.viewport.from); + this.parsedPos = context.viewport.from; + } + this.moveRangeIndex(); + } + advance() { + let context = ParseContext.get(); + let parseEnd = this.stoppedAt == null ? this.to : Math.min(this.to, this.stoppedAt); + let end = Math.min(parseEnd, this.chunkStart + 512 /* C.ChunkSize */); + if (context) + end = Math.min(end, context.viewport.to); + while (this.parsedPos < end) + this.parseLine(context); + if (this.chunkStart < this.parsedPos) + this.finishChunk(); + if (this.parsedPos >= parseEnd) + return this.finish(); + if (context && this.parsedPos >= context.viewport.to) { + context.skipUntilInView(this.parsedPos, parseEnd); + return this.finish(); + } + return null; + } + stopAt(pos) { + this.stoppedAt = pos; + } + lineAfter(pos) { + let chunk = this.input.chunk(pos); + if (!this.input.lineChunks) { + let eol = chunk.indexOf("\n"); + if (eol > -1) + chunk = chunk.slice(0, eol); + } + else if (chunk == "\n") { + chunk = ""; + } + return pos + chunk.length <= this.to ? chunk : chunk.slice(0, this.to - pos); + } + nextLine() { + let from = this.parsedPos, line = this.lineAfter(from), end = from + line.length; + for (let index = this.rangeIndex;;) { + let rangeEnd = this.ranges[index].to; + if (rangeEnd >= end) + break; + line = line.slice(0, rangeEnd - (end - line.length)); + index++; + if (index == this.ranges.length) + break; + let rangeStart = this.ranges[index].from; + let after = this.lineAfter(rangeStart); + line += after; + end = rangeStart + after.length; + } + return { line, end }; + } + skipGapsTo(pos, offset, side) { + for (;;) { + let end = this.ranges[this.rangeIndex].to, offPos = pos + offset; + if (side > 0 ? end > offPos : end >= offPos) + break; + let start = this.ranges[++this.rangeIndex].from; + offset += start - end; + } + return offset; + } + moveRangeIndex() { + while (this.ranges[this.rangeIndex].to < this.parsedPos) + this.rangeIndex++; + } + emitToken(id, from, to, offset) { + let size = 4; + if (this.ranges.length > 1) { + offset = this.skipGapsTo(from, offset, 1); + from += offset; + let len0 = this.chunk.length; + offset = this.skipGapsTo(to, offset, -1); + to += offset; + size += this.chunk.length - len0; + } + let last = this.chunk.length - 4; + if (this.lang.streamParser.mergeTokens && size == 4 && last >= 0 && + this.chunk[last] == id && this.chunk[last + 2] == from) + this.chunk[last + 2] = to; + else + this.chunk.push(id, from, to, size); + return offset; + } + parseLine(context) { + let { line, end } = this.nextLine(), offset = 0, { streamParser } = this.lang; + let stream = new StringStream(line, context ? context.state.tabSize : 4, context ? getIndentUnit(context.state) : 2); + if (stream.eol()) { + streamParser.blankLine(this.state, stream.indentUnit); + } + else { + while (!stream.eol()) { + let token = readToken(streamParser.token, stream, this.state); + if (token) + offset = this.emitToken(this.lang.tokenTable.resolve(token), this.parsedPos + stream.start, this.parsedPos + stream.pos, offset); + if (stream.start > 10000 /* C.MaxLineLength */) + break; + } + } + this.parsedPos = end; + this.moveRangeIndex(); + if (this.parsedPos < this.to) + this.parsedPos++; + } + finishChunk() { + let tree = common.Tree.build({ + buffer: this.chunk, + start: this.chunkStart, + length: this.parsedPos - this.chunkStart, + nodeSet, + topID: 0, + maxBufferLength: 512 /* C.ChunkSize */, + reused: this.chunkReused + }); + tree = new common.Tree(tree.type, tree.children, tree.positions, tree.length, [[this.lang.stateAfter, this.lang.streamParser.copyState(this.state)]]); + this.chunks.push(tree); + this.chunkPos.push(this.chunkStart - this.ranges[0].from); + this.chunk = []; + this.chunkReused = undefined; + this.chunkStart = this.parsedPos; + } + finish() { + return new common.Tree(this.lang.topNode, this.chunks, this.chunkPos, this.parsedPos - this.ranges[0].from).balance(); + } +} +function readToken(token, stream, state) { + stream.start = stream.pos; + for (let i = 0; i < 10; i++) { + let result = token(stream, state); + if (stream.pos > stream.start) + return result; + } + throw new Error("Stream parser failed to advance stream."); +} +const noTokens = Object.create(null); +const typeArray = [common.NodeType.none]; +const nodeSet = new common.NodeSet(typeArray); +const warned = []; +// Cache of node types by name and tags +const byTag = Object.create(null); +const defaultTable = Object.create(null); +for (let [legacyName, name] of [ + ["variable", "variableName"], + ["variable-2", "variableName.special"], + ["string-2", "string.special"], + ["def", "variableName.definition"], + ["tag", "tagName"], + ["attribute", "attributeName"], + ["type", "typeName"], + ["builtin", "variableName.standard"], + ["qualifier", "modifier"], + ["error", "invalid"], + ["header", "heading"], + ["property", "propertyName"] +]) + defaultTable[legacyName] = createTokenType(noTokens, name); +class TokenTable { + constructor(extra) { + this.extra = extra; + this.table = Object.assign(Object.create(null), defaultTable); + } + resolve(tag) { + return !tag ? 0 : this.table[tag] || (this.table[tag] = createTokenType(this.extra, tag)); + } +} +const defaultTokenTable = new TokenTable(noTokens); +function warnForPart(part, msg) { + if (warned.indexOf(part) > -1) + return; + warned.push(part); + console.warn(msg); +} +function createTokenType(extra, tagStr) { + let tags = []; + for (let name of tagStr.split(" ")) { + let found = []; + for (let part of name.split(".")) { + let value = (extra[part] || highlight.tags[part]); + if (!value) { + warnForPart(part, `Unknown highlighting tag ${part}`); + } + else if (typeof value == "function") { + if (!found.length) + warnForPart(part, `Modifier ${part} used at start of tag`); + else + found = found.map(value); + } + else { + if (found.length) + warnForPart(part, `Tag ${part} used as modifier`); + else + found = Array.isArray(value) ? value : [value]; + } + } + for (let tag of found) + tags.push(tag); + } + if (!tags.length) + return 0; + let name = tagStr.replace(/ /g, "_"), key = name + " " + tags.map(t => t.id); + let known = byTag[key]; + if (known) + return known.id; + let type = byTag[key] = common.NodeType.define({ + id: typeArray.length, + name, + props: [highlight.styleTags({ [name]: tags })] + }); + typeArray.push(type); + return type.id; +} +function docID(data, lang) { + let type = common.NodeType.define({ id: typeArray.length, name: "Document", props: [ + languageDataProp.add(() => data), + indentNodeProp.add(() => cx => lang.getIndent(cx)) + ], top: true }); + typeArray.push(type); + return type; +} + +function buildForLine(line) { + return line.length <= 4096 && /[\u0590-\u05f4\u0600-\u06ff\u0700-\u08ac\ufb50-\ufdff]/.test(line); +} +function textHasRTL(text) { + for (let i = text.iter(); !i.next().done;) + if (buildForLine(i.value)) + return true; + return false; +} +function changeAddsRTL(change) { + let added = false; + change.iterChanges((fA, tA, fB, tB, ins) => { + if (!added && textHasRTL(ins)) + added = true; + }); + return added; +} +const alwaysIsolate = state.Facet.define({ combine: values => values.some(x => x) }); +/** +Make sure nodes +[marked](https://lezer.codemirror.net/docs/ref/#common.NodeProp^isolate) +as isolating for bidirectional text are rendered in a way that +isolates them from the surrounding text. +*/ +function bidiIsolates(options = {}) { + let extensions = [isolateMarks]; + if (options.alwaysIsolate) + extensions.push(alwaysIsolate.of(true)); + return extensions; +} +const isolateMarks = view.ViewPlugin.fromClass(class { + constructor(view$1) { + this.always = view$1.state.facet(alwaysIsolate) || + view$1.textDirection != view.Direction.LTR || + view$1.state.facet(view.EditorView.perLineTextDirection); + this.hasRTL = !this.always && textHasRTL(view$1.state.doc); + this.tree = syntaxTree(view$1.state); + this.decorations = this.always || this.hasRTL ? buildDeco(view$1, this.tree, this.always) : view.Decoration.none; + } + update(update) { + let always = update.state.facet(alwaysIsolate) || + update.view.textDirection != view.Direction.LTR || + update.state.facet(view.EditorView.perLineTextDirection); + if (!always && !this.hasRTL && changeAddsRTL(update.changes)) + this.hasRTL = true; + if (!always && !this.hasRTL) + return; + let tree = syntaxTree(update.state); + if (always != this.always || tree != this.tree || update.docChanged || update.viewportChanged) { + this.tree = tree; + this.always = always; + this.decorations = buildDeco(update.view, tree, always); + } + } +}, { + provide: plugin => { + function access(view$1) { + var _a, _b; + return (_b = (_a = view$1.plugin(plugin)) === null || _a === void 0 ? void 0 : _a.decorations) !== null && _b !== void 0 ? _b : view.Decoration.none; + } + return [view.EditorView.outerDecorations.of(access), + state.Prec.lowest(view.EditorView.bidiIsolatedRanges.of(access))]; + } +}); +function buildDeco(view, tree, always) { + let deco = new state.RangeSetBuilder(); + let ranges = view.visibleRanges; + if (!always) + ranges = clipRTLLines(ranges, view.state.doc); + for (let { from, to } of ranges) { + tree.iterate({ + enter: node => { + let iso = node.type.prop(common.NodeProp.isolate); + if (iso) + deco.add(node.from, node.to, marks[iso]); + }, + from, to + }); + } + return deco.finish(); +} +function clipRTLLines(ranges, doc) { + let cur = doc.iter(), pos = 0, result = [], last = null; + for (let { from, to } of ranges) { + if (last && last.to > from) { + from = last.to; + if (from >= to) + continue; + } + if (pos + cur.value.length < from) { + cur.next(from - (pos + cur.value.length)); + pos = from; + } + for (;;) { + let start = pos, end = pos + cur.value.length; + if (!cur.lineBreak && buildForLine(cur.value)) { + if (last && last.to > start - 10) + last.to = Math.min(to, end); + else + result.push(last = { from: start, to: Math.min(to, end) }); + } + if (end >= to) + break; + pos = end; + cur.next(); + } + } + return result; +} +const marks = { + rtl: view.Decoration.mark({ class: "cm-iso", inclusive: true, attributes: { dir: "rtl" }, bidiIsolate: view.Direction.RTL }), + ltr: view.Decoration.mark({ class: "cm-iso", inclusive: true, attributes: { dir: "ltr" }, bidiIsolate: view.Direction.LTR }), + auto: view.Decoration.mark({ class: "cm-iso", inclusive: true, attributes: { dir: "auto" }, bidiIsolate: null }) +}; + +exports.DocInput = DocInput; +exports.HighlightStyle = HighlightStyle; +exports.IndentContext = IndentContext; +exports.LRLanguage = LRLanguage; +exports.Language = Language; +exports.LanguageDescription = LanguageDescription; +exports.LanguageSupport = LanguageSupport; +exports.ParseContext = ParseContext; +exports.StreamLanguage = StreamLanguage; +exports.StringStream = StringStream; +exports.TreeIndentContext = TreeIndentContext; +exports.bidiIsolates = bidiIsolates; +exports.bracketMatching = bracketMatching; +exports.bracketMatchingHandle = bracketMatchingHandle; +exports.codeFolding = codeFolding; +exports.continuedIndent = continuedIndent; +exports.defaultHighlightStyle = defaultHighlightStyle; +exports.defineLanguageFacet = defineLanguageFacet; +exports.delimitedIndent = delimitedIndent; +exports.ensureSyntaxTree = ensureSyntaxTree; +exports.flatIndent = flatIndent; +exports.foldAll = foldAll; +exports.foldCode = foldCode; +exports.foldEffect = foldEffect; +exports.foldGutter = foldGutter; +exports.foldInside = foldInside; +exports.foldKeymap = foldKeymap; +exports.foldNodeProp = foldNodeProp; +exports.foldService = foldService; +exports.foldState = foldState; +exports.foldable = foldable; +exports.foldedRanges = foldedRanges; +exports.forceParsing = forceParsing; +exports.getIndentUnit = getIndentUnit; +exports.getIndentation = getIndentation; +exports.highlightingFor = highlightingFor; +exports.indentNodeProp = indentNodeProp; +exports.indentOnInput = indentOnInput; +exports.indentRange = indentRange; +exports.indentService = indentService; +exports.indentString = indentString; +exports.indentUnit = indentUnit; +exports.language = language; +exports.languageDataProp = languageDataProp; +exports.matchBrackets = matchBrackets; +exports.sublanguageProp = sublanguageProp; +exports.syntaxHighlighting = syntaxHighlighting; +exports.syntaxParserRunning = syntaxParserRunning; +exports.syntaxTree = syntaxTree; +exports.syntaxTreeAvailable = syntaxTreeAvailable; +exports.toggleFold = toggleFold; +exports.unfoldAll = unfoldAll; +exports.unfoldCode = unfoldCode; +exports.unfoldEffect = unfoldEffect; +exports.viewportPriorityParsing = viewportPriorityParsing; diff --git a/vendor/codemirror-language/dist/index.d.cts b/vendor/codemirror-language/dist/index.d.cts new file mode 100644 index 0000000000..1ebda40037 --- /dev/null +++ b/vendor/codemirror-language/dist/index.d.cts @@ -0,0 +1,1242 @@ +import { Input, Parser, TreeFragment, SyntaxNode, Tree, NodeProp, NodeType } from '@lezer/common'; +import { LRParser, ParserConfig } from '@lezer/lr'; +import * as _codemirror_state from '@codemirror/state'; +import { Text, Facet, Extension, EditorState, StateField, Range } from '@codemirror/state'; +import { EditorView, Command, BlockInfo, ViewUpdate, KeyBinding, DecorationSet, Decoration } from '@codemirror/view'; +import { Highlighter, Tag } from '@lezer/highlight'; +import { StyleModule, StyleSpec } from 'style-mod'; + +/** +Node prop stored in a parser's top syntax node to provide the +facet that stores language-specific data for that language. +*/ +declare const languageDataProp: NodeProp>; +/** +Helper function to define a facet (to be added to the top syntax +node(s) for a language via +[`languageDataProp`](https://codemirror.net/6/docs/ref/#language.languageDataProp)), that will be +used to associate language data with the language. You +probably only need this when subclassing +[`Language`](https://codemirror.net/6/docs/ref/#language.Language). +*/ +declare function defineLanguageFacet(baseData?: { + [name: string]: any; +}): Facet<{ + [name: string]: any; +}, readonly { + [name: string]: any; +}[]>; +/** +Some languages need to return different [language +data](https://codemirror.net/6/docs/ref/#state.EditorState.languageDataAt) for some parts of their +tree. Sublanguages, registered by adding a [node +prop](https://codemirror.net/6/docs/ref/#language.sublanguageProp) to the language's top syntax +node, provide a mechanism to do this. + +(Note that when using nested parsing, where nested syntax is +parsed by a different parser and has its own top node type, you +don't need a sublanguage.) +*/ +interface Sublanguage { + /** + Determines whether the data provided by this sublanguage should + completely replace the regular data or be added to it (with + higher-precedence). The default is `"extend"`. + */ + type?: "replace" | "extend"; + /** + A predicate that returns whether the node at the queried + position is part of the sublanguage. + */ + test: (node: SyntaxNode, state: EditorState) => boolean; + /** + The language data facet that holds the sublanguage's data. + You'll want to use + [`defineLanguageFacet`](https://codemirror.net/6/docs/ref/#language.defineLanguageFacet) to create + this. + */ + facet: Facet<{ + [name: string]: any; + }>; +} +/** +Syntax node prop used to register sublanguages. Should be added to +the top level node type for the language. +*/ +declare const sublanguageProp: NodeProp; +/** +A language object manages parsing and per-language +[metadata](https://codemirror.net/6/docs/ref/#state.EditorState.languageDataAt). Parse data is +managed as a [Lezer](https://lezer.codemirror.net) tree. The class +can be used directly, via the [`LRLanguage`](https://codemirror.net/6/docs/ref/#language.LRLanguage) +subclass for [Lezer](https://lezer.codemirror.net/) LR parsers, or +via the [`StreamLanguage`](https://codemirror.net/6/docs/ref/#language.StreamLanguage) subclass +for stream parsers. +*/ +declare class Language { + /** + The [language data](https://codemirror.net/6/docs/ref/#state.EditorState.languageDataAt) facet + used for this language. + */ + readonly data: Facet<{ + [name: string]: any; + }>; + /** + A language name. + */ + readonly name: string; + /** + The extension value to install this as the document language. + */ + readonly extension: Extension; + /** + The parser object. Can be useful when using this as a [nested + parser](https://lezer.codemirror.net/docs/ref#common.Parser). + */ + parser: Parser; + /** + Construct a language object. If you need to invoke this + directly, first define a data facet with + [`defineLanguageFacet`](https://codemirror.net/6/docs/ref/#language.defineLanguageFacet), and then + configure your parser to [attach](https://codemirror.net/6/docs/ref/#language.languageDataProp) it + to the language's outer syntax node. + */ + constructor( + /** + The [language data](https://codemirror.net/6/docs/ref/#state.EditorState.languageDataAt) facet + used for this language. + */ + data: Facet<{ + [name: string]: any; + }>, parser: Parser, extraExtensions?: Extension[], + /** + A language name. + */ + name?: string); + /** + Query whether this language is active at the given position. + */ + isActiveAt(state: EditorState, pos: number, side?: -1 | 0 | 1): boolean; + /** + Find the document regions that were parsed using this language. + The returned regions will _include_ any nested languages rooted + in this language, when those exist. + */ + findRegions(state: EditorState): { + from: number; + to: number; + }[]; + /** + Indicates whether this language allows nested languages. The + default implementation returns true. + */ + get allowsNesting(): boolean; +} +/** +A subclass of [`Language`](https://codemirror.net/6/docs/ref/#language.Language) for use with Lezer +[LR parsers](https://lezer.codemirror.net/docs/ref#lr.LRParser). +*/ +declare class LRLanguage extends Language { + readonly parser: LRParser; + private constructor(); + /** + Define a language from a parser. + */ + static define(spec: { + /** + The [name](https://codemirror.net/6/docs/ref/#Language.name) of the language. + */ + name?: string; + /** + The parser to use. Should already have added editor-relevant + node props (and optionally things like dialect and top rule) + configured. + */ + parser: LRParser; + /** + [Language data](https://codemirror.net/6/docs/ref/#state.EditorState.languageDataAt) + to register for this language. + */ + languageData?: { + [name: string]: any; + }; + }): LRLanguage; + /** + Create a new instance of this language with a reconfigured + version of its parser and optionally a new name. + */ + configure(options: ParserConfig, name?: string): LRLanguage; + get allowsNesting(): boolean; +} +/** +Get the syntax tree for a state, which is the current (possibly +incomplete) parse tree of the active +[language](https://codemirror.net/6/docs/ref/#language.Language), or the empty tree if there is no +language available. +*/ +declare function syntaxTree(state: EditorState): Tree; +/** +Try to get a parse tree that spans at least up to `upto`. The +method will do at most `timeout` milliseconds of work to parse +up to that point if the tree isn't already available. +*/ +declare function ensureSyntaxTree(state: EditorState, upto: number, timeout?: number): Tree | null; +/** +Queries whether there is a full syntax tree available up to the +given document position. If there isn't, the background parse +process _might_ still be working and update the tree further, but +there is no guarantee of that—the parser will [stop +working](https://codemirror.net/6/docs/ref/#language.syntaxParserRunning) when it has spent a +certain amount of time or has moved beyond the visible viewport. +Always returns false if no language has been enabled. +*/ +declare function syntaxTreeAvailable(state: EditorState, upto?: number): boolean; +/** +Move parsing forward, and update the editor state afterwards to +reflect the new tree. Will work for at most `timeout` +milliseconds. Returns true if the parser managed get to the given +position in that time. +*/ +declare function forceParsing(view: EditorView, upto?: number, timeout?: number): boolean; +/** +Tells you whether the language parser is planning to do more +parsing work (in a `requestIdleCallback` pseudo-thread) or has +stopped running, either because it parsed the entire document, +because it spent too much time and was cut off, or because there +is no language parser enabled. +*/ +declare function syntaxParserRunning(view: EditorView): boolean; +/** +Configuration for viewport-priority provisional parsing. +*/ +interface ViewportPriorityParsingConfig { + /** + Cooperative budget for initial, edit, background, and provisional parse + slices. Individual parser advances and tree finalization cannot be + preempted. Provisional work uses at most 2 ms per slice during gestures. + */ + sliceMs?: number; + /** + Provisional trees only apply highlighting from the outer language. + */ + approximate: "outer-language"; +} +/** +Enable temporary viewport-priority highlighting while the canonical parser +is still working through an earlier part of a large document. The +provisional tree is never exposed through `syntaxTree` or other language +services. +*/ +declare function viewportPriorityParsing(config: ViewportPriorityParsingConfig): Extension; +/** +Lezer-style +[`Input`](https://lezer.codemirror.net/docs/ref#common.Input) +object for a [`Text`](https://codemirror.net/6/docs/ref/#state.Text) object. +*/ +declare class DocInput implements Input { + readonly doc: Text; + private cursor; + private cursorPos; + private string; + /** + Create an input object for the given document. + */ + constructor(doc: Text); + get length(): number; + private syncTo; + chunk(pos: number): string; + get lineChunks(): boolean; + read(from: number, to: number): string; +} +/** +A parse context provided to parsers working on the editor content. +*/ +declare class ParseContext { + private parser; + /** + The current editor state. + */ + readonly state: EditorState; + /** + Tree fragments that can be reused by incremental re-parses. + */ + fragments: readonly TreeFragment[]; + /** + The current editor viewport (or some overapproximation + thereof). Intended to be used for opportunistically avoiding + work (in which case + [`skipUntilInView`](https://codemirror.net/6/docs/ref/#language.ParseContext.skipUntilInView) + should be called to make sure the parser is restarted when the + skipped region becomes visible). + */ + viewport: { + from: number; + to: number; + }; + private parse; + private constructor(); + private startParse; + private withContext; + private withoutTempSkipped; + /** + Notify the parse scheduler that the given region was skipped + because it wasn't in view, and the parse should be restarted + when it comes into view. + */ + skipUntilInView(from: number, to: number): void; + /** + Returns a parser intended to be used as placeholder when + asynchronously loading a nested parser. It'll skip its input and + mark it as not-really-parsed, so that the next update will parse + it again. + + When `until` is given, a reparse will be scheduled when that + promise resolves. + */ + static getSkippingParser(until?: Promise): Parser; + /** + Get the context for the current parse, or `null` if no editor + parse is in progress. + */ + static get(): ParseContext | null; +} +/** +The facet used to associate a language with an editor state. Used +by `Language` object's `extension` property (so you don't need to +manually wrap your languages in this). Can be used to access the +current language on a state. +*/ +declare const language: Facet; +/** +This class bundles a [language](https://codemirror.net/6/docs/ref/#language.Language) with an +optional set of supporting extensions. Language packages are +encouraged to export a function that optionally takes a +configuration object and returns a `LanguageSupport` instance, as +the main way for client code to use the package. +*/ +declare class LanguageSupport { + /** + The language object. + */ + readonly language: Language; + /** + An optional set of supporting extensions. When nesting a + language in another language, the outer language is encouraged + to include the supporting extensions for its inner languages + in its own set of support extensions. + */ + readonly support: Extension; + /** + An extension including both the language and its support + extensions. (Allowing the object to be used as an extension + value itself.) + */ + extension: Extension; + /** + Create a language support object. + */ + constructor( + /** + The language object. + */ + language: Language, + /** + An optional set of supporting extensions. When nesting a + language in another language, the outer language is encouraged + to include the supporting extensions for its inner languages + in its own set of support extensions. + */ + support?: Extension); +} +/** +Language descriptions are used to store metadata about languages +and to dynamically load them. Their main role is finding the +appropriate language for a filename or dynamically loading nested +parsers. +*/ +declare class LanguageDescription { + /** + The name of this language. + */ + readonly name: string; + /** + Alternative names for the mode (lowercased, includes `this.name`). + */ + readonly alias: readonly string[]; + /** + File extensions associated with this language. + */ + readonly extensions: readonly string[]; + /** + Optional filename pattern that should be associated with this + language. + */ + readonly filename: RegExp | undefined; + private loadFunc; + /** + If the language has been loaded, this will hold its value. + */ + support: LanguageSupport | undefined; + private loading; + private constructor(); + /** + Start loading the the language. Will return a promise that + resolves to a [`LanguageSupport`](https://codemirror.net/6/docs/ref/#language.LanguageSupport) + object when the language successfully loads. + */ + load(): Promise; + /** + Create a language description. + */ + static of(spec: { + /** + The language's name. + */ + name: string; + /** + An optional array of alternative names. + */ + alias?: readonly string[]; + /** + An optional array of filename extensions associated with this + language. + */ + extensions?: readonly string[]; + /** + An optional filename pattern associated with this language. + */ + filename?: RegExp; + /** + A function that will asynchronously load the language. + */ + load?: () => Promise; + /** + Alternatively to `load`, you can provide an already loaded + support object. Either this or `load` should be provided. + */ + support?: LanguageSupport; + }): LanguageDescription; + /** + Look for a language in the given array of descriptions that + matches the filename. Will first match + [`filename`](https://codemirror.net/6/docs/ref/#language.LanguageDescription.filename) patterns, + and then [extensions](https://codemirror.net/6/docs/ref/#language.LanguageDescription.extensions), + and return the first language that matches. + */ + static matchFilename(descs: readonly LanguageDescription[], filename: string): LanguageDescription | null; + /** + Look for a language whose name or alias matches the the given + name (case-insensitively). If `fuzzy` is true, and no direct + matchs is found, this'll also search for a language whose name + or alias occurs in the string (for names shorter than three + characters, only when surrounded by non-word characters). + */ + static matchLanguageName(descs: readonly LanguageDescription[], name: string, fuzzy?: boolean): LanguageDescription | null; +} + +/** +Facet that defines a way to provide a function that computes the +appropriate indentation depth, as a column number (see +[`indentString`](https://codemirror.net/6/docs/ref/#language.indentString)), at the start of a given +line. A return value of `null` indicates no indentation can be +determined, and the line should inherit the indentation of the one +above it. A return value of `undefined` defers to the next indent +service. +*/ +declare const indentService: Facet<(context: IndentContext, pos: number) => number | null | undefined, readonly ((context: IndentContext, pos: number) => number | null | undefined)[]>; +/** +Facet for overriding the unit by which indentation happens. Should +be a string consisting entirely of the same whitespace character. +When not set, this defaults to 2 spaces. +*/ +declare const indentUnit: Facet; +/** +Return the _column width_ of an indent unit in the state. +Determined by the [`indentUnit`](https://codemirror.net/6/docs/ref/#language.indentUnit) +facet, and [`tabSize`](https://codemirror.net/6/docs/ref/#state.EditorState^tabSize) when that +contains tabs. +*/ +declare function getIndentUnit(state: EditorState): number; +/** +Create an indentation string that covers columns 0 to `cols`. +Will use tabs for as much of the columns as possible when the +[`indentUnit`](https://codemirror.net/6/docs/ref/#language.indentUnit) facet contains +tabs. +*/ +declare function indentString(state: EditorState, cols: number): string; +/** +Get the indentation, as a column number, at the given position. +Will first consult any [indent services](https://codemirror.net/6/docs/ref/#language.indentService) +that are registered, and if none of those return an indentation, +this will check the syntax tree for the [indent node +prop](https://codemirror.net/6/docs/ref/#language.indentNodeProp) and use that if found. Returns a +number when an indentation could be determined, and null +otherwise. +*/ +declare function getIndentation(context: IndentContext | EditorState, pos: number): number | null; +/** +Create a change set that auto-indents all lines touched by the +given document range. +*/ +declare function indentRange(state: EditorState, from: number, to: number): _codemirror_state.ChangeSet; +/** +Indentation contexts are used when calling [indentation +services](https://codemirror.net/6/docs/ref/#language.indentService). They provide helper utilities +useful in indentation logic, and can selectively override the +indentation reported for some lines. +*/ +declare class IndentContext { + /** + The editor state. + */ + readonly state: EditorState; + /** + The indent unit (number of columns per indentation level). + */ + unit: number; + /** + Create an indent context. + */ + constructor( + /** + The editor state. + */ + state: EditorState, + /** + @internal + */ + options?: { + /** + Override line indentations provided to the indentation + helper function, which is useful when implementing region + indentation, where indentation for later lines needs to refer + to previous lines, which may have been reindented compared to + the original start state. If given, this function should + return -1 for lines (given by start position) that didn't + change, and an updated indentation otherwise. + */ + overrideIndentation?: (pos: number) => number; + /** + Make it look, to the indent logic, like a line break was + added at the given position (which is mostly just useful for + implementing something like + [`insertNewlineAndIndent`](https://codemirror.net/6/docs/ref/#commands.insertNewlineAndIndent)). + */ + simulateBreak?: number; + /** + When `simulateBreak` is given, this can be used to make the + simulated break behave like a double line break. + */ + simulateDoubleBreak?: boolean; + }); + /** + Get a description of the line at the given position, taking + [simulated line + breaks](https://codemirror.net/6/docs/ref/#language.IndentContext.constructor^options.simulateBreak) + into account. If there is such a break at `pos`, the `bias` + argument determines whether the part of the line line before or + after the break is used. + */ + lineAt(pos: number, bias?: -1 | 1): { + text: string; + from: number; + }; + /** + Get the text directly after `pos`, either the entire line + or the next 100 characters, whichever is shorter. + */ + textAfterPos(pos: number, bias?: -1 | 1): string; + /** + Find the column for the given position. + */ + column(pos: number, bias?: -1 | 1): number; + /** + Find the column position (taking tabs into account) of the given + position in the given string. + */ + countColumn(line: string, pos?: number): number; + /** + Find the indentation column of the line at the given point. + */ + lineIndent(pos: number, bias?: -1 | 1): number; + /** + Returns the [simulated line + break](https://codemirror.net/6/docs/ref/#language.IndentContext.constructor^options.simulateBreak) + for this context, if any. + */ + get simulatedBreak(): number | null; +} +/** +A syntax tree node prop used to associate indentation strategies +with node types. Such a strategy is a function from an indentation +context to a column number (see also +[`indentString`](https://codemirror.net/6/docs/ref/#language.indentString)) or null, where null +indicates that no definitive indentation can be determined. +*/ +declare const indentNodeProp: NodeProp<(context: TreeIndentContext) => number | null>; +/** +Objects of this type provide context information and helper +methods to indentation functions registered on syntax nodes. +*/ +declare class TreeIndentContext extends IndentContext { + private base; + /** + The position at which indentation is being computed. + */ + readonly pos: number; + private constructor(); + /** + The syntax tree node to which the indentation strategy + applies. + */ + get node(): SyntaxNode; + /** + Get the text directly after `this.pos`, either the entire line + or the next 100 characters, whichever is shorter. + */ + get textAfter(): string; + /** + Get the indentation at the reference line for `this.node`, which + is the line on which it starts, unless there is a node that is + _not_ a parent of this node covering the start of that line. If + so, the line at the start of that node is tried, again skipping + on if it is covered by another such node. + */ + get baseIndent(): number; + /** + Get the indentation for the reference line of the given node + (see [`baseIndent`](https://codemirror.net/6/docs/ref/#language.TreeIndentContext.baseIndent)). + */ + baseIndentFor(node: SyntaxNode): number; + /** + Continue looking for indentations in the node's parent nodes, + and return the result of that. + */ + continue(): number | null; +} +/** +An indentation strategy for delimited (usually bracketed) nodes. +Will, by default, indent one unit more than the parent's base +indent unless the line starts with a closing token. When `align` +is true and there are non-skipped nodes on the node's opening +line, the content of the node will be aligned with the end of the +opening node, like this: + + foo(bar, + baz) +*/ +declare function delimitedIndent({ closing, align, units }: { + closing: string; + align?: boolean; + units?: number; +}): (context: TreeIndentContext) => number; +/** +An indentation strategy that aligns a node's content to its base +indentation. +*/ +declare const flatIndent: (context: TreeIndentContext) => number; +/** +Creates an indentation strategy that, by default, indents +continued lines one unit more than the node's base indentation. +You can provide `except` to prevent indentation of lines that +match a pattern (for example `/^else\b/` in `if`/`else` +constructs), and you can change the amount of units used with the +`units` option. +*/ +declare function continuedIndent({ except, units }?: { + except?: RegExp; + units?: number; +}): (context: TreeIndentContext) => number; +/** +Enables reindentation on input. When a language defines an +`indentOnInput` field in its [language +data](https://codemirror.net/6/docs/ref/#state.EditorState.languageDataAt), which must hold a regular +expression, the line at the cursor will be reindented whenever new +text is typed and the input from the start of the line up to the +cursor matches that regexp. + +To avoid unneccesary reindents, it is recommended to start the +regexp with `^` (usually followed by `\s*`), and end it with `$`. +For example, `/^\s*\}$/` will reindent when a closing brace is +added at the start of a line. +*/ +declare function indentOnInput(): Extension; + +/** +A facet that registers a code folding service. When called with +the extent of a line, such a function should return a foldable +range that starts on that line (but continues beyond it), if one +can be found. +*/ +declare const foldService: Facet<(state: EditorState, lineStart: number, lineEnd: number) => ({ + from: number; + to: number; +} | null), readonly ((state: EditorState, lineStart: number, lineEnd: number) => ({ + from: number; + to: number; +} | null))[]>; +/** +This node prop is used to associate folding information with +syntax node types. Given a syntax node, it should check whether +that tree is foldable and return the range that can be collapsed +when it is. +*/ +declare const foldNodeProp: NodeProp<(node: SyntaxNode, state: EditorState) => ({ + from: number; + to: number; +} | null)>; +/** +[Fold](https://codemirror.net/6/docs/ref/#language.foldNodeProp) function that folds everything but +the first and the last child of a syntax node. Useful for nodes +that start and end with delimiters. +*/ +declare function foldInside(node: SyntaxNode): { + from: number; + to: number; +} | null; +/** +Check whether the given line is foldable. First asks any fold +services registered through +[`foldService`](https://codemirror.net/6/docs/ref/#language.foldService), and if none of them return +a result, tries to query the [fold node +prop](https://codemirror.net/6/docs/ref/#language.foldNodeProp) of syntax nodes that cover the end +of the line. +*/ +declare function foldable(state: EditorState, lineStart: number, lineEnd: number): { + from: number; + to: number; +} | null; +type DocRange = { + from: number; + to: number; +}; +/** +State effect that can be attached to a transaction to fold the +given range. (You probably only need this in exceptional +circumstances—usually you'll just want to let +[`foldCode`](https://codemirror.net/6/docs/ref/#language.foldCode) and the [fold +gutter](https://codemirror.net/6/docs/ref/#language.foldGutter) create the transactions.) +*/ +declare const foldEffect: _codemirror_state.StateEffectType; +/** +State effect that unfolds the given range (if it was folded). +*/ +declare const unfoldEffect: _codemirror_state.StateEffectType; +/** +The state field that stores the folded ranges (as a [decoration +set](https://codemirror.net/6/docs/ref/#view.DecorationSet)). Can be passed to +[`EditorState.toJSON`](https://codemirror.net/6/docs/ref/#state.EditorState.toJSON) and +[`fromJSON`](https://codemirror.net/6/docs/ref/#state.EditorState^fromJSON) to serialize the fold +state. +*/ +declare const foldState: StateField; +/** +Get a [range set](https://codemirror.net/6/docs/ref/#state.RangeSet) containing the folded ranges +in the given state. +*/ +declare function foldedRanges(state: EditorState): DecorationSet; +/** +Fold the lines that are selected, if possible. +*/ +declare const foldCode: Command; +/** +Unfold folded ranges on selected lines. +*/ +declare const unfoldCode: Command; +/** +Fold all top-level foldable ranges. Note that, in most cases, +folding information will depend on the [syntax +tree](https://codemirror.net/6/docs/ref/#language.syntaxTree), and folding everything may not work +reliably when the document hasn't been fully parsed (either +because the editor state was only just initialized, or because the +document is so big that the parser decided not to parse it +entirely). +*/ +declare const foldAll: Command; +/** +Unfold all folded code. +*/ +declare const unfoldAll: Command; +/** +Toggle folding at cursors. Unfolds if there is an existing fold +starting in that line, tries to find a foldable range around it +otherwise. +*/ +declare const toggleFold: Command; +/** +Default fold-related key bindings. + + - Ctrl-Shift-[ (Cmd-Alt-[ on macOS): [`foldCode`](https://codemirror.net/6/docs/ref/#language.foldCode). + - Ctrl-Shift-] (Cmd-Alt-] on macOS): [`unfoldCode`](https://codemirror.net/6/docs/ref/#language.unfoldCode). + - Ctrl-Alt-[: [`foldAll`](https://codemirror.net/6/docs/ref/#language.foldAll). + - Ctrl-Alt-]: [`unfoldAll`](https://codemirror.net/6/docs/ref/#language.unfoldAll). +*/ +declare const foldKeymap: readonly KeyBinding[]; +interface FoldConfig { + /** + A function that creates the DOM element used to indicate the + position of folded code. The `onclick` argument is the default + click event handler, which toggles folding on the line that + holds the element, and should probably be added as an event + handler to the returned element. If + [`preparePlaceholder`](https://codemirror.net/6/docs/ref/#language.FoldConfig.preparePlaceholder) + is given, its result will be passed as 3rd argument. Otherwise, + this will be null. + + When this option isn't given, the `placeholderText` option will + be used to create the placeholder element. + */ + placeholderDOM?: ((view: EditorView, onclick: (event: Event) => void, prepared: any) => HTMLElement) | null; + /** + Text to use as placeholder for folded text. Defaults to `"…"`. + Will be styled with the `"cm-foldPlaceholder"` class. + */ + placeholderText?: string; + /** + Given a range that is being folded, create a value that + describes it, to be used by `placeholderDOM` to render a custom + widget that, for example, indicates something about the folded + range's size or type. + */ + preparePlaceholder?: (state: EditorState, range: { + from: number; + to: number; + }) => any; +} +/** +Create an extension that configures code folding. +*/ +declare function codeFolding(config?: FoldConfig): Extension; +type Handlers = { + [event: string]: (view: EditorView, line: BlockInfo, event: Event) => boolean; +}; +interface FoldGutterConfig { + /** + A function that creates the DOM element used to indicate a + given line is folded or can be folded. + When not given, the `openText`/`closeText` option will be used instead. + */ + markerDOM?: ((open: boolean) => HTMLElement) | null; + /** + Text used to indicate that a given line can be folded. + Defaults to `"⌄"`. + */ + openText?: string; + /** + Text used to indicate that a given line is folded. + Defaults to `"›"`. + */ + closedText?: string; + /** + Supply event handlers for DOM events on this gutter. + */ + domEventHandlers?: Handlers; + /** + When given, if this returns true for a given view update, + recompute the fold markers. + */ + foldingChanged?: (update: ViewUpdate) => boolean; +} +/** +Create an extension that registers a fold gutter, which shows a +fold status indicator before foldable lines (which can be clicked +to fold or unfold the line). +*/ +declare function foldGutter(config?: FoldGutterConfig): Extension; + +/** +A highlight style associates CSS styles with highlighting +[tags](https://lezer.codemirror.net/docs/ref#highlight.Tag). +*/ +declare class HighlightStyle implements Highlighter { + /** + The tag styles used to create this highlight style. + */ + readonly specs: readonly TagStyle[]; + /** + A style module holding the CSS rules for this highlight style. + When using + [`highlightTree`](https://lezer.codemirror.net/docs/ref#highlight.highlightTree) + outside of the editor, you may want to manually mount this + module to show the highlighting. + */ + readonly module: StyleModule | null; + readonly style: (tags: readonly Tag[]) => string | null; + readonly scope?: (type: NodeType) => boolean; + private constructor(); + /** + Create a highlighter style that associates the given styles to + the given tags. The specs must be objects that hold a style tag + or array of tags in their `tag` property, and either a single + `class` property providing a static CSS class (for highlighter + that rely on external styling), or a + [`style-mod`](https://code.haverbeke.berlin/marijn/style-mod#documentation)-style + set of CSS properties (which define the styling for those tags). + + The CSS rules created for a highlighter will be emitted in the + order of the spec's properties. That means that for elements that + have multiple tags associated with them, styles defined further + down in the list will have a higher CSS precedence than styles + defined earlier. + */ + static define(specs: readonly TagStyle[], options?: { + /** + By default, highlighters apply to the entire document. You can + scope them to a single language by providing the language + object or a language's top node type here. + */ + scope?: Language | NodeType; + /** + Add a style to _all_ content. Probably only useful in + combination with `scope`. + */ + all?: string | StyleSpec; + /** + Specify that this highlight style should only be active then + the theme is dark or light. By default, it is active + regardless of theme. + */ + themeType?: "dark" | "light"; + }): HighlightStyle; +} +/** +Wrap a highlighter in an editor extension that uses it to apply +syntax highlighting to the editor content. + +When multiple (non-fallback) styles are provided, the styling +applied is the union of the classes they emit. +*/ +declare function syntaxHighlighting(highlighter: Highlighter, options?: { + /** + When enabled, this marks the highlighter as a fallback, which + only takes effect if no other highlighters are registered. + */ + fallback: boolean; +}): Extension; +/** +Returns the CSS classes (if any) that the highlighters active in +the state would assign to the given style +[tags](https://lezer.codemirror.net/docs/ref#highlight.Tag) and +(optional) language +[scope](https://codemirror.net/6/docs/ref/#language.HighlightStyle^define^options.scope). +*/ +declare function highlightingFor(state: EditorState, tags: readonly Tag[], scope?: NodeType): string | null; +/** +The type of object used in +[`HighlightStyle.define`](https://codemirror.net/6/docs/ref/#language.HighlightStyle^define). +Assigns a style to one or more highlighting +[tags](https://lezer.codemirror.net/docs/ref#highlight.Tag), which can either be a fixed class name +(which must be defined elsewhere), or a set of CSS properties, for +which the library will define an anonymous class. +*/ +interface TagStyle { + /** + The tag or tags to target. + */ + tag: Tag | readonly Tag[]; + /** + If given, this maps the tags to a fixed class name. + */ + class?: string; + /** + Any further properties (if `class` isn't given) will be + interpreted as in style objects given to + [style-mod](https://code.haverbeke.berlin/marijn/style-mod#documentation). + (The type here is `any` because of TypeScript limitations.) + */ + [styleProperty: string]: any; +} +/** +A default highlight style (works well with light themes). +*/ +declare const defaultHighlightStyle: HighlightStyle; + +interface Config { + /** + Whether the bracket matching should look at the character after + the cursor when matching (if the one before isn't a bracket). + Defaults to true. + */ + afterCursor?: boolean; + /** + The bracket characters to match, as a string of pairs. Defaults + to `"()[]{}"`. Note that these are only used as fallback when + there is no [matching + information](https://lezer.codemirror.net/docs/ref/#common.NodeProp^closedBy) + in the syntax tree. + */ + brackets?: string; + /** + The maximum distance to scan for matching brackets. This is only + relevant for brackets not encoded in the syntax tree. Defaults + to 10 000. + */ + maxScanDistance?: number; + /** + Can be used to configure the way in which brackets are + decorated. The default behavior is to add the + `cm-matchingBracket` class for matching pairs, and + `cm-nonmatchingBracket` for mismatched pairs or single brackets. + */ + renderMatch?: (match: MatchResult, state: EditorState) => readonly Range[]; +} +/** +Create an extension that enables bracket matching. Whenever the +cursor is next to a bracket, that bracket and the one it matches +are highlighted. Or, when no matching bracket is found, another +highlighting style is used to indicate this. +*/ +declare function bracketMatching(config?: Config): Extension; +/** +When larger syntax nodes, such as HTML tags, are marked as +opening/closing, it can be a bit messy to treat the whole node as +a matchable bracket. This node prop allows you to define, for such +a node, a ‘handle’—the part of the node that is highlighted, and +that the cursor must be on to activate highlighting in the first +place. +*/ +declare const bracketMatchingHandle: NodeProp<(node: SyntaxNode) => SyntaxNode | null>; +/** +The result returned from `matchBrackets`. +*/ +interface MatchResult { + /** + The extent of the bracket token found. + */ + start: { + from: number; + to: number; + }; + /** + The extent of the matched token, if any was found. + */ + end?: { + from: number; + to: number; + }; + /** + Whether the tokens match. This can be false even when `end` has + a value, if that token doesn't match the opening token. + */ + matched: boolean; +} +/** +Find the matching bracket for the token at `pos`, scanning +direction `dir`. Only the `brackets` and `maxScanDistance` +properties are used from `config`, if given. Returns null if no +bracket was found at `pos`, or a match result otherwise. +*/ +declare function matchBrackets(state: EditorState, pos: number, dir: -1 | 1, config?: Config): MatchResult | null; + +/** +Encapsulates a single line of input. Given to stream syntax code, +which uses it to tokenize the content. +*/ +declare class StringStream { + /** + The line. + */ + string: string; + private tabSize; + /** + The current indent unit size. + */ + indentUnit: number; + private overrideIndent?; + /** + The current position on the line. + */ + pos: number; + /** + The start position of the current token. + */ + start: number; + private lastColumnPos; + private lastColumnValue; + /** + Create a stream. + */ + constructor( + /** + The line. + */ + string: string, tabSize: number, + /** + The current indent unit size. + */ + indentUnit: number, overrideIndent?: number | undefined); + /** + True if we are at the end of the line. + */ + eol(): boolean; + /** + True if we are at the start of the line. + */ + sol(): boolean; + /** + Get the next code unit after the current position, or undefined + if we're at the end of the line. + */ + peek(): string | undefined; + /** + Read the next code unit and advance `this.pos`. + */ + next(): string | void; + /** + Match the next character against the given string, regular + expression, or predicate. Consume and return it if it matches. + */ + eat(match: string | RegExp | ((ch: string) => boolean)): string | void; + /** + Continue matching characters that match the given string, + regular expression, or predicate function. Return true if any + characters were consumed. + */ + eatWhile(match: string | RegExp | ((ch: string) => boolean)): boolean; + /** + Consume whitespace ahead of `this.pos`. Return true if any was + found. + */ + eatSpace(): boolean; + /** + Move to the end of the line. + */ + skipToEnd(): void; + /** + Move to directly before the given character, if found on the + current line. + */ + skipTo(ch: string): boolean | void; + /** + Move back `n` characters. + */ + backUp(n: number): void; + /** + Get the column position at `this.pos`. + */ + column(): number; + /** + Get the indentation column of the current line. + */ + indentation(): number; + /** + Match the input against the given string or regular expression + (which should start with a `^`). Return true or the regexp match + if it matches. + + Unless `consume` is set to `false`, this will move `this.pos` + past the matched text. + + When matching a string `caseInsensitive` can be set to true to + make the match case-insensitive. + */ + match(pattern: string | RegExp, consume?: boolean, caseInsensitive?: boolean): boolean | RegExpMatchArray | null; + /** + Get the current token. + */ + current(): string; +} + +/** +A stream parser parses or tokenizes content from start to end, +emitting tokens as it goes over it. It keeps a mutable (but +copyable) object with state, in which it can store information +about the current context. +*/ +interface StreamParser { + /** + A name for this language. + */ + name?: string; + /** + Produce a start state for the parser. + */ + startState?(indentUnit: number): State; + /** + Read one token, advancing the stream past it, and returning a + string indicating the token's style tag—either the name of one + of the tags in + [`tags`](https://lezer.codemirror.net/docs/ref#highlight.tags) + or [`tokenTable`](https://codemirror.net/6/docs/ref/#language.StreamParser.tokenTable), or such a + name suffixed by one or more tag + [modifier](https://lezer.codemirror.net/docs/ref#highlight.Tag^defineModifier) + names, separated by periods. For example `"keyword"` or + "`variableName.constant"`, or a space-separated set of such + token types. + + It is okay to return a zero-length token, but only if that + updates the state so that the next call will return a non-empty + token again. + */ + token(stream: StringStream, state: State): string | null; + /** + This notifies the parser of a blank line in the input. It can + update its state here if it needs to. + */ + blankLine?(state: State, indentUnit: number): void; + /** + Copy a given state. By default, a shallow object copy is done + which also copies arrays held at the top level of the object. + */ + copyState?(state: State): State; + /** + Compute automatic indentation for the line that starts with the + given state and text. + */ + indent?(state: State, textAfter: string, context: IndentContext): number | null; + /** + Default [language data](https://codemirror.net/6/docs/ref/#state.EditorState.languageDataAt) to + attach to this language. + */ + languageData?: { + [name: string]: any; + }; + /** + Extra tokens to use in this parser. When the tokenizer returns a + token name that exists as a property in this object, the + corresponding tags will be assigned to the token. + */ + tokenTable?: { + [name: string]: Tag | readonly Tag[]; + }; + /** + By default, adjacent tokens of the same type are merged in the + output tree. Set this to false to disable that. + */ + mergeTokens?: boolean; +} +/** +A [language](https://codemirror.net/6/docs/ref/#language.Language) class based on a CodeMirror +5-style [streaming parser](https://codemirror.net/6/docs/ref/#language.StreamParser). +*/ +declare class StreamLanguage extends Language { + private constructor(); + /** + Define a stream language. + */ + static define(spec: StreamParser): StreamLanguage; + get allowsNesting(): boolean; +} + +/** +Make sure nodes +[marked](https://lezer.codemirror.net/docs/ref/#common.NodeProp^isolate) +as isolating for bidirectional text are rendered in a way that +isolates them from the surrounding text. +*/ +declare function bidiIsolates(options?: { + /** + By default, isolating elements are only added when the editor + direction isn't uniformly left-to-right, or if it is, on lines + that contain right-to-left character. When true, disable this + optimization and add them everywhere. + */ + alwaysIsolate?: boolean; +}): Extension; + +export { DocInput, HighlightStyle, IndentContext, LRLanguage, Language, LanguageDescription, LanguageSupport, ParseContext, StreamLanguage, StringStream, TreeIndentContext, bidiIsolates, bracketMatching, bracketMatchingHandle, codeFolding, continuedIndent, defaultHighlightStyle, defineLanguageFacet, delimitedIndent, ensureSyntaxTree, flatIndent, foldAll, foldCode, foldEffect, foldGutter, foldInside, foldKeymap, foldNodeProp, foldService, foldState, foldable, foldedRanges, forceParsing, getIndentUnit, getIndentation, highlightingFor, indentNodeProp, indentOnInput, indentRange, indentService, indentString, indentUnit, language, languageDataProp, matchBrackets, sublanguageProp, syntaxHighlighting, syntaxParserRunning, syntaxTree, syntaxTreeAvailable, toggleFold, unfoldAll, unfoldCode, unfoldEffect, viewportPriorityParsing }; +export type { Config, MatchResult, StreamParser, Sublanguage, TagStyle, ViewportPriorityParsingConfig }; diff --git a/vendor/codemirror-language/dist/index.d.ts b/vendor/codemirror-language/dist/index.d.ts new file mode 100644 index 0000000000..1ebda40037 --- /dev/null +++ b/vendor/codemirror-language/dist/index.d.ts @@ -0,0 +1,1242 @@ +import { Input, Parser, TreeFragment, SyntaxNode, Tree, NodeProp, NodeType } from '@lezer/common'; +import { LRParser, ParserConfig } from '@lezer/lr'; +import * as _codemirror_state from '@codemirror/state'; +import { Text, Facet, Extension, EditorState, StateField, Range } from '@codemirror/state'; +import { EditorView, Command, BlockInfo, ViewUpdate, KeyBinding, DecorationSet, Decoration } from '@codemirror/view'; +import { Highlighter, Tag } from '@lezer/highlight'; +import { StyleModule, StyleSpec } from 'style-mod'; + +/** +Node prop stored in a parser's top syntax node to provide the +facet that stores language-specific data for that language. +*/ +declare const languageDataProp: NodeProp>; +/** +Helper function to define a facet (to be added to the top syntax +node(s) for a language via +[`languageDataProp`](https://codemirror.net/6/docs/ref/#language.languageDataProp)), that will be +used to associate language data with the language. You +probably only need this when subclassing +[`Language`](https://codemirror.net/6/docs/ref/#language.Language). +*/ +declare function defineLanguageFacet(baseData?: { + [name: string]: any; +}): Facet<{ + [name: string]: any; +}, readonly { + [name: string]: any; +}[]>; +/** +Some languages need to return different [language +data](https://codemirror.net/6/docs/ref/#state.EditorState.languageDataAt) for some parts of their +tree. Sublanguages, registered by adding a [node +prop](https://codemirror.net/6/docs/ref/#language.sublanguageProp) to the language's top syntax +node, provide a mechanism to do this. + +(Note that when using nested parsing, where nested syntax is +parsed by a different parser and has its own top node type, you +don't need a sublanguage.) +*/ +interface Sublanguage { + /** + Determines whether the data provided by this sublanguage should + completely replace the regular data or be added to it (with + higher-precedence). The default is `"extend"`. + */ + type?: "replace" | "extend"; + /** + A predicate that returns whether the node at the queried + position is part of the sublanguage. + */ + test: (node: SyntaxNode, state: EditorState) => boolean; + /** + The language data facet that holds the sublanguage's data. + You'll want to use + [`defineLanguageFacet`](https://codemirror.net/6/docs/ref/#language.defineLanguageFacet) to create + this. + */ + facet: Facet<{ + [name: string]: any; + }>; +} +/** +Syntax node prop used to register sublanguages. Should be added to +the top level node type for the language. +*/ +declare const sublanguageProp: NodeProp; +/** +A language object manages parsing and per-language +[metadata](https://codemirror.net/6/docs/ref/#state.EditorState.languageDataAt). Parse data is +managed as a [Lezer](https://lezer.codemirror.net) tree. The class +can be used directly, via the [`LRLanguage`](https://codemirror.net/6/docs/ref/#language.LRLanguage) +subclass for [Lezer](https://lezer.codemirror.net/) LR parsers, or +via the [`StreamLanguage`](https://codemirror.net/6/docs/ref/#language.StreamLanguage) subclass +for stream parsers. +*/ +declare class Language { + /** + The [language data](https://codemirror.net/6/docs/ref/#state.EditorState.languageDataAt) facet + used for this language. + */ + readonly data: Facet<{ + [name: string]: any; + }>; + /** + A language name. + */ + readonly name: string; + /** + The extension value to install this as the document language. + */ + readonly extension: Extension; + /** + The parser object. Can be useful when using this as a [nested + parser](https://lezer.codemirror.net/docs/ref#common.Parser). + */ + parser: Parser; + /** + Construct a language object. If you need to invoke this + directly, first define a data facet with + [`defineLanguageFacet`](https://codemirror.net/6/docs/ref/#language.defineLanguageFacet), and then + configure your parser to [attach](https://codemirror.net/6/docs/ref/#language.languageDataProp) it + to the language's outer syntax node. + */ + constructor( + /** + The [language data](https://codemirror.net/6/docs/ref/#state.EditorState.languageDataAt) facet + used for this language. + */ + data: Facet<{ + [name: string]: any; + }>, parser: Parser, extraExtensions?: Extension[], + /** + A language name. + */ + name?: string); + /** + Query whether this language is active at the given position. + */ + isActiveAt(state: EditorState, pos: number, side?: -1 | 0 | 1): boolean; + /** + Find the document regions that were parsed using this language. + The returned regions will _include_ any nested languages rooted + in this language, when those exist. + */ + findRegions(state: EditorState): { + from: number; + to: number; + }[]; + /** + Indicates whether this language allows nested languages. The + default implementation returns true. + */ + get allowsNesting(): boolean; +} +/** +A subclass of [`Language`](https://codemirror.net/6/docs/ref/#language.Language) for use with Lezer +[LR parsers](https://lezer.codemirror.net/docs/ref#lr.LRParser). +*/ +declare class LRLanguage extends Language { + readonly parser: LRParser; + private constructor(); + /** + Define a language from a parser. + */ + static define(spec: { + /** + The [name](https://codemirror.net/6/docs/ref/#Language.name) of the language. + */ + name?: string; + /** + The parser to use. Should already have added editor-relevant + node props (and optionally things like dialect and top rule) + configured. + */ + parser: LRParser; + /** + [Language data](https://codemirror.net/6/docs/ref/#state.EditorState.languageDataAt) + to register for this language. + */ + languageData?: { + [name: string]: any; + }; + }): LRLanguage; + /** + Create a new instance of this language with a reconfigured + version of its parser and optionally a new name. + */ + configure(options: ParserConfig, name?: string): LRLanguage; + get allowsNesting(): boolean; +} +/** +Get the syntax tree for a state, which is the current (possibly +incomplete) parse tree of the active +[language](https://codemirror.net/6/docs/ref/#language.Language), or the empty tree if there is no +language available. +*/ +declare function syntaxTree(state: EditorState): Tree; +/** +Try to get a parse tree that spans at least up to `upto`. The +method will do at most `timeout` milliseconds of work to parse +up to that point if the tree isn't already available. +*/ +declare function ensureSyntaxTree(state: EditorState, upto: number, timeout?: number): Tree | null; +/** +Queries whether there is a full syntax tree available up to the +given document position. If there isn't, the background parse +process _might_ still be working and update the tree further, but +there is no guarantee of that—the parser will [stop +working](https://codemirror.net/6/docs/ref/#language.syntaxParserRunning) when it has spent a +certain amount of time or has moved beyond the visible viewport. +Always returns false if no language has been enabled. +*/ +declare function syntaxTreeAvailable(state: EditorState, upto?: number): boolean; +/** +Move parsing forward, and update the editor state afterwards to +reflect the new tree. Will work for at most `timeout` +milliseconds. Returns true if the parser managed get to the given +position in that time. +*/ +declare function forceParsing(view: EditorView, upto?: number, timeout?: number): boolean; +/** +Tells you whether the language parser is planning to do more +parsing work (in a `requestIdleCallback` pseudo-thread) or has +stopped running, either because it parsed the entire document, +because it spent too much time and was cut off, or because there +is no language parser enabled. +*/ +declare function syntaxParserRunning(view: EditorView): boolean; +/** +Configuration for viewport-priority provisional parsing. +*/ +interface ViewportPriorityParsingConfig { + /** + Cooperative budget for initial, edit, background, and provisional parse + slices. Individual parser advances and tree finalization cannot be + preempted. Provisional work uses at most 2 ms per slice during gestures. + */ + sliceMs?: number; + /** + Provisional trees only apply highlighting from the outer language. + */ + approximate: "outer-language"; +} +/** +Enable temporary viewport-priority highlighting while the canonical parser +is still working through an earlier part of a large document. The +provisional tree is never exposed through `syntaxTree` or other language +services. +*/ +declare function viewportPriorityParsing(config: ViewportPriorityParsingConfig): Extension; +/** +Lezer-style +[`Input`](https://lezer.codemirror.net/docs/ref#common.Input) +object for a [`Text`](https://codemirror.net/6/docs/ref/#state.Text) object. +*/ +declare class DocInput implements Input { + readonly doc: Text; + private cursor; + private cursorPos; + private string; + /** + Create an input object for the given document. + */ + constructor(doc: Text); + get length(): number; + private syncTo; + chunk(pos: number): string; + get lineChunks(): boolean; + read(from: number, to: number): string; +} +/** +A parse context provided to parsers working on the editor content. +*/ +declare class ParseContext { + private parser; + /** + The current editor state. + */ + readonly state: EditorState; + /** + Tree fragments that can be reused by incremental re-parses. + */ + fragments: readonly TreeFragment[]; + /** + The current editor viewport (or some overapproximation + thereof). Intended to be used for opportunistically avoiding + work (in which case + [`skipUntilInView`](https://codemirror.net/6/docs/ref/#language.ParseContext.skipUntilInView) + should be called to make sure the parser is restarted when the + skipped region becomes visible). + */ + viewport: { + from: number; + to: number; + }; + private parse; + private constructor(); + private startParse; + private withContext; + private withoutTempSkipped; + /** + Notify the parse scheduler that the given region was skipped + because it wasn't in view, and the parse should be restarted + when it comes into view. + */ + skipUntilInView(from: number, to: number): void; + /** + Returns a parser intended to be used as placeholder when + asynchronously loading a nested parser. It'll skip its input and + mark it as not-really-parsed, so that the next update will parse + it again. + + When `until` is given, a reparse will be scheduled when that + promise resolves. + */ + static getSkippingParser(until?: Promise): Parser; + /** + Get the context for the current parse, or `null` if no editor + parse is in progress. + */ + static get(): ParseContext | null; +} +/** +The facet used to associate a language with an editor state. Used +by `Language` object's `extension` property (so you don't need to +manually wrap your languages in this). Can be used to access the +current language on a state. +*/ +declare const language: Facet; +/** +This class bundles a [language](https://codemirror.net/6/docs/ref/#language.Language) with an +optional set of supporting extensions. Language packages are +encouraged to export a function that optionally takes a +configuration object and returns a `LanguageSupport` instance, as +the main way for client code to use the package. +*/ +declare class LanguageSupport { + /** + The language object. + */ + readonly language: Language; + /** + An optional set of supporting extensions. When nesting a + language in another language, the outer language is encouraged + to include the supporting extensions for its inner languages + in its own set of support extensions. + */ + readonly support: Extension; + /** + An extension including both the language and its support + extensions. (Allowing the object to be used as an extension + value itself.) + */ + extension: Extension; + /** + Create a language support object. + */ + constructor( + /** + The language object. + */ + language: Language, + /** + An optional set of supporting extensions. When nesting a + language in another language, the outer language is encouraged + to include the supporting extensions for its inner languages + in its own set of support extensions. + */ + support?: Extension); +} +/** +Language descriptions are used to store metadata about languages +and to dynamically load them. Their main role is finding the +appropriate language for a filename or dynamically loading nested +parsers. +*/ +declare class LanguageDescription { + /** + The name of this language. + */ + readonly name: string; + /** + Alternative names for the mode (lowercased, includes `this.name`). + */ + readonly alias: readonly string[]; + /** + File extensions associated with this language. + */ + readonly extensions: readonly string[]; + /** + Optional filename pattern that should be associated with this + language. + */ + readonly filename: RegExp | undefined; + private loadFunc; + /** + If the language has been loaded, this will hold its value. + */ + support: LanguageSupport | undefined; + private loading; + private constructor(); + /** + Start loading the the language. Will return a promise that + resolves to a [`LanguageSupport`](https://codemirror.net/6/docs/ref/#language.LanguageSupport) + object when the language successfully loads. + */ + load(): Promise; + /** + Create a language description. + */ + static of(spec: { + /** + The language's name. + */ + name: string; + /** + An optional array of alternative names. + */ + alias?: readonly string[]; + /** + An optional array of filename extensions associated with this + language. + */ + extensions?: readonly string[]; + /** + An optional filename pattern associated with this language. + */ + filename?: RegExp; + /** + A function that will asynchronously load the language. + */ + load?: () => Promise; + /** + Alternatively to `load`, you can provide an already loaded + support object. Either this or `load` should be provided. + */ + support?: LanguageSupport; + }): LanguageDescription; + /** + Look for a language in the given array of descriptions that + matches the filename. Will first match + [`filename`](https://codemirror.net/6/docs/ref/#language.LanguageDescription.filename) patterns, + and then [extensions](https://codemirror.net/6/docs/ref/#language.LanguageDescription.extensions), + and return the first language that matches. + */ + static matchFilename(descs: readonly LanguageDescription[], filename: string): LanguageDescription | null; + /** + Look for a language whose name or alias matches the the given + name (case-insensitively). If `fuzzy` is true, and no direct + matchs is found, this'll also search for a language whose name + or alias occurs in the string (for names shorter than three + characters, only when surrounded by non-word characters). + */ + static matchLanguageName(descs: readonly LanguageDescription[], name: string, fuzzy?: boolean): LanguageDescription | null; +} + +/** +Facet that defines a way to provide a function that computes the +appropriate indentation depth, as a column number (see +[`indentString`](https://codemirror.net/6/docs/ref/#language.indentString)), at the start of a given +line. A return value of `null` indicates no indentation can be +determined, and the line should inherit the indentation of the one +above it. A return value of `undefined` defers to the next indent +service. +*/ +declare const indentService: Facet<(context: IndentContext, pos: number) => number | null | undefined, readonly ((context: IndentContext, pos: number) => number | null | undefined)[]>; +/** +Facet for overriding the unit by which indentation happens. Should +be a string consisting entirely of the same whitespace character. +When not set, this defaults to 2 spaces. +*/ +declare const indentUnit: Facet; +/** +Return the _column width_ of an indent unit in the state. +Determined by the [`indentUnit`](https://codemirror.net/6/docs/ref/#language.indentUnit) +facet, and [`tabSize`](https://codemirror.net/6/docs/ref/#state.EditorState^tabSize) when that +contains tabs. +*/ +declare function getIndentUnit(state: EditorState): number; +/** +Create an indentation string that covers columns 0 to `cols`. +Will use tabs for as much of the columns as possible when the +[`indentUnit`](https://codemirror.net/6/docs/ref/#language.indentUnit) facet contains +tabs. +*/ +declare function indentString(state: EditorState, cols: number): string; +/** +Get the indentation, as a column number, at the given position. +Will first consult any [indent services](https://codemirror.net/6/docs/ref/#language.indentService) +that are registered, and if none of those return an indentation, +this will check the syntax tree for the [indent node +prop](https://codemirror.net/6/docs/ref/#language.indentNodeProp) and use that if found. Returns a +number when an indentation could be determined, and null +otherwise. +*/ +declare function getIndentation(context: IndentContext | EditorState, pos: number): number | null; +/** +Create a change set that auto-indents all lines touched by the +given document range. +*/ +declare function indentRange(state: EditorState, from: number, to: number): _codemirror_state.ChangeSet; +/** +Indentation contexts are used when calling [indentation +services](https://codemirror.net/6/docs/ref/#language.indentService). They provide helper utilities +useful in indentation logic, and can selectively override the +indentation reported for some lines. +*/ +declare class IndentContext { + /** + The editor state. + */ + readonly state: EditorState; + /** + The indent unit (number of columns per indentation level). + */ + unit: number; + /** + Create an indent context. + */ + constructor( + /** + The editor state. + */ + state: EditorState, + /** + @internal + */ + options?: { + /** + Override line indentations provided to the indentation + helper function, which is useful when implementing region + indentation, where indentation for later lines needs to refer + to previous lines, which may have been reindented compared to + the original start state. If given, this function should + return -1 for lines (given by start position) that didn't + change, and an updated indentation otherwise. + */ + overrideIndentation?: (pos: number) => number; + /** + Make it look, to the indent logic, like a line break was + added at the given position (which is mostly just useful for + implementing something like + [`insertNewlineAndIndent`](https://codemirror.net/6/docs/ref/#commands.insertNewlineAndIndent)). + */ + simulateBreak?: number; + /** + When `simulateBreak` is given, this can be used to make the + simulated break behave like a double line break. + */ + simulateDoubleBreak?: boolean; + }); + /** + Get a description of the line at the given position, taking + [simulated line + breaks](https://codemirror.net/6/docs/ref/#language.IndentContext.constructor^options.simulateBreak) + into account. If there is such a break at `pos`, the `bias` + argument determines whether the part of the line line before or + after the break is used. + */ + lineAt(pos: number, bias?: -1 | 1): { + text: string; + from: number; + }; + /** + Get the text directly after `pos`, either the entire line + or the next 100 characters, whichever is shorter. + */ + textAfterPos(pos: number, bias?: -1 | 1): string; + /** + Find the column for the given position. + */ + column(pos: number, bias?: -1 | 1): number; + /** + Find the column position (taking tabs into account) of the given + position in the given string. + */ + countColumn(line: string, pos?: number): number; + /** + Find the indentation column of the line at the given point. + */ + lineIndent(pos: number, bias?: -1 | 1): number; + /** + Returns the [simulated line + break](https://codemirror.net/6/docs/ref/#language.IndentContext.constructor^options.simulateBreak) + for this context, if any. + */ + get simulatedBreak(): number | null; +} +/** +A syntax tree node prop used to associate indentation strategies +with node types. Such a strategy is a function from an indentation +context to a column number (see also +[`indentString`](https://codemirror.net/6/docs/ref/#language.indentString)) or null, where null +indicates that no definitive indentation can be determined. +*/ +declare const indentNodeProp: NodeProp<(context: TreeIndentContext) => number | null>; +/** +Objects of this type provide context information and helper +methods to indentation functions registered on syntax nodes. +*/ +declare class TreeIndentContext extends IndentContext { + private base; + /** + The position at which indentation is being computed. + */ + readonly pos: number; + private constructor(); + /** + The syntax tree node to which the indentation strategy + applies. + */ + get node(): SyntaxNode; + /** + Get the text directly after `this.pos`, either the entire line + or the next 100 characters, whichever is shorter. + */ + get textAfter(): string; + /** + Get the indentation at the reference line for `this.node`, which + is the line on which it starts, unless there is a node that is + _not_ a parent of this node covering the start of that line. If + so, the line at the start of that node is tried, again skipping + on if it is covered by another such node. + */ + get baseIndent(): number; + /** + Get the indentation for the reference line of the given node + (see [`baseIndent`](https://codemirror.net/6/docs/ref/#language.TreeIndentContext.baseIndent)). + */ + baseIndentFor(node: SyntaxNode): number; + /** + Continue looking for indentations in the node's parent nodes, + and return the result of that. + */ + continue(): number | null; +} +/** +An indentation strategy for delimited (usually bracketed) nodes. +Will, by default, indent one unit more than the parent's base +indent unless the line starts with a closing token. When `align` +is true and there are non-skipped nodes on the node's opening +line, the content of the node will be aligned with the end of the +opening node, like this: + + foo(bar, + baz) +*/ +declare function delimitedIndent({ closing, align, units }: { + closing: string; + align?: boolean; + units?: number; +}): (context: TreeIndentContext) => number; +/** +An indentation strategy that aligns a node's content to its base +indentation. +*/ +declare const flatIndent: (context: TreeIndentContext) => number; +/** +Creates an indentation strategy that, by default, indents +continued lines one unit more than the node's base indentation. +You can provide `except` to prevent indentation of lines that +match a pattern (for example `/^else\b/` in `if`/`else` +constructs), and you can change the amount of units used with the +`units` option. +*/ +declare function continuedIndent({ except, units }?: { + except?: RegExp; + units?: number; +}): (context: TreeIndentContext) => number; +/** +Enables reindentation on input. When a language defines an +`indentOnInput` field in its [language +data](https://codemirror.net/6/docs/ref/#state.EditorState.languageDataAt), which must hold a regular +expression, the line at the cursor will be reindented whenever new +text is typed and the input from the start of the line up to the +cursor matches that regexp. + +To avoid unneccesary reindents, it is recommended to start the +regexp with `^` (usually followed by `\s*`), and end it with `$`. +For example, `/^\s*\}$/` will reindent when a closing brace is +added at the start of a line. +*/ +declare function indentOnInput(): Extension; + +/** +A facet that registers a code folding service. When called with +the extent of a line, such a function should return a foldable +range that starts on that line (but continues beyond it), if one +can be found. +*/ +declare const foldService: Facet<(state: EditorState, lineStart: number, lineEnd: number) => ({ + from: number; + to: number; +} | null), readonly ((state: EditorState, lineStart: number, lineEnd: number) => ({ + from: number; + to: number; +} | null))[]>; +/** +This node prop is used to associate folding information with +syntax node types. Given a syntax node, it should check whether +that tree is foldable and return the range that can be collapsed +when it is. +*/ +declare const foldNodeProp: NodeProp<(node: SyntaxNode, state: EditorState) => ({ + from: number; + to: number; +} | null)>; +/** +[Fold](https://codemirror.net/6/docs/ref/#language.foldNodeProp) function that folds everything but +the first and the last child of a syntax node. Useful for nodes +that start and end with delimiters. +*/ +declare function foldInside(node: SyntaxNode): { + from: number; + to: number; +} | null; +/** +Check whether the given line is foldable. First asks any fold +services registered through +[`foldService`](https://codemirror.net/6/docs/ref/#language.foldService), and if none of them return +a result, tries to query the [fold node +prop](https://codemirror.net/6/docs/ref/#language.foldNodeProp) of syntax nodes that cover the end +of the line. +*/ +declare function foldable(state: EditorState, lineStart: number, lineEnd: number): { + from: number; + to: number; +} | null; +type DocRange = { + from: number; + to: number; +}; +/** +State effect that can be attached to a transaction to fold the +given range. (You probably only need this in exceptional +circumstances—usually you'll just want to let +[`foldCode`](https://codemirror.net/6/docs/ref/#language.foldCode) and the [fold +gutter](https://codemirror.net/6/docs/ref/#language.foldGutter) create the transactions.) +*/ +declare const foldEffect: _codemirror_state.StateEffectType; +/** +State effect that unfolds the given range (if it was folded). +*/ +declare const unfoldEffect: _codemirror_state.StateEffectType; +/** +The state field that stores the folded ranges (as a [decoration +set](https://codemirror.net/6/docs/ref/#view.DecorationSet)). Can be passed to +[`EditorState.toJSON`](https://codemirror.net/6/docs/ref/#state.EditorState.toJSON) and +[`fromJSON`](https://codemirror.net/6/docs/ref/#state.EditorState^fromJSON) to serialize the fold +state. +*/ +declare const foldState: StateField; +/** +Get a [range set](https://codemirror.net/6/docs/ref/#state.RangeSet) containing the folded ranges +in the given state. +*/ +declare function foldedRanges(state: EditorState): DecorationSet; +/** +Fold the lines that are selected, if possible. +*/ +declare const foldCode: Command; +/** +Unfold folded ranges on selected lines. +*/ +declare const unfoldCode: Command; +/** +Fold all top-level foldable ranges. Note that, in most cases, +folding information will depend on the [syntax +tree](https://codemirror.net/6/docs/ref/#language.syntaxTree), and folding everything may not work +reliably when the document hasn't been fully parsed (either +because the editor state was only just initialized, or because the +document is so big that the parser decided not to parse it +entirely). +*/ +declare const foldAll: Command; +/** +Unfold all folded code. +*/ +declare const unfoldAll: Command; +/** +Toggle folding at cursors. Unfolds if there is an existing fold +starting in that line, tries to find a foldable range around it +otherwise. +*/ +declare const toggleFold: Command; +/** +Default fold-related key bindings. + + - Ctrl-Shift-[ (Cmd-Alt-[ on macOS): [`foldCode`](https://codemirror.net/6/docs/ref/#language.foldCode). + - Ctrl-Shift-] (Cmd-Alt-] on macOS): [`unfoldCode`](https://codemirror.net/6/docs/ref/#language.unfoldCode). + - Ctrl-Alt-[: [`foldAll`](https://codemirror.net/6/docs/ref/#language.foldAll). + - Ctrl-Alt-]: [`unfoldAll`](https://codemirror.net/6/docs/ref/#language.unfoldAll). +*/ +declare const foldKeymap: readonly KeyBinding[]; +interface FoldConfig { + /** + A function that creates the DOM element used to indicate the + position of folded code. The `onclick` argument is the default + click event handler, which toggles folding on the line that + holds the element, and should probably be added as an event + handler to the returned element. If + [`preparePlaceholder`](https://codemirror.net/6/docs/ref/#language.FoldConfig.preparePlaceholder) + is given, its result will be passed as 3rd argument. Otherwise, + this will be null. + + When this option isn't given, the `placeholderText` option will + be used to create the placeholder element. + */ + placeholderDOM?: ((view: EditorView, onclick: (event: Event) => void, prepared: any) => HTMLElement) | null; + /** + Text to use as placeholder for folded text. Defaults to `"…"`. + Will be styled with the `"cm-foldPlaceholder"` class. + */ + placeholderText?: string; + /** + Given a range that is being folded, create a value that + describes it, to be used by `placeholderDOM` to render a custom + widget that, for example, indicates something about the folded + range's size or type. + */ + preparePlaceholder?: (state: EditorState, range: { + from: number; + to: number; + }) => any; +} +/** +Create an extension that configures code folding. +*/ +declare function codeFolding(config?: FoldConfig): Extension; +type Handlers = { + [event: string]: (view: EditorView, line: BlockInfo, event: Event) => boolean; +}; +interface FoldGutterConfig { + /** + A function that creates the DOM element used to indicate a + given line is folded or can be folded. + When not given, the `openText`/`closeText` option will be used instead. + */ + markerDOM?: ((open: boolean) => HTMLElement) | null; + /** + Text used to indicate that a given line can be folded. + Defaults to `"⌄"`. + */ + openText?: string; + /** + Text used to indicate that a given line is folded. + Defaults to `"›"`. + */ + closedText?: string; + /** + Supply event handlers for DOM events on this gutter. + */ + domEventHandlers?: Handlers; + /** + When given, if this returns true for a given view update, + recompute the fold markers. + */ + foldingChanged?: (update: ViewUpdate) => boolean; +} +/** +Create an extension that registers a fold gutter, which shows a +fold status indicator before foldable lines (which can be clicked +to fold or unfold the line). +*/ +declare function foldGutter(config?: FoldGutterConfig): Extension; + +/** +A highlight style associates CSS styles with highlighting +[tags](https://lezer.codemirror.net/docs/ref#highlight.Tag). +*/ +declare class HighlightStyle implements Highlighter { + /** + The tag styles used to create this highlight style. + */ + readonly specs: readonly TagStyle[]; + /** + A style module holding the CSS rules for this highlight style. + When using + [`highlightTree`](https://lezer.codemirror.net/docs/ref#highlight.highlightTree) + outside of the editor, you may want to manually mount this + module to show the highlighting. + */ + readonly module: StyleModule | null; + readonly style: (tags: readonly Tag[]) => string | null; + readonly scope?: (type: NodeType) => boolean; + private constructor(); + /** + Create a highlighter style that associates the given styles to + the given tags. The specs must be objects that hold a style tag + or array of tags in their `tag` property, and either a single + `class` property providing a static CSS class (for highlighter + that rely on external styling), or a + [`style-mod`](https://code.haverbeke.berlin/marijn/style-mod#documentation)-style + set of CSS properties (which define the styling for those tags). + + The CSS rules created for a highlighter will be emitted in the + order of the spec's properties. That means that for elements that + have multiple tags associated with them, styles defined further + down in the list will have a higher CSS precedence than styles + defined earlier. + */ + static define(specs: readonly TagStyle[], options?: { + /** + By default, highlighters apply to the entire document. You can + scope them to a single language by providing the language + object or a language's top node type here. + */ + scope?: Language | NodeType; + /** + Add a style to _all_ content. Probably only useful in + combination with `scope`. + */ + all?: string | StyleSpec; + /** + Specify that this highlight style should only be active then + the theme is dark or light. By default, it is active + regardless of theme. + */ + themeType?: "dark" | "light"; + }): HighlightStyle; +} +/** +Wrap a highlighter in an editor extension that uses it to apply +syntax highlighting to the editor content. + +When multiple (non-fallback) styles are provided, the styling +applied is the union of the classes they emit. +*/ +declare function syntaxHighlighting(highlighter: Highlighter, options?: { + /** + When enabled, this marks the highlighter as a fallback, which + only takes effect if no other highlighters are registered. + */ + fallback: boolean; +}): Extension; +/** +Returns the CSS classes (if any) that the highlighters active in +the state would assign to the given style +[tags](https://lezer.codemirror.net/docs/ref#highlight.Tag) and +(optional) language +[scope](https://codemirror.net/6/docs/ref/#language.HighlightStyle^define^options.scope). +*/ +declare function highlightingFor(state: EditorState, tags: readonly Tag[], scope?: NodeType): string | null; +/** +The type of object used in +[`HighlightStyle.define`](https://codemirror.net/6/docs/ref/#language.HighlightStyle^define). +Assigns a style to one or more highlighting +[tags](https://lezer.codemirror.net/docs/ref#highlight.Tag), which can either be a fixed class name +(which must be defined elsewhere), or a set of CSS properties, for +which the library will define an anonymous class. +*/ +interface TagStyle { + /** + The tag or tags to target. + */ + tag: Tag | readonly Tag[]; + /** + If given, this maps the tags to a fixed class name. + */ + class?: string; + /** + Any further properties (if `class` isn't given) will be + interpreted as in style objects given to + [style-mod](https://code.haverbeke.berlin/marijn/style-mod#documentation). + (The type here is `any` because of TypeScript limitations.) + */ + [styleProperty: string]: any; +} +/** +A default highlight style (works well with light themes). +*/ +declare const defaultHighlightStyle: HighlightStyle; + +interface Config { + /** + Whether the bracket matching should look at the character after + the cursor when matching (if the one before isn't a bracket). + Defaults to true. + */ + afterCursor?: boolean; + /** + The bracket characters to match, as a string of pairs. Defaults + to `"()[]{}"`. Note that these are only used as fallback when + there is no [matching + information](https://lezer.codemirror.net/docs/ref/#common.NodeProp^closedBy) + in the syntax tree. + */ + brackets?: string; + /** + The maximum distance to scan for matching brackets. This is only + relevant for brackets not encoded in the syntax tree. Defaults + to 10 000. + */ + maxScanDistance?: number; + /** + Can be used to configure the way in which brackets are + decorated. The default behavior is to add the + `cm-matchingBracket` class for matching pairs, and + `cm-nonmatchingBracket` for mismatched pairs or single brackets. + */ + renderMatch?: (match: MatchResult, state: EditorState) => readonly Range[]; +} +/** +Create an extension that enables bracket matching. Whenever the +cursor is next to a bracket, that bracket and the one it matches +are highlighted. Or, when no matching bracket is found, another +highlighting style is used to indicate this. +*/ +declare function bracketMatching(config?: Config): Extension; +/** +When larger syntax nodes, such as HTML tags, are marked as +opening/closing, it can be a bit messy to treat the whole node as +a matchable bracket. This node prop allows you to define, for such +a node, a ‘handle’—the part of the node that is highlighted, and +that the cursor must be on to activate highlighting in the first +place. +*/ +declare const bracketMatchingHandle: NodeProp<(node: SyntaxNode) => SyntaxNode | null>; +/** +The result returned from `matchBrackets`. +*/ +interface MatchResult { + /** + The extent of the bracket token found. + */ + start: { + from: number; + to: number; + }; + /** + The extent of the matched token, if any was found. + */ + end?: { + from: number; + to: number; + }; + /** + Whether the tokens match. This can be false even when `end` has + a value, if that token doesn't match the opening token. + */ + matched: boolean; +} +/** +Find the matching bracket for the token at `pos`, scanning +direction `dir`. Only the `brackets` and `maxScanDistance` +properties are used from `config`, if given. Returns null if no +bracket was found at `pos`, or a match result otherwise. +*/ +declare function matchBrackets(state: EditorState, pos: number, dir: -1 | 1, config?: Config): MatchResult | null; + +/** +Encapsulates a single line of input. Given to stream syntax code, +which uses it to tokenize the content. +*/ +declare class StringStream { + /** + The line. + */ + string: string; + private tabSize; + /** + The current indent unit size. + */ + indentUnit: number; + private overrideIndent?; + /** + The current position on the line. + */ + pos: number; + /** + The start position of the current token. + */ + start: number; + private lastColumnPos; + private lastColumnValue; + /** + Create a stream. + */ + constructor( + /** + The line. + */ + string: string, tabSize: number, + /** + The current indent unit size. + */ + indentUnit: number, overrideIndent?: number | undefined); + /** + True if we are at the end of the line. + */ + eol(): boolean; + /** + True if we are at the start of the line. + */ + sol(): boolean; + /** + Get the next code unit after the current position, or undefined + if we're at the end of the line. + */ + peek(): string | undefined; + /** + Read the next code unit and advance `this.pos`. + */ + next(): string | void; + /** + Match the next character against the given string, regular + expression, or predicate. Consume and return it if it matches. + */ + eat(match: string | RegExp | ((ch: string) => boolean)): string | void; + /** + Continue matching characters that match the given string, + regular expression, or predicate function. Return true if any + characters were consumed. + */ + eatWhile(match: string | RegExp | ((ch: string) => boolean)): boolean; + /** + Consume whitespace ahead of `this.pos`. Return true if any was + found. + */ + eatSpace(): boolean; + /** + Move to the end of the line. + */ + skipToEnd(): void; + /** + Move to directly before the given character, if found on the + current line. + */ + skipTo(ch: string): boolean | void; + /** + Move back `n` characters. + */ + backUp(n: number): void; + /** + Get the column position at `this.pos`. + */ + column(): number; + /** + Get the indentation column of the current line. + */ + indentation(): number; + /** + Match the input against the given string or regular expression + (which should start with a `^`). Return true or the regexp match + if it matches. + + Unless `consume` is set to `false`, this will move `this.pos` + past the matched text. + + When matching a string `caseInsensitive` can be set to true to + make the match case-insensitive. + */ + match(pattern: string | RegExp, consume?: boolean, caseInsensitive?: boolean): boolean | RegExpMatchArray | null; + /** + Get the current token. + */ + current(): string; +} + +/** +A stream parser parses or tokenizes content from start to end, +emitting tokens as it goes over it. It keeps a mutable (but +copyable) object with state, in which it can store information +about the current context. +*/ +interface StreamParser { + /** + A name for this language. + */ + name?: string; + /** + Produce a start state for the parser. + */ + startState?(indentUnit: number): State; + /** + Read one token, advancing the stream past it, and returning a + string indicating the token's style tag—either the name of one + of the tags in + [`tags`](https://lezer.codemirror.net/docs/ref#highlight.tags) + or [`tokenTable`](https://codemirror.net/6/docs/ref/#language.StreamParser.tokenTable), or such a + name suffixed by one or more tag + [modifier](https://lezer.codemirror.net/docs/ref#highlight.Tag^defineModifier) + names, separated by periods. For example `"keyword"` or + "`variableName.constant"`, or a space-separated set of such + token types. + + It is okay to return a zero-length token, but only if that + updates the state so that the next call will return a non-empty + token again. + */ + token(stream: StringStream, state: State): string | null; + /** + This notifies the parser of a blank line in the input. It can + update its state here if it needs to. + */ + blankLine?(state: State, indentUnit: number): void; + /** + Copy a given state. By default, a shallow object copy is done + which also copies arrays held at the top level of the object. + */ + copyState?(state: State): State; + /** + Compute automatic indentation for the line that starts with the + given state and text. + */ + indent?(state: State, textAfter: string, context: IndentContext): number | null; + /** + Default [language data](https://codemirror.net/6/docs/ref/#state.EditorState.languageDataAt) to + attach to this language. + */ + languageData?: { + [name: string]: any; + }; + /** + Extra tokens to use in this parser. When the tokenizer returns a + token name that exists as a property in this object, the + corresponding tags will be assigned to the token. + */ + tokenTable?: { + [name: string]: Tag | readonly Tag[]; + }; + /** + By default, adjacent tokens of the same type are merged in the + output tree. Set this to false to disable that. + */ + mergeTokens?: boolean; +} +/** +A [language](https://codemirror.net/6/docs/ref/#language.Language) class based on a CodeMirror +5-style [streaming parser](https://codemirror.net/6/docs/ref/#language.StreamParser). +*/ +declare class StreamLanguage extends Language { + private constructor(); + /** + Define a stream language. + */ + static define(spec: StreamParser): StreamLanguage; + get allowsNesting(): boolean; +} + +/** +Make sure nodes +[marked](https://lezer.codemirror.net/docs/ref/#common.NodeProp^isolate) +as isolating for bidirectional text are rendered in a way that +isolates them from the surrounding text. +*/ +declare function bidiIsolates(options?: { + /** + By default, isolating elements are only added when the editor + direction isn't uniformly left-to-right, or if it is, on lines + that contain right-to-left character. When true, disable this + optimization and add them everywhere. + */ + alwaysIsolate?: boolean; +}): Extension; + +export { DocInput, HighlightStyle, IndentContext, LRLanguage, Language, LanguageDescription, LanguageSupport, ParseContext, StreamLanguage, StringStream, TreeIndentContext, bidiIsolates, bracketMatching, bracketMatchingHandle, codeFolding, continuedIndent, defaultHighlightStyle, defineLanguageFacet, delimitedIndent, ensureSyntaxTree, flatIndent, foldAll, foldCode, foldEffect, foldGutter, foldInside, foldKeymap, foldNodeProp, foldService, foldState, foldable, foldedRanges, forceParsing, getIndentUnit, getIndentation, highlightingFor, indentNodeProp, indentOnInput, indentRange, indentService, indentString, indentUnit, language, languageDataProp, matchBrackets, sublanguageProp, syntaxHighlighting, syntaxParserRunning, syntaxTree, syntaxTreeAvailable, toggleFold, unfoldAll, unfoldCode, unfoldEffect, viewportPriorityParsing }; +export type { Config, MatchResult, StreamParser, Sublanguage, TagStyle, ViewportPriorityParsingConfig }; diff --git a/vendor/codemirror-language/dist/index.js b/vendor/codemirror-language/dist/index.js new file mode 100644 index 0000000000..91c95dd497 --- /dev/null +++ b/vendor/codemirror-language/dist/index.js @@ -0,0 +1,3140 @@ +import { NodeProp, Tree, TreeFragment, Parser, IterMode, NodeType, NodeSet } from '@lezer/common'; +import { StateEffect, Facet, StateField, EditorState, countColumn, combineConfig, RangeSetBuilder, RangeSet, Prec } from '@codemirror/state'; +import { ViewPlugin, EditorView, logException, Decoration, WidgetType, gutter, GutterMarker, Direction } from '@codemirror/view'; +import { tags, highlightTree, tagHighlighter, styleTags } from '@lezer/highlight'; +import { StyleModule } from 'style-mod'; + +var _a; +/** +Node prop stored in a parser's top syntax node to provide the +facet that stores language-specific data for that language. +*/ +const languageDataProp = /*@__PURE__*/new NodeProp(); +/** +Helper function to define a facet (to be added to the top syntax +node(s) for a language via +[`languageDataProp`](https://codemirror.net/6/docs/ref/#language.languageDataProp)), that will be +used to associate language data with the language. You +probably only need this when subclassing +[`Language`](https://codemirror.net/6/docs/ref/#language.Language). +*/ +function defineLanguageFacet(baseData) { + return Facet.define({ + combine: baseData ? values => values.concat(baseData) : undefined + }); +} +/** +Syntax node prop used to register sublanguages. Should be added to +the top level node type for the language. +*/ +const sublanguageProp = /*@__PURE__*/new NodeProp(); +/** +A language object manages parsing and per-language +[metadata](https://codemirror.net/6/docs/ref/#state.EditorState.languageDataAt). Parse data is +managed as a [Lezer](https://lezer.codemirror.net) tree. The class +can be used directly, via the [`LRLanguage`](https://codemirror.net/6/docs/ref/#language.LRLanguage) +subclass for [Lezer](https://lezer.codemirror.net/) LR parsers, or +via the [`StreamLanguage`](https://codemirror.net/6/docs/ref/#language.StreamLanguage) subclass +for stream parsers. +*/ +class Language { + /** + Construct a language object. If you need to invoke this + directly, first define a data facet with + [`defineLanguageFacet`](https://codemirror.net/6/docs/ref/#language.defineLanguageFacet), and then + configure your parser to [attach](https://codemirror.net/6/docs/ref/#language.languageDataProp) it + to the language's outer syntax node. + */ + constructor( + /** + The [language data](https://codemirror.net/6/docs/ref/#state.EditorState.languageDataAt) facet + used for this language. + */ + data, parser, extraExtensions = [], + /** + A language name. + */ + name = "") { + this.data = data; + this.name = name; + // Kludge to define EditorState.tree as a debugging helper, + // without the EditorState package actually knowing about + // languages and lezer trees. + if (!EditorState.prototype.hasOwnProperty("tree")) + Object.defineProperty(EditorState.prototype, "tree", { get() { return syntaxTree(this); } }); + this.parser = parser; + this.extension = [ + language.of(this), + EditorState.languageData.of((state, pos, side) => { + let top = topNodeAt(state, pos, side), data = top.type.prop(languageDataProp); + if (!data) + return []; + let base = state.facet(data), sub = top.type.prop(sublanguageProp); + if (sub) { + let innerNode = top.resolve(pos - top.from, side); + for (let sublang of sub) + if (sublang.test(innerNode, state)) { + let data = state.facet(sublang.facet); + return sublang.type == "replace" ? data : data.concat(base); + } + } + return base; + }) + ].concat(extraExtensions); + } + /** + Query whether this language is active at the given position. + */ + isActiveAt(state, pos, side = -1) { + return topNodeAt(state, pos, side).type.prop(languageDataProp) == this.data; + } + /** + Find the document regions that were parsed using this language. + The returned regions will _include_ any nested languages rooted + in this language, when those exist. + */ + findRegions(state) { + let lang = state.facet(language); + if ((lang === null || lang === void 0 ? void 0 : lang.data) == this.data) + return [{ from: 0, to: state.doc.length }]; + if (!lang || !lang.allowsNesting) + return []; + let result = []; + let explore = (tree, from) => { + if (tree.prop(languageDataProp) == this.data) { + result.push({ from, to: from + tree.length }); + return; + } + let mount = tree.prop(NodeProp.mounted); + if (mount) { + if (mount.tree.prop(languageDataProp) == this.data) { + if (mount.overlay) + for (let r of mount.overlay) + result.push({ from: r.from + from, to: r.to + from }); + else + result.push({ from: from, to: from + tree.length }); + return; + } + else if (mount.overlay) { + let size = result.length; + explore(mount.tree, mount.overlay[0].from + from); + if (result.length > size) + return; + } + } + for (let i = 0; i < tree.children.length; i++) { + let ch = tree.children[i]; + if (ch instanceof Tree) + explore(ch, tree.positions[i] + from); + } + }; + explore(syntaxTree(state), 0); + return result; + } + /** + Indicates whether this language allows nested languages. The + default implementation returns true. + */ + get allowsNesting() { return true; } +} +/** +@internal +*/ +Language.setState = /*@__PURE__*/StateEffect.define(); +function topNodeAt(state, pos, side) { + let topLang = state.facet(language), tree = syntaxTree(state).topNode; + if (!topLang || topLang.allowsNesting) { + for (let node = tree; node; node = node.enter(pos, side, IterMode.ExcludeBuffers | IterMode.EnterBracketed)) + if (node.type.isTop) + tree = node; + } + return tree; +} +/** +A subclass of [`Language`](https://codemirror.net/6/docs/ref/#language.Language) for use with Lezer +[LR parsers](https://lezer.codemirror.net/docs/ref#lr.LRParser). +*/ +class LRLanguage extends Language { + constructor(data, parser, name) { + super(data, parser, [], name); + this.parser = parser; + } + /** + Define a language from a parser. + */ + static define(spec) { + let data = defineLanguageFacet(spec.languageData); + return new LRLanguage(data, spec.parser.configure({ + props: [languageDataProp.add(type => type.isTop ? data : undefined)] + }), spec.name); + } + /** + Create a new instance of this language with a reconfigured + version of its parser and optionally a new name. + */ + configure(options, name) { + return new LRLanguage(this.data, this.parser.configure(options), name || this.name); + } + get allowsNesting() { return this.parser.hasWrappers(); } +} +/** +Get the syntax tree for a state, which is the current (possibly +incomplete) parse tree of the active +[language](https://codemirror.net/6/docs/ref/#language.Language), or the empty tree if there is no +language available. +*/ +function syntaxTree(state) { + let field = state.field(Language.state, false); + return field ? field.tree : Tree.empty; +} +/** +Try to get a parse tree that spans at least up to `upto`. The +method will do at most `timeout` milliseconds of work to parse +up to that point if the tree isn't already available. +*/ +function ensureSyntaxTree(state, upto, timeout = 50) { + var _a; + let parse = (_a = state.field(Language.state, false)) === null || _a === void 0 ? void 0 : _a.context; + if (!parse) + return null; + let oldVieport = parse.viewport; + parse.updateViewport({ from: 0, to: upto }); + let result = parse.isDone(upto) || parse.work(timeout, upto) ? parse.tree : null; + parse.updateViewport(oldVieport); + return result; +} +/** +Queries whether there is a full syntax tree available up to the +given document position. If there isn't, the background parse +process _might_ still be working and update the tree further, but +there is no guarantee of that—the parser will [stop +working](https://codemirror.net/6/docs/ref/#language.syntaxParserRunning) when it has spent a +certain amount of time or has moved beyond the visible viewport. +Always returns false if no language has been enabled. +*/ +function syntaxTreeAvailable(state, upto = state.doc.length) { + var _a; + return ((_a = state.field(Language.state, false)) === null || _a === void 0 ? void 0 : _a.context.isDone(upto)) || false; +} +/** +Move parsing forward, and update the editor state afterwards to +reflect the new tree. Will work for at most `timeout` +milliseconds. Returns true if the parser managed get to the given +position in that time. +*/ +function forceParsing(view, upto = view.viewport.to, timeout = 100) { + let success = ensureSyntaxTree(view.state, upto, timeout); + if (success != syntaxTree(view.state)) + view.dispatch({}); + return !!success; +} +/** +Tells you whether the language parser is planning to do more +parsing work (in a `requestIdleCallback` pseudo-thread) or has +stopped running, either because it parsed the entire document, +because it spent too much time and was cut off, or because there +is no language parser enabled. +*/ +function syntaxParserRunning(view) { + var _a; + return ((_a = view.plugin(parseWorker)) === null || _a === void 0 ? void 0 : _a.isWorking()) || false; +} +const viewportPriorityParsingFacet = /*@__PURE__*/Facet.define({ + combine(values) { + if (!values.length) + return null; + let value = values[0]; + return { + sliceMs: Math.max(1, Math.min(8, Number.isFinite(value.sliceMs) ? value.sliceMs : 4)), + approximate: "outer-language" + }; + } +}); +/** +Enable temporary viewport-priority highlighting while the canonical parser +is still working through an earlier part of a large document. The +provisional tree is never exposed through `syntaxTree` or other language +services. +*/ +function viewportPriorityParsing(config) { + return viewportPriorityParsingFacet.of(config); +} +/** +Lezer-style +[`Input`](https://lezer.codemirror.net/docs/ref#common.Input) +object for a [`Text`](https://codemirror.net/6/docs/ref/#state.Text) object. +*/ +class DocInput { + /** + Create an input object for the given document. + */ + constructor(doc) { + this.doc = doc; + this.cursorPos = 0; + this.string = ""; + this.cursor = doc.iter(); + } + get length() { return this.doc.length; } + syncTo(pos) { + this.string = this.cursor.next(pos - this.cursorPos).value; + this.cursorPos = pos + this.string.length; + return this.cursorPos - this.string.length; + } + chunk(pos) { + this.syncTo(pos); + return this.string; + } + get lineChunks() { return true; } + read(from, to) { + let stringStart = this.cursorPos - this.string.length; + if (from < stringStart || to >= this.cursorPos) + return this.doc.sliceString(from, to); + else + return this.string.slice(from - stringStart, to - stringStart); + } +} +let currentContext = null; +/** +A parse context provided to parsers working on the editor content. +*/ +class ParseContext { + constructor(parser, + /** + The current editor state. + */ + state, + /** + Tree fragments that can be reused by incremental re-parses. + */ + fragments = [], + /** + @internal + */ + tree, + /** + @internal + */ + treeLen, + /** + The current editor viewport (or some overapproximation + thereof). Intended to be used for opportunistically avoiding + work (in which case + [`skipUntilInView`](https://codemirror.net/6/docs/ref/#language.ParseContext.skipUntilInView) + should be called to make sure the parser is restarted when the + skipped region becomes visible). + */ + viewport, + /** + @internal + */ + skipped, + /** + This is where skipping parsers can register a promise that, + when resolved, will schedule a new parse. It is cleared when + the parse worker picks up the promise. @internal + */ + scheduleOn) { + this.parser = parser; + this.state = state; + this.fragments = fragments; + this.tree = tree; + this.treeLen = treeLen; + this.viewport = viewport; + this.skipped = skipped; + this.scheduleOn = scheduleOn; + this.parse = null; + /** + @internal + */ + this.tempSkipped = []; + } + /** + @internal + */ + static create(parser, state, viewport) { + return new ParseContext(parser, state, [], Tree.empty, 0, viewport, [], null); + } + startParse() { + return this.parser.startParse(new DocInput(this.state.doc), this.fragments); + } + /** + @internal + */ + work(until, upto) { + if (upto != null && upto >= this.state.doc.length) + upto = undefined; + if (this.tree != Tree.empty && this.isDone(upto !== null && upto !== void 0 ? upto : this.state.doc.length)) { + this.takeTree(); + return true; + } + return this.withContext(() => { + var _a; + if (typeof until == "number") { + let endTime = Date.now() + until; + until = () => Date.now() > endTime; + } + if (!this.parse) + this.parse = this.startParse(); + if (upto != null && (this.parse.stoppedAt == null || this.parse.stoppedAt > upto) && + upto < this.state.doc.length) + this.parse.stopAt(upto); + for (;;) { + let done = this.parse.advance(); + if (done) { + this.fragments = this.withoutTempSkipped(TreeFragment.addTree(done, this.fragments, this.parse.stoppedAt != null)); + this.treeLen = (_a = this.parse.stoppedAt) !== null && _a !== void 0 ? _a : this.state.doc.length; + this.tree = done; + this.parse = null; + if (this.treeLen < (upto !== null && upto !== void 0 ? upto : this.state.doc.length)) + this.parse = this.startParse(); + else + return true; + } + if (until()) + return false; + } + }); + } + /** + @internal + */ + takeTree() { + let pos, tree; + if (this.parse && (pos = this.parse.parsedPos) >= this.treeLen) { + if (this.parse.stoppedAt == null || this.parse.stoppedAt > pos) + this.parse.stopAt(pos); + this.withContext(() => { while (!(tree = this.parse.advance())) { } }); + this.treeLen = pos; + this.tree = tree; + this.fragments = this.withoutTempSkipped(TreeFragment.addTree(this.tree, this.fragments, true)); + this.parse = null; + } + } + withContext(f) { + let prev = currentContext; + currentContext = this; + try { + return f(); + } + finally { + currentContext = prev; + } + } + withoutTempSkipped(fragments) { + for (let r; r = this.tempSkipped.pop();) + fragments = cutFragments(fragments, r.from, r.to); + return fragments; + } + /** + @internal + */ + changes(changes, newState) { + let { fragments, tree, treeLen, viewport, skipped } = this; + this.takeTree(); + if (!changes.empty) { + let ranges = []; + changes.iterChangedRanges((fromA, toA, fromB, toB) => ranges.push({ fromA, toA, fromB, toB })); + fragments = TreeFragment.applyChanges(fragments, ranges); + tree = Tree.empty; + treeLen = 0; + viewport = { from: changes.mapPos(viewport.from, -1), to: changes.mapPos(viewport.to, 1) }; + if (this.skipped.length) { + skipped = []; + for (let r of this.skipped) { + let from = changes.mapPos(r.from, 1), to = changes.mapPos(r.to, -1); + if (from < to) + skipped.push({ from, to }); + } + } + } + return new ParseContext(this.parser, newState, fragments, tree, treeLen, viewport, skipped, this.scheduleOn); + } + /** + @internal + */ + updateViewport(viewport) { + if (this.viewport.from == viewport.from && this.viewport.to == viewport.to) + return false; + this.viewport = viewport; + let startLen = this.skipped.length; + for (let i = 0; i < this.skipped.length; i++) { + let { from, to } = this.skipped[i]; + if (from < viewport.to && to > viewport.from) { + this.fragments = cutFragments(this.fragments, from, to); + this.skipped.splice(i--, 1); + } + } + if (this.skipped.length >= startLen) + return false; + this.reset(); + return true; + } + /** + @internal + */ + reset() { + if (this.parse) { + this.takeTree(); + this.parse = null; + } + } + /** + Notify the parse scheduler that the given region was skipped + because it wasn't in view, and the parse should be restarted + when it comes into view. + */ + skipUntilInView(from, to) { + this.skipped.push({ from, to }); + } + /** + Returns a parser intended to be used as placeholder when + asynchronously loading a nested parser. It'll skip its input and + mark it as not-really-parsed, so that the next update will parse + it again. + + When `until` is given, a reparse will be scheduled when that + promise resolves. + */ + static getSkippingParser(until) { + return new class extends Parser { + createParse(input, fragments, ranges) { + let from = ranges[0].from, to = ranges[ranges.length - 1].to; + let parser = { + parsedPos: from, + advance() { + let cx = currentContext; + if (cx) { + for (let r of ranges) + cx.tempSkipped.push(r); + if (until) + cx.scheduleOn = cx.scheduleOn ? Promise.all([cx.scheduleOn, until]) : until; + } + this.parsedPos = to; + return new Tree(NodeType.none, [], [], to - from); + }, + stoppedAt: null, + stopAt() { } + }; + return parser; + } + }; + } + /** + @internal + */ + isDone(upto) { + upto = Math.min(upto, this.state.doc.length); + let frags = this.fragments; + return this.treeLen >= upto && frags.length && frags[0].from == 0 && frags[0].to >= upto; + } + /** + Get the context for the current parse, or `null` if no editor + parse is in progress. + */ + static get() { return currentContext; } +} +function cutFragments(fragments, from, to) { + return TreeFragment.applyChanges(fragments, [{ fromA: from, toA: to, fromB: from, toB: to }]); +} +class LanguageState { + constructor( + // A mutable parse state that is used to preserve work done during + // the lifetime of a state when moving to the next state. + context) { + this.context = context; + this.tree = context.tree; + } + apply(tr) { + var _a, _b; + if (!tr.docChanged && this.tree == this.context.tree) + return this; + let newCx = this.context.changes(tr.changes, tr.state); + // If the previous parse wasn't done, go forward only up to its + // end position or the end of the viewport, to avoid slowing down + // state updates with parse work beyond the viewport. + let upto = this.context.treeLen == tr.startState.doc.length ? undefined + : Math.max(tr.changes.mapPos(this.context.treeLen), newCx.viewport.to); + let budget = (_b = (_a = tr.state.facet(viewportPriorityParsingFacet)) === null || _a === void 0 ? void 0 : _a.sliceMs) !== null && _b !== void 0 ? _b : 20 /* Work.Apply */; + if (!newCx.work(budget, upto)) + newCx.takeTree(); + return new LanguageState(newCx); + } + static init(state) { + var _a, _b; + let vpTo = Math.min(3000 /* Work.InitViewport */, state.doc.length); + let parseState = ParseContext.create(state.facet(language).parser, state, { from: 0, to: vpTo }); + let budget = (_b = (_a = state.facet(viewportPriorityParsingFacet)) === null || _a === void 0 ? void 0 : _a.sliceMs) !== null && _b !== void 0 ? _b : 20 /* Work.Apply */; + if (!parseState.work(budget, vpTo)) + parseState.takeTree(); + return new LanguageState(parseState); + } +} +Language.state = /*@__PURE__*/StateField.define({ + create: LanguageState.init, + update(value, tr) { + for (let e of tr.effects) + if (e.is(Language.setState)) + return e.value; + if (tr.startState.facet(language) != tr.state.facet(language)) + return LanguageState.init(tr.state); + return value.apply(tr); + } +}); +let requestIdle = (callback, shortSlices = false) => { + let timeout = setTimeout(() => callback(), shortSlices ? 16 : 500 /* Work.MaxPause */); + return () => clearTimeout(timeout); +}; +if (typeof requestIdleCallback != "undefined") + requestIdle = (callback, shortSlices = false) => { + let pause = shortSlices ? 16 : 100 /* Work.MinPause */; + let idle = -1, timeout = setTimeout(() => { + idle = requestIdleCallback(callback, { timeout: 500 /* Work.MaxPause */ - pause }); + }, pause); + return () => idle < 0 ? clearTimeout(timeout) : cancelIdleCallback(idle); + }; +const isInputPending = typeof navigator != "undefined" && ((_a = navigator.scheduling) === null || _a === void 0 ? void 0 : _a.isInputPending) + ? () => navigator.scheduling.isInputPending() : null; +const ProvisionalEditDebounce = 50; +const ProvisionalDebug = /*@__PURE__*/Symbol.for("@codemirror/language.viewportPriorityDebug"); +/** +@internal +*/ +function provisionalParseIsCurrent(task, generation, doc, lang, destroyed) { + return !!(task === null || task === void 0 ? void 0 : task.parse) && !destroyed && task.generation == generation && + task.doc == doc && task.language == lang; +} +/** +@internal +*/ +class ParseWorker { + constructor(view) { + this.view = view; + this.working = null; + this.workScheduled = 0; + // End of the current time chunk + this.chunkEnd = -1; + // Milliseconds of budget left for this chunk + this.chunkBudget = -1; + this.provisional = null; + this.provisionalTimer = -1; + this.provisionalGeneration = 0; + this.provisionalVersion = 0; + // Completed windows survive viewport changes. Only document/language changes + // invalidate these visual trees; canonical syntax state remains independent. + this.provisionalCache = []; + this.destroyed = false; + this.work = this.work.bind(this); + this.workProvisional = this.workProvisional.bind(this); + this.scheduleWork(); + this.restartProvisional(); + Object.defineProperty(this.view, ProvisionalDebug, { + configurable: true, + value: () => { + var _a, _b; + return ({ + generation: this.provisionalGeneration, + version: this.provisionalVersion, + pending: !!((_a = this.provisional) === null || _a === void 0 ? void 0 : _a.parse), + published: this.provisionalCache.length > 0, + cachedWindows: this.provisionalCache.length, + failed: !!((_b = this.provisional) === null || _b === void 0 ? void 0 : _b.failed) + }); + } + }); + } + update(update) { + let cx = this.view.state.field(Language.state).context; + if (cx.updateViewport(update.view.viewport) || this.view.viewport.to > cx.treeLen) + this.scheduleWork(); + if (update.docChanged || update.selectionSet) { + if (this.view.hasFocus) + this.chunkBudget += 50 /* Work.ChangeBonus */; + this.scheduleWork(); + } + let languageChanged = update.startState.facet(language) != update.state.facet(language); + if (update.docChanged || languageChanged) + this.clearProvisional(); + this.restartProvisional(update.docChanged ? ProvisionalEditDebounce : 0); + if (update.viewportChanged && !update.docChanged && !languageChanged) + this.workProvisional(false); + this.checkAsyncSchedule(cx); + } + provisionalRanges() { + let ranges = this.view.visibleRanges; + if (!ranges.length) + return []; + let doc = this.view.state.doc, size = 128; + let first = Math.floor((doc.lineAt(ranges[0].from).number - 1) / size); + let last = Math.floor((doc.lineAt(ranges[ranges.length - 1].to).number - 1) / size); + let result = []; + let add = (block) => { + let line = block * size + 1; + if (line < 1 || line > doc.lines) + return; + let end = Math.min(doc.lines, line + size - 1); + result.push({ from: doc.line(line).from, + to: end == doc.lines ? doc.length : doc.line(end + 1).from }); + }; + // Finish stable windows instead of restarting on each pixel of movement. + // Visible windows come first, followed by two windows in each direction. + for (let block = first; block <= last; block++) + add(block); + for (let ahead = 1; ahead <= 2; ahead++) { + add(last + ahead); + add(first - ahead); + } + return result.filter(range => range.from < range.to).slice(0, 12); + } + clearProvisional(notify = false, keepCache = false) { + if (this.provisionalTimer > -1) + clearTimeout(this.provisionalTimer); + this.provisionalTimer = -1; + this.provisionalGeneration++; + let hadTree = this.provisionalCache.length > 0; + this.provisional = null; + if (!keepCache) + this.provisionalCache = []; + if (notify && hadTree) + this.provisionalVersion++; + } + restartProvisional(delay = 0) { + let config = this.view.state.facet(viewportPriorityParsingFacet); + let lang = this.view.state.facet(language); + let cx = this.view.state.field(Language.state).context; + if (!config || !lang) { + this.clearProvisional(true); + return; + } + let ranges = this.provisionalRanges(); + let missing = ranges.filter(range => !cx.isDone(range.to) && + !this.provisionalCache.some(tree => tree.from == range.from && tree.to == range.to)); + let current = this.provisional; + if (current && current.doc == this.view.state.doc && current.language == lang && + missing.some(range => range.from == current.from && range.to == current.to)) + return; + if (!current && !missing.length) + return; + this.clearProvisional(false, true); + let range = missing[0]; + if (!range) + return; + let generation = ++this.provisionalGeneration; + try { + this.provisional = { + generation, + doc: this.view.state.doc, + language: lang, + from: range.from, + to: range.to, + parse: lang.parser.startParse(new DocInput(this.view.state.doc), [], [range]), + tree: null, + published: false, + failed: false + }; + this.scheduleProvisional(delay); + } + catch (error) { + this.provisional = { + generation, + doc: this.view.state.doc, + language: lang, + from: range.from, + to: range.to, + parse: null, + tree: null, + published: false, + failed: true + }; + this.provisionalTimer = setTimeout(() => { + var _a; + this.provisionalTimer = -1; + if (!this.destroyed && ((_a = this.provisional) === null || _a === void 0 ? void 0 : _a.generation) == generation && this.provisional.failed) + this.view.dispatch({}); + }, 0); + logException(this.view.state, error, "viewport-priority parse"); + } + } + scheduleProvisional(delay = 0) { + if (this.provisionalTimer > -1 || !this.provisional || this.destroyed) + return; + this.provisionalTimer = setTimeout(this.workProvisional, Math.max(0, delay)); + } + renderCritical() { + var _a; + return !!((_a = this.view.controlledTouchScrollController) === null || _a === void 0 ? void 0 : _a.renderCritical); + } + publishProvisional(task, notify) { + task.published = true; + this.provisionalVersion++; + this.provisionalCache.push({ tree: task.tree, from: task.from, to: task.to, + version: this.provisionalVersion, approximate: "outer-language" }); + // Keep every requested window before nearby history. Character distance + // alone can evict a requested window containing an unusually long line, + // causing it to be parsed repeatedly without ever staying in the cache. + let wanted = new Set(this.provisionalRanges().map(range => range.from)); + let center = (this.view.viewport.from + this.view.viewport.to) / 2; + this.provisionalCache.sort((a, b) => Number(wanted.has(b.from)) - Number(wanted.has(a.from)) || + Math.abs((a.from + a.to) / 2 - center) - Math.abs((b.from + b.to) / 2 - center)); + this.provisionalCache.length = Math.min(this.provisionalCache.length, 12); + if (notify) { + this.view.dispatch({}); + this.restartProvisional(); + } + else { + let generation = task.generation; + this.provisionalTimer = setTimeout(() => { + var _a; + this.provisionalTimer = -1; + if (!this.destroyed && ((_a = this.provisional) === null || _a === void 0 ? void 0 : _a.generation) == generation && this.provisional.published) { + this.view.dispatch({}); + this.restartProvisional(); + } + }, 0); + } + } + workProvisional(notify = true) { + if (this.provisionalTimer > -1) + clearTimeout(this.provisionalTimer); + this.provisionalTimer = -1; + let task = this.provisional; + let config = this.view.state.facet(viewportPriorityParsingFacet); + if ((task === null || task === void 0 ? void 0 : task.tree) && !task.published) { + if (isInputPending === null || isInputPending === void 0 ? void 0 : isInputPending()) + this.scheduleProvisional(16); + else + this.publishProvisional(task, notify); + return; + } + if (!config || !provisionalParseIsCurrent(task, this.provisionalGeneration, this.view.state.doc, this.view.state.facet(language), this.destroyed)) + return; + // Keep canonical parsing deferred during gestures, but prepare visual + // windows in short slices so input and momentum can continue between them. + if (isInputPending === null || isInputPending === void 0 ? void 0 : isInputPending()) { + this.scheduleProvisional(16); + return; + } + let clock = this.view.win.performance; + let end = clock.now() + (this.renderCritical() ? Math.min(2, config.sliceMs) : config.sliceMs); + let tree = null; + do { + tree = task.parse.advance(); + if (tree || notify && isInputPending && isInputPending()) + break; + } while (clock.now() < end); + if (tree) { + task.tree = tree; + task.parse = null; + if (isInputPending === null || isInputPending === void 0 ? void 0 : isInputPending()) + this.scheduleProvisional(16); + else + this.publishProvisional(task, notify); + } + else { + this.scheduleProvisional(this.renderCritical() ? 16 : 0); + } + } + viewportTree() { + let trees = this.viewportTrees(); + let tree = trees.find(tree => tree.to > this.view.viewport.from && tree.from < this.view.viewport.to); + return tree ? Object.assign(Object.assign({}, tree), { version: this.provisionalVersion }) : null; + } + viewportTrees() { + return this.provisionalCache.slice().sort((a, b) => a.from - b.from); + } + provisionalPending() { + var _a, _b; + return !!(this.provisionalTimer > -1 || ((_a = this.provisional) === null || _a === void 0 ? void 0 : _a.parse) || + ((_b = this.provisional) === null || _b === void 0 ? void 0 : _b.tree) && !this.provisional.published); + } + scheduleWork() { + if (this.working) + return; + let { state } = this.view, field = state.field(Language.state); + if (field.tree != field.context.tree || !field.context.isDone(state.doc.length)) + this.working = requestIdle(this.work, !!state.facet(viewportPriorityParsingFacet)); + } + work(deadline) { + var _a; + this.working = null; + if (this.renderCritical()) { + this.scheduleWork(); + return; + } + let now = Date.now(); + if (this.chunkEnd < now && (this.chunkEnd < 0 || this.view.hasFocus)) { // Start a new chunk + this.chunkEnd = now + 30000 /* Work.ChunkTime */; + this.chunkBudget = 3000 /* Work.ChunkBudget */; + } + if (this.chunkBudget <= 0) + return; // No more budget + let { state, viewport: { to: vpTo } } = this.view, field = state.field(Language.state); + if (field.tree == field.context.tree && field.context.isDone(vpTo + 100000 /* Work.MaxParseAhead */)) + return; + let config = state.facet(viewportPriorityParsingFacet); + let endTime = Date.now() + Math.min(this.chunkBudget, (_a = config === null || config === void 0 ? void 0 : config.sliceMs) !== null && _a !== void 0 ? _a : 100 /* Work.Slice */, deadline && !isInputPending ? Math.max(config ? 0 : 25 /* Work.MinSlice */, deadline.timeRemaining() - 5) : 1e9); + let viewportFirst = field.context.treeLen < vpTo && state.doc.length > vpTo + 1000; + let done = field.context.work(() => { + return this.renderCritical() || isInputPending && isInputPending() || Date.now() > endTime; + }, vpTo + (viewportFirst ? 0 : 100000 /* Work.MaxParseAhead */)); + this.chunkBudget -= Date.now() - now; + if (done || this.chunkBudget <= 0) { + field.context.takeTree(); + this.view.dispatch({ effects: Language.setState.of(new LanguageState(field.context)) }); + } + if (this.chunkBudget > 0 && !(done && !viewportFirst)) + this.scheduleWork(); + this.checkAsyncSchedule(field.context); + } + checkAsyncSchedule(cx) { + if (cx.scheduleOn) { + this.workScheduled++; + cx.scheduleOn + .then(() => this.scheduleWork()) + .catch(err => logException(this.view.state, err)) + .then(() => this.workScheduled--); + cx.scheduleOn = null; + } + } + destroy() { + this.destroyed = true; + if (this.working) + this.working(); + this.clearProvisional(); + delete this.view[ProvisionalDebug]; + } + isWorking() { + var _a; + return !!(this.working || this.workScheduled > 0 || this.provisionalTimer > -1 || ((_a = this.provisional) === null || _a === void 0 ? void 0 : _a.parse)); + } +} +const parseWorker = /*@__PURE__*/ViewPlugin.fromClass(ParseWorker, { + eventHandlers: { focus() { this.scheduleWork(); } } +}); +/** +@internal +*/ +function viewportPriorityTree(view) { + var _a; + return ((_a = view.plugin(parseWorker)) === null || _a === void 0 ? void 0 : _a.viewportTree()) || null; +} +/** +@internal +*/ +function viewportPriorityTrees(view) { + var _a; + return ((_a = view.plugin(parseWorker)) === null || _a === void 0 ? void 0 : _a.viewportTrees()) || []; +} +/** +@internal +*/ +function viewportPriorityParsingPending(view) { + var _a; + return !!((_a = view.plugin(parseWorker)) === null || _a === void 0 ? void 0 : _a.provisionalPending()); +} +/** +@internal +*/ +function viewportPriorityParsingFailed(view) { + var _a, _b; + return !!((_b = (_a = view.plugin(parseWorker)) === null || _a === void 0 ? void 0 : _a.provisional) === null || _b === void 0 ? void 0 : _b.failed); +} +/** +The facet used to associate a language with an editor state. Used +by `Language` object's `extension` property (so you don't need to +manually wrap your languages in this). Can be used to access the +current language on a state. +*/ +const language = /*@__PURE__*/Facet.define({ + combine(languages) { return languages.length ? languages[0] : null; }, + enables: language => [ + Language.state, + parseWorker, + EditorView.contentAttributes.compute([language], state => { + let lang = state.facet(language); + return lang && lang.name ? { "data-language": lang.name } : {}; + }) + ] +}); +/** +This class bundles a [language](https://codemirror.net/6/docs/ref/#language.Language) with an +optional set of supporting extensions. Language packages are +encouraged to export a function that optionally takes a +configuration object and returns a `LanguageSupport` instance, as +the main way for client code to use the package. +*/ +class LanguageSupport { + /** + Create a language support object. + */ + constructor( + /** + The language object. + */ + language, + /** + An optional set of supporting extensions. When nesting a + language in another language, the outer language is encouraged + to include the supporting extensions for its inner languages + in its own set of support extensions. + */ + support = []) { + this.language = language; + this.support = support; + this.extension = [language, support]; + } +} +/** +Language descriptions are used to store metadata about languages +and to dynamically load them. Their main role is finding the +appropriate language for a filename or dynamically loading nested +parsers. +*/ +class LanguageDescription { + constructor( + /** + The name of this language. + */ + name, + /** + Alternative names for the mode (lowercased, includes `this.name`). + */ + alias, + /** + File extensions associated with this language. + */ + extensions, + /** + Optional filename pattern that should be associated with this + language. + */ + filename, loadFunc, + /** + If the language has been loaded, this will hold its value. + */ + support = undefined) { + this.name = name; + this.alias = alias; + this.extensions = extensions; + this.filename = filename; + this.loadFunc = loadFunc; + this.support = support; + this.loading = null; + } + /** + Start loading the the language. Will return a promise that + resolves to a [`LanguageSupport`](https://codemirror.net/6/docs/ref/#language.LanguageSupport) + object when the language successfully loads. + */ + load() { + return this.loading || (this.loading = this.loadFunc().then(support => this.support = support, err => { this.loading = null; throw err; })); + } + /** + Create a language description. + */ + static of(spec) { + let { load, support } = spec; + if (!load) { + if (!support) + throw new RangeError("Must pass either 'load' or 'support' to LanguageDescription.of"); + load = () => Promise.resolve(support); + } + return new LanguageDescription(spec.name, (spec.alias || []).concat(spec.name).map(s => s.toLowerCase()), spec.extensions || [], spec.filename, load, support); + } + /** + Look for a language in the given array of descriptions that + matches the filename. Will first match + [`filename`](https://codemirror.net/6/docs/ref/#language.LanguageDescription.filename) patterns, + and then [extensions](https://codemirror.net/6/docs/ref/#language.LanguageDescription.extensions), + and return the first language that matches. + */ + static matchFilename(descs, filename) { + for (let d of descs) + if (d.filename && d.filename.test(filename)) + return d; + let ext = /\.([^.]+)$/.exec(filename); + if (ext) + for (let d of descs) + if (d.extensions.indexOf(ext[1]) > -1) + return d; + return null; + } + /** + Look for a language whose name or alias matches the the given + name (case-insensitively). If `fuzzy` is true, and no direct + matchs is found, this'll also search for a language whose name + or alias occurs in the string (for names shorter than three + characters, only when surrounded by non-word characters). + */ + static matchLanguageName(descs, name, fuzzy = true) { + name = name.toLowerCase(); + for (let d of descs) + if (d.alias.some(a => a == name)) + return d; + if (fuzzy) + for (let d of descs) + for (let a of d.alias) { + let found = name.indexOf(a); + if (found > -1 && (a.length > 2 || !/\w/.test(name[found - 1]) && !/\w/.test(name[found + a.length]))) + return d; + } + return null; + } +} + +/** +Facet that defines a way to provide a function that computes the +appropriate indentation depth, as a column number (see +[`indentString`](https://codemirror.net/6/docs/ref/#language.indentString)), at the start of a given +line. A return value of `null` indicates no indentation can be +determined, and the line should inherit the indentation of the one +above it. A return value of `undefined` defers to the next indent +service. +*/ +const indentService = /*@__PURE__*/Facet.define(); +/** +Facet for overriding the unit by which indentation happens. Should +be a string consisting entirely of the same whitespace character. +When not set, this defaults to 2 spaces. +*/ +const indentUnit = /*@__PURE__*/Facet.define({ + combine: values => { + if (!values.length) + return " "; + let unit = values[0]; + if (!unit || /\S/.test(unit) || Array.from(unit).some(e => e != unit[0])) + throw new Error("Invalid indent unit: " + JSON.stringify(values[0])); + return unit; + } +}); +/** +Return the _column width_ of an indent unit in the state. +Determined by the [`indentUnit`](https://codemirror.net/6/docs/ref/#language.indentUnit) +facet, and [`tabSize`](https://codemirror.net/6/docs/ref/#state.EditorState^tabSize) when that +contains tabs. +*/ +function getIndentUnit(state) { + let unit = state.facet(indentUnit); + return unit.charCodeAt(0) == 9 ? state.tabSize * unit.length : unit.length; +} +/** +Create an indentation string that covers columns 0 to `cols`. +Will use tabs for as much of the columns as possible when the +[`indentUnit`](https://codemirror.net/6/docs/ref/#language.indentUnit) facet contains +tabs. +*/ +function indentString(state, cols) { + let result = "", ts = state.tabSize, ch = state.facet(indentUnit)[0]; + if (ch == "\t") { + while (cols >= ts) { + result += "\t"; + cols -= ts; + } + ch = " "; + } + for (let i = 0; i < cols; i++) + result += ch; + return result; +} +/** +Get the indentation, as a column number, at the given position. +Will first consult any [indent services](https://codemirror.net/6/docs/ref/#language.indentService) +that are registered, and if none of those return an indentation, +this will check the syntax tree for the [indent node +prop](https://codemirror.net/6/docs/ref/#language.indentNodeProp) and use that if found. Returns a +number when an indentation could be determined, and null +otherwise. +*/ +function getIndentation(context, pos) { + if (context instanceof EditorState) + context = new IndentContext(context); + for (let service of context.state.facet(indentService)) { + let result = service(context, pos); + if (result !== undefined) + return result; + } + let tree = syntaxTree(context.state); + return tree.length >= pos ? syntaxIndentation(context, tree, pos) : null; +} +/** +Create a change set that auto-indents all lines touched by the +given document range. +*/ +function indentRange(state, from, to) { + let updated = Object.create(null); + let context = new IndentContext(state, { overrideIndentation: start => { var _a; return (_a = updated[start]) !== null && _a !== void 0 ? _a : -1; } }); + let changes = []; + for (let pos = from; pos <= to;) { + let line = state.doc.lineAt(pos); + pos = line.to + 1; + let indent = getIndentation(context, line.from); + if (indent == null) + continue; + if (!/\S/.test(line.text)) + indent = 0; + let cur = /^\s*/.exec(line.text)[0]; + let norm = indentString(state, indent); + if (cur != norm) { + updated[line.from] = indent; + changes.push({ from: line.from, to: line.from + cur.length, insert: norm }); + } + } + return state.changes(changes); +} +/** +Indentation contexts are used when calling [indentation +services](https://codemirror.net/6/docs/ref/#language.indentService). They provide helper utilities +useful in indentation logic, and can selectively override the +indentation reported for some lines. +*/ +class IndentContext { + /** + Create an indent context. + */ + constructor( + /** + The editor state. + */ + state, + /** + @internal + */ + options = {}) { + this.state = state; + this.options = options; + this.unit = getIndentUnit(state); + } + /** + Get a description of the line at the given position, taking + [simulated line + breaks](https://codemirror.net/6/docs/ref/#language.IndentContext.constructor^options.simulateBreak) + into account. If there is such a break at `pos`, the `bias` + argument determines whether the part of the line line before or + after the break is used. + */ + lineAt(pos, bias = 1) { + let line = this.state.doc.lineAt(pos); + let { simulateBreak, simulateDoubleBreak } = this.options; + if (simulateBreak != null && simulateBreak >= line.from && simulateBreak <= line.to) { + if (simulateDoubleBreak && simulateBreak == pos) + return { text: "", from: pos }; + else if (bias < 0 ? simulateBreak < pos : simulateBreak <= pos) + return { text: line.text.slice(simulateBreak - line.from), from: simulateBreak }; + else + return { text: line.text.slice(0, simulateBreak - line.from), from: line.from }; + } + return line; + } + /** + Get the text directly after `pos`, either the entire line + or the next 100 characters, whichever is shorter. + */ + textAfterPos(pos, bias = 1) { + if (this.options.simulateDoubleBreak && pos == this.options.simulateBreak) + return ""; + let { text, from } = this.lineAt(pos, bias); + return text.slice(pos - from, Math.min(text.length, pos + 100 - from)); + } + /** + Find the column for the given position. + */ + column(pos, bias = 1) { + let { text, from } = this.lineAt(pos, bias); + let result = this.countColumn(text, pos - from); + let override = this.options.overrideIndentation ? this.options.overrideIndentation(from) : -1; + if (override > -1) + result += override - this.countColumn(text, text.search(/\S|$/)); + return result; + } + /** + Find the column position (taking tabs into account) of the given + position in the given string. + */ + countColumn(line, pos = line.length) { + return countColumn(line, this.state.tabSize, pos); + } + /** + Find the indentation column of the line at the given point. + */ + lineIndent(pos, bias = 1) { + let { text, from } = this.lineAt(pos, bias); + let override = this.options.overrideIndentation; + if (override) { + let overriden = override(from); + if (overriden > -1) + return overriden; + } + return this.countColumn(text, text.search(/\S|$/)); + } + /** + Returns the [simulated line + break](https://codemirror.net/6/docs/ref/#language.IndentContext.constructor^options.simulateBreak) + for this context, if any. + */ + get simulatedBreak() { + return this.options.simulateBreak || null; + } +} +/** +A syntax tree node prop used to associate indentation strategies +with node types. Such a strategy is a function from an indentation +context to a column number (see also +[`indentString`](https://codemirror.net/6/docs/ref/#language.indentString)) or null, where null +indicates that no definitive indentation can be determined. +*/ +const indentNodeProp = /*@__PURE__*/new NodeProp(); +// Compute the indentation for a given position from the syntax tree. +function syntaxIndentation(cx, ast, pos) { + let stack = ast.resolveStack(pos); + let inner = ast.resolveInner(pos, -1).resolve(pos, 0).enterUnfinishedNodesBefore(pos); + if (inner != stack.node) { + let add = []; + for (let cur = inner; cur && !(cur.from < stack.node.from || cur.to > stack.node.to || + cur.from == stack.node.from && cur.type == stack.node.type); cur = cur.parent) + add.push(cur); + for (let i = add.length - 1; i >= 0; i--) + stack = { node: add[i], next: stack }; + } + return indentFor(stack, cx, pos); +} +function indentFor(stack, cx, pos) { + for (let cur = stack; cur; cur = cur.next) { + let strategy = indentStrategy(cur.node); + if (strategy) + return strategy(TreeIndentContext.create(cx, pos, cur)); + } + return 0; +} +function ignoreClosed(cx) { + return cx.pos == cx.options.simulateBreak && cx.options.simulateDoubleBreak; +} +function indentStrategy(tree) { + let strategy = tree.type.prop(indentNodeProp); + if (strategy) + return strategy; + let first = tree.firstChild, close; + if (first && (close = first.type.prop(NodeProp.closedBy))) { + let last = tree.lastChild, closed = last && close.indexOf(last.name) > -1; + return cx => delimitedStrategy(cx, true, 1, undefined, closed && !ignoreClosed(cx) ? last.from : undefined); + } + return tree.parent == null ? topIndent : null; +} +function topIndent() { return 0; } +/** +Objects of this type provide context information and helper +methods to indentation functions registered on syntax nodes. +*/ +class TreeIndentContext extends IndentContext { + constructor(base, + /** + The position at which indentation is being computed. + */ + pos, + /** + @internal + */ + context) { + super(base.state, base.options); + this.base = base; + this.pos = pos; + this.context = context; + } + /** + The syntax tree node to which the indentation strategy + applies. + */ + get node() { return this.context.node; } + /** + @internal + */ + static create(base, pos, context) { + return new TreeIndentContext(base, pos, context); + } + /** + Get the text directly after `this.pos`, either the entire line + or the next 100 characters, whichever is shorter. + */ + get textAfter() { + return this.textAfterPos(this.pos); + } + /** + Get the indentation at the reference line for `this.node`, which + is the line on which it starts, unless there is a node that is + _not_ a parent of this node covering the start of that line. If + so, the line at the start of that node is tried, again skipping + on if it is covered by another such node. + */ + get baseIndent() { + return this.baseIndentFor(this.node); + } + /** + Get the indentation for the reference line of the given node + (see [`baseIndent`](https://codemirror.net/6/docs/ref/#language.TreeIndentContext.baseIndent)). + */ + baseIndentFor(node) { + let line = this.state.doc.lineAt(node.from); + // Skip line starts that are covered by a sibling (or cousin, etc) + for (;;) { + let atBreak = node.resolve(line.from); + while (atBreak.parent && atBreak.parent.from == atBreak.from) + atBreak = atBreak.parent; + if (isParent(atBreak, node)) + break; + line = this.state.doc.lineAt(atBreak.from); + } + return this.lineIndent(line.from); + } + /** + Continue looking for indentations in the node's parent nodes, + and return the result of that. + */ + continue() { + return indentFor(this.context.next, this.base, this.pos); + } +} +function isParent(parent, of) { + for (let cur = of; cur; cur = cur.parent) + if (parent == cur) + return true; + return false; +} +// Check whether a delimited node is aligned (meaning there are +// non-skipped nodes on the same line as the opening delimiter). And +// if so, return the opening token. +function bracketedAligned(context) { + let tree = context.node; + let openToken = tree.childAfter(tree.from), last = tree.lastChild; + if (!openToken) + return null; + let sim = context.options.simulateBreak; + let openLine = context.state.doc.lineAt(openToken.from); + let lineEnd = sim == null || sim <= openLine.from ? openLine.to : Math.min(openLine.to, sim); + for (let pos = openToken.to;;) { + let next = tree.childAfter(pos); + if (!next || next == last) + return null; + if (!next.type.isSkipped) { + if (next.from >= lineEnd) + return null; + let space = /^ */.exec(openLine.text.slice(openToken.to - openLine.from))[0].length; + return { from: openToken.from, to: openToken.to + space }; + } + pos = next.to; + } +} +/** +An indentation strategy for delimited (usually bracketed) nodes. +Will, by default, indent one unit more than the parent's base +indent unless the line starts with a closing token. When `align` +is true and there are non-skipped nodes on the node's opening +line, the content of the node will be aligned with the end of the +opening node, like this: + + foo(bar, + baz) +*/ +function delimitedIndent({ closing, align = true, units = 1 }) { + return (context) => delimitedStrategy(context, align, units, closing); +} +function delimitedStrategy(context, align, units, closing, closedAt) { + let after = context.textAfter, space = after.match(/^\s*/)[0].length; + let closed = closing && after.slice(space, space + closing.length) == closing || closedAt == context.pos + space; + let aligned = align ? bracketedAligned(context) : null; + if (aligned) + return closed ? context.column(aligned.from) : context.column(aligned.to); + return context.baseIndent + (closed ? 0 : context.unit * units); +} +/** +An indentation strategy that aligns a node's content to its base +indentation. +*/ +const flatIndent = (context) => context.baseIndent; +/** +Creates an indentation strategy that, by default, indents +continued lines one unit more than the node's base indentation. +You can provide `except` to prevent indentation of lines that +match a pattern (for example `/^else\b/` in `if`/`else` +constructs), and you can change the amount of units used with the +`units` option. +*/ +function continuedIndent({ except, units = 1 } = {}) { + return (context) => { + let matchExcept = except && except.test(context.textAfter); + return context.baseIndent + (matchExcept ? 0 : units * context.unit); + }; +} +const DontIndentBeyond = 200; +/** +Enables reindentation on input. When a language defines an +`indentOnInput` field in its [language +data](https://codemirror.net/6/docs/ref/#state.EditorState.languageDataAt), which must hold a regular +expression, the line at the cursor will be reindented whenever new +text is typed and the input from the start of the line up to the +cursor matches that regexp. + +To avoid unneccesary reindents, it is recommended to start the +regexp with `^` (usually followed by `\s*`), and end it with `$`. +For example, `/^\s*\}$/` will reindent when a closing brace is +added at the start of a line. +*/ +function indentOnInput() { + return EditorState.transactionFilter.of(tr => { + if (!tr.docChanged || !tr.isUserEvent("input.type") && !tr.isUserEvent("input.complete")) + return tr; + let rules = tr.startState.languageDataAt("indentOnInput", tr.startState.selection.main.head); + if (!rules.length) + return tr; + let doc = tr.newDoc, { head } = tr.newSelection.main, line = doc.lineAt(head); + if (head > line.from + DontIndentBeyond) + return tr; + let lineStart = doc.sliceString(line.from, head); + if (!rules.some(r => r.test(lineStart))) + return tr; + let { state } = tr, last = -1, changes = []; + for (let { head } of state.selection.ranges) { + let line = state.doc.lineAt(head); + if (line.from == last) + continue; + last = line.from; + let indent = getIndentation(state, line.from); + if (indent == null) + continue; + let cur = /^\s*/.exec(line.text)[0]; + let norm = indentString(state, indent); + if (cur != norm) + changes.push({ from: line.from, to: line.from + cur.length, insert: norm }); + } + return changes.length ? [tr, { changes, sequential: true }] : tr; + }); +} + +/** +A facet that registers a code folding service. When called with +the extent of a line, such a function should return a foldable +range that starts on that line (but continues beyond it), if one +can be found. +*/ +const foldService = /*@__PURE__*/Facet.define(); +/** +This node prop is used to associate folding information with +syntax node types. Given a syntax node, it should check whether +that tree is foldable and return the range that can be collapsed +when it is. +*/ +const foldNodeProp = /*@__PURE__*/new NodeProp(); +/** +[Fold](https://codemirror.net/6/docs/ref/#language.foldNodeProp) function that folds everything but +the first and the last child of a syntax node. Useful for nodes +that start and end with delimiters. +*/ +function foldInside(node) { + let first = node.firstChild, last = node.lastChild; + return first && first.to < last.from ? { from: first.to, to: last.type.isError ? node.to : last.from } : null; +} +function syntaxFolding(state, start, end) { + let tree = syntaxTree(state); + if (tree.length < end) + return null; + let stack = tree.resolveStack(end, 1); + let found = null; + for (let iter = stack; iter; iter = iter.next) { + let cur = iter.node; + if (cur.to <= end || cur.from > end) + continue; + if (found && cur.from < start) + break; + let prop = cur.type.prop(foldNodeProp); + if (prop && (cur.to < tree.length - 50 || tree.length == state.doc.length || !isUnfinished(cur))) { + let value = prop(cur, state); + if (value && value.from <= end && value.from >= start && value.to > end) + found = value; + } + } + return found; +} +function isUnfinished(node) { + let ch = node.lastChild; + return ch && ch.to == node.to && ch.type.isError; +} +/** +Check whether the given line is foldable. First asks any fold +services registered through +[`foldService`](https://codemirror.net/6/docs/ref/#language.foldService), and if none of them return +a result, tries to query the [fold node +prop](https://codemirror.net/6/docs/ref/#language.foldNodeProp) of syntax nodes that cover the end +of the line. +*/ +function foldable(state, lineStart, lineEnd) { + for (let service of state.facet(foldService)) { + let result = service(state, lineStart, lineEnd); + if (result) + return result; + } + return syntaxFolding(state, lineStart, lineEnd); +} +function mapRange(range, mapping) { + let from = mapping.mapPos(range.from, 1), to = mapping.mapPos(range.to, -1); + return from >= to ? undefined : { from, to }; +} +/** +State effect that can be attached to a transaction to fold the +given range. (You probably only need this in exceptional +circumstances—usually you'll just want to let +[`foldCode`](https://codemirror.net/6/docs/ref/#language.foldCode) and the [fold +gutter](https://codemirror.net/6/docs/ref/#language.foldGutter) create the transactions.) +*/ +const foldEffect = /*@__PURE__*/StateEffect.define({ map: mapRange }); +/** +State effect that unfolds the given range (if it was folded). +*/ +const unfoldEffect = /*@__PURE__*/StateEffect.define({ map: mapRange }); +function selectedLines(view) { + let lines = []; + for (let { head } of view.state.selection.ranges) { + if (lines.some(l => l.from <= head && l.to >= head)) + continue; + lines.push(view.lineBlockAt(head)); + } + return lines; +} +/** +The state field that stores the folded ranges (as a [decoration +set](https://codemirror.net/6/docs/ref/#view.DecorationSet)). Can be passed to +[`EditorState.toJSON`](https://codemirror.net/6/docs/ref/#state.EditorState.toJSON) and +[`fromJSON`](https://codemirror.net/6/docs/ref/#state.EditorState^fromJSON) to serialize the fold +state. +*/ +const foldState = /*@__PURE__*/StateField.define({ + create() { + return Decoration.none; + }, + update(folded, tr) { + if (tr.isUserEvent("delete")) + tr.changes.iterChangedRanges((fromA, toA) => folded = clearTouchedFolds(folded, fromA, toA)); + folded = folded.map(tr.changes); + let rangesToFold = []; + for (let e of tr.effects) { + if (e.is(foldEffect) && !foldExists(folded, e.value.from, e.value.to)) { + rangesToFold.push(e.value); + } + else if (e.is(unfoldEffect)) { + folded = folded.update({ filter: (from, to) => e.value.from != from || e.value.to != to, + filterFrom: e.value.from, filterTo: e.value.to }); + } + } + if (rangesToFold.length) { + let { preparePlaceholder } = tr.state.facet(foldConfig); + let decorations = rangesToFold.map(value => { + let widget = !preparePlaceholder ? foldWidget : + Decoration.replace({ widget: new PreparedFoldWidget(preparePlaceholder(tr.state, value)) }); + return widget.range(value.from, value.to); + }); + folded = folded.update({ add: decorations }); + } + // Clear folded ranges that cover the selection head + if (tr.selection) + folded = clearTouchedFolds(folded, tr.selection.main.head); + return folded; + }, + provide: f => EditorView.decorations.from(f), + toJSON(folded, state) { + let ranges = []; + folded.between(0, state.doc.length, (from, to) => { ranges.push(from, to); }); + return ranges; + }, + fromJSON(value) { + if (!Array.isArray(value) || value.length % 2) + throw new RangeError("Invalid JSON for fold state"); + let ranges = []; + for (let i = 0; i < value.length;) { + let from = value[i++], to = value[i++]; + if (typeof from != "number" || typeof to != "number") + throw new RangeError("Invalid JSON for fold state"); + ranges.push(foldWidget.range(from, to)); + } + return Decoration.set(ranges, true); + } +}); +function clearTouchedFolds(folded, from, to = from) { + let touched = false; + folded.between(from, to, (a, b) => { if (a < to && b > from) + touched = true; }); + return !touched ? folded : folded.update({ + filterFrom: from, + filterTo: to, + filter: (a, b) => a >= to || b <= from + }); +} +/** +Get a [range set](https://codemirror.net/6/docs/ref/#state.RangeSet) containing the folded ranges +in the given state. +*/ +function foldedRanges(state) { + return state.field(foldState, false) || RangeSet.empty; +} +function findFold(state, from, to) { + var _a; + let found = null; + (_a = state.field(foldState, false)) === null || _a === void 0 ? void 0 : _a.between(from, to, (from, to) => { + if (!found || found.from > from) + found = { from, to }; + }); + return found; +} +function foldExists(folded, from, to) { + let found = false; + folded.between(from, from, (a, b) => { if (a == from && b == to) + found = true; }); + return found; +} +function maybeEnable(state, other) { + return state.field(foldState, false) ? other : other.concat(StateEffect.appendConfig.of(codeFolding())); +} +/** +Fold the lines that are selected, if possible. +*/ +const foldCode = view => { + for (let line of selectedLines(view)) { + let range = foldable(view.state, line.from, line.to); + if (range) { + view.dispatch({ effects: maybeEnable(view.state, [foldEffect.of(range), announceFold(view, range)]) }); + return true; + } + } + return false; +}; +/** +Unfold folded ranges on selected lines. +*/ +const unfoldCode = view => { + if (!view.state.field(foldState, false)) + return false; + let effects = []; + for (let line of selectedLines(view)) { + let folded = findFold(view.state, line.from, line.to); + if (folded) + effects.push(unfoldEffect.of(folded), announceFold(view, folded, false)); + } + if (effects.length) + view.dispatch({ effects }); + return effects.length > 0; +}; +function announceFold(view, range, fold = true) { + let lineFrom = view.state.doc.lineAt(range.from).number, lineTo = view.state.doc.lineAt(range.to).number; + return EditorView.announce.of(`${view.state.phrase(fold ? "Folded lines" : "Unfolded lines")} ${lineFrom} ${view.state.phrase("to")} ${lineTo}.`); +} +/** +Fold all top-level foldable ranges. Note that, in most cases, +folding information will depend on the [syntax +tree](https://codemirror.net/6/docs/ref/#language.syntaxTree), and folding everything may not work +reliably when the document hasn't been fully parsed (either +because the editor state was only just initialized, or because the +document is so big that the parser decided not to parse it +entirely). +*/ +const foldAll = view => { + let { state } = view, effects = []; + for (let pos = 0; pos < state.doc.length;) { + let line = view.lineBlockAt(pos), range = foldable(state, line.from, line.to); + if (range) + effects.push(foldEffect.of(range)); + pos = (range ? view.lineBlockAt(range.to) : line).to + 1; + } + if (effects.length) + view.dispatch({ effects: maybeEnable(view.state, effects) }); + return !!effects.length; +}; +/** +Unfold all folded code. +*/ +const unfoldAll = view => { + let field = view.state.field(foldState, false); + if (!field || !field.size) + return false; + let effects = []; + field.between(0, view.state.doc.length, (from, to) => { effects.push(unfoldEffect.of({ from, to })); }); + view.dispatch({ effects }); + return true; +}; +// Find the foldable region containing the given line, if one exists +function foldableContainer(view, lineBlock) { + // Look backwards through line blocks until we find a foldable region that + // intersects with the line + for (let line = lineBlock;;) { + let foldableRegion = foldable(view.state, line.from, line.to); + if (foldableRegion && foldableRegion.to > lineBlock.from) + return foldableRegion; + if (!line.from) + return null; + line = view.lineBlockAt(line.from - 1); + } +} +/** +Toggle folding at cursors. Unfolds if there is an existing fold +starting in that line, tries to find a foldable range around it +otherwise. +*/ +const toggleFold = (view) => { + let effects = []; + for (let line of selectedLines(view)) { + let folded = findFold(view.state, line.from, line.to); + if (folded) { + effects.push(unfoldEffect.of(folded), announceFold(view, folded, false)); + } + else { + let foldRange = foldableContainer(view, line); + if (foldRange) + effects.push(foldEffect.of(foldRange), announceFold(view, foldRange)); + } + } + if (effects.length > 0) + view.dispatch({ effects: maybeEnable(view.state, effects) }); + return !!effects.length; +}; +/** +Default fold-related key bindings. + + - Ctrl-Shift-[ (Cmd-Alt-[ on macOS): [`foldCode`](https://codemirror.net/6/docs/ref/#language.foldCode). + - Ctrl-Shift-] (Cmd-Alt-] on macOS): [`unfoldCode`](https://codemirror.net/6/docs/ref/#language.unfoldCode). + - Ctrl-Alt-[: [`foldAll`](https://codemirror.net/6/docs/ref/#language.foldAll). + - Ctrl-Alt-]: [`unfoldAll`](https://codemirror.net/6/docs/ref/#language.unfoldAll). +*/ +const foldKeymap = [ + { key: "Ctrl-Shift-[", mac: "Cmd-Alt-[", run: foldCode }, + { key: "Ctrl-Shift-]", mac: "Cmd-Alt-]", run: unfoldCode }, + { key: "Ctrl-Alt-[", run: foldAll }, + { key: "Ctrl-Alt-]", run: unfoldAll } +]; +const defaultConfig = { + placeholderDOM: null, + preparePlaceholder: null, + placeholderText: "…" +}; +const foldConfig = /*@__PURE__*/Facet.define({ + combine(values) { return combineConfig(values, defaultConfig); } +}); +/** +Create an extension that configures code folding. +*/ +function codeFolding(config) { + let result = [foldState, baseTheme$1]; + if (config) + result.push(foldConfig.of(config)); + return result; +} +function widgetToDOM(view, prepared) { + let { state } = view, conf = state.facet(foldConfig); + let onclick = (event) => { + let line = view.lineBlockAt(view.posAtDOM(event.target)); + let folded = findFold(view.state, line.from, line.to); + if (folded) + view.dispatch({ effects: unfoldEffect.of(folded) }); + event.preventDefault(); + }; + if (conf.placeholderDOM) + return conf.placeholderDOM(view, onclick, prepared); + let element = document.createElement("span"); + element.textContent = conf.placeholderText; + element.setAttribute("aria-label", state.phrase("folded code")); + element.title = state.phrase("unfold"); + element.className = "cm-foldPlaceholder"; + element.onclick = onclick; + return element; +} +const foldWidget = /*@__PURE__*/Decoration.replace({ widget: /*@__PURE__*/new class extends WidgetType { + toDOM(view) { return widgetToDOM(view, null); } + } }); +class PreparedFoldWidget extends WidgetType { + constructor(value) { + super(); + this.value = value; + } + eq(other) { return this.value == other.value; } + toDOM(view) { return widgetToDOM(view, this.value); } +} +const foldGutterDefaults = { + openText: "⌄", + closedText: "›", + markerDOM: null, + domEventHandlers: {}, + foldingChanged: () => false +}; +class FoldMarker extends GutterMarker { + constructor(config, open) { + super(); + this.config = config; + this.open = open; + } + eq(other) { return this.config == other.config && this.open == other.open; } + toDOM(view) { + if (this.config.markerDOM) + return this.config.markerDOM(this.open); + let span = document.createElement("span"); + span.textContent = this.open ? this.config.openText : this.config.closedText; + span.title = view.state.phrase(this.open ? "Fold line" : "Unfold line"); + return span; + } +} +/** +Create an extension that registers a fold gutter, which shows a +fold status indicator before foldable lines (which can be clicked +to fold or unfold the line). +*/ +function foldGutter(config = {}) { + let fullConfig = Object.assign(Object.assign({}, foldGutterDefaults), config); + let canFold = new FoldMarker(fullConfig, true), canUnfold = new FoldMarker(fullConfig, false); + let markers = ViewPlugin.fromClass(class { + constructor(view) { + this.from = view.viewport.from; + this.markers = this.buildMarkers(view); + } + update(update) { + if (update.docChanged || update.viewportChanged || + update.startState.facet(language) != update.state.facet(language) || + update.startState.field(foldState, false) != update.state.field(foldState, false) || + syntaxTree(update.startState) != syntaxTree(update.state) || + fullConfig.foldingChanged(update)) + this.markers = this.buildMarkers(update.view); + } + buildMarkers(view) { + let builder = new RangeSetBuilder(); + for (let line of view.viewportLineBlocks) { + let mark = findFold(view.state, line.from, line.to) ? canUnfold + : foldable(view.state, line.from, line.to) ? canFold : null; + if (mark) + builder.add(line.from, line.from, mark); + } + return builder.finish(); + } + }); + let { domEventHandlers } = fullConfig; + return [ + markers, + gutter({ + class: "cm-foldGutter", + markers(view) { var _a; return ((_a = view.plugin(markers)) === null || _a === void 0 ? void 0 : _a.markers) || RangeSet.empty; }, + initialSpacer() { + return new FoldMarker(fullConfig, false); + }, + domEventHandlers: Object.assign(Object.assign({}, domEventHandlers), { click: (view, line, event) => { + if (domEventHandlers.click && domEventHandlers.click(view, line, event)) + return true; + let folded = findFold(view.state, line.from, line.to); + if (folded) { + view.dispatch({ effects: unfoldEffect.of(folded) }); + return true; + } + let range = foldable(view.state, line.from, line.to); + if (range) { + view.dispatch({ effects: foldEffect.of(range) }); + return true; + } + return false; + } }) + }), + codeFolding() + ]; +} +const baseTheme$1 = /*@__PURE__*/EditorView.baseTheme({ + ".cm-foldPlaceholder": { + backgroundColor: "#eee", + border: "1px solid #ddd", + color: "#888", + borderRadius: ".2em", + margin: "0 1px", + padding: "0 1px", + cursor: "pointer" + }, + ".cm-foldGutter span": { + padding: "0 1px", + cursor: "pointer" + } +}); + +/** +A highlight style associates CSS styles with highlighting +[tags](https://lezer.codemirror.net/docs/ref#highlight.Tag). +*/ +class HighlightStyle { + constructor( + /** + The tag styles used to create this highlight style. + */ + specs, options) { + this.specs = specs; + let modSpec; + function def(spec) { + let cls = StyleModule.newName(); + (modSpec || (modSpec = Object.create(null)))["." + cls] = spec; + return cls; + } + const all = typeof options.all == "string" ? options.all : options.all ? def(options.all) : undefined; + const scopeOpt = options.scope; + this.scope = scopeOpt instanceof Language ? (type) => type.prop(languageDataProp) == scopeOpt.data + : scopeOpt ? (type) => type == scopeOpt : undefined; + this.style = tagHighlighter(specs.map(style => ({ + tag: style.tag, + class: style.class || def(Object.assign({}, style, { tag: null })) + })), { + all, + }).style; + this.module = modSpec ? new StyleModule(modSpec) : null; + this.themeType = options.themeType; + } + /** + Create a highlighter style that associates the given styles to + the given tags. The specs must be objects that hold a style tag + or array of tags in their `tag` property, and either a single + `class` property providing a static CSS class (for highlighter + that rely on external styling), or a + [`style-mod`](https://code.haverbeke.berlin/marijn/style-mod#documentation)-style + set of CSS properties (which define the styling for those tags). + + The CSS rules created for a highlighter will be emitted in the + order of the spec's properties. That means that for elements that + have multiple tags associated with them, styles defined further + down in the list will have a higher CSS precedence than styles + defined earlier. + */ + static define(specs, options) { + return new HighlightStyle(specs, options || {}); + } +} +const highlighterFacet = /*@__PURE__*/Facet.define(); +const fallbackHighlighter = /*@__PURE__*/Facet.define({ + combine(values) { return values.length ? [values[0]] : null; } +}); +function getHighlighters(state) { + let main = state.facet(highlighterFacet); + return main.length ? main : state.facet(fallbackHighlighter); +} +/** +Wrap a highlighter in an editor extension that uses it to apply +syntax highlighting to the editor content. + +When multiple (non-fallback) styles are provided, the styling +applied is the union of the classes they emit. +*/ +function syntaxHighlighting(highlighter, options) { + let ext = [treeHighlighter], themeType; + if (highlighter instanceof HighlightStyle) { + if (highlighter.module) + ext.push(EditorView.styleModule.of(highlighter.module)); + themeType = highlighter.themeType; + } + if (options === null || options === void 0 ? void 0 : options.fallback) + ext.push(fallbackHighlighter.of(highlighter)); + else if (themeType) + ext.push(highlighterFacet.computeN([EditorView.darkTheme], state => { + return state.facet(EditorView.darkTheme) == (themeType == "dark") ? [highlighter] : []; + })); + else + ext.push(highlighterFacet.of(highlighter)); + return ext; +} +/** +Returns the CSS classes (if any) that the highlighters active in +the state would assign to the given style +[tags](https://lezer.codemirror.net/docs/ref#highlight.Tag) and +(optional) language +[scope](https://codemirror.net/6/docs/ref/#language.HighlightStyle^define^options.scope). +*/ +function highlightingFor(state, tags, scope) { + let highlighters = getHighlighters(state); + let result = null; + if (highlighters) + for (let highlighter of highlighters) { + if (!highlighter.scope || scope && highlighter.scope(scope)) { + let cls = highlighter.style(tags); + if (cls) + result = result ? result + " " + cls : cls; + } + } + return result; +} +class TreeHighlighter { + constructor(view) { + var _a; + this.view = view; + this.markCache = Object.create(null); + this.provisionalVersion = -1; + this.usedProvisional = false; + this.mappedFallback = false; + this.fallbackExpired = false; + this.fallbackGeneration = 0; + this.fallbackTimer = -1; + this.tree = syntaxTree(view.state); + this.decorations = this.buildDeco(view, getHighlighters(view.state)); + this.decoratedFrom = view.viewport.from; + this.decoratedTo = view.viewport.to; + let provisional = viewportPriorityTree(view); + this.provisionalVersion = (_a = provisional === null || provisional === void 0 ? void 0 : provisional.version) !== null && _a !== void 0 ? _a : -1; + this.usedProvisional = !!provisional; + this.publishControlledCoverage(view); + } + update(update) { + var _a, _b; + let tree = syntaxTree(update.state), highlighters = getHighlighters(update.state); + let styleChange = highlighters != getHighlighters(update.startState); + let provisional = viewportPriorityTree(update.view); + let provisionalFailed = viewportPriorityParsingFailed(update.view); + let provisionalVersion = (_a = provisional === null || provisional === void 0 ? void 0 : provisional.version) !== null && _a !== void 0 ? _a : -1; + let { viewport } = update.view; + let decoratedFromMapped = update.changes.mapPos(this.decoratedFrom, -1); + let decoratedToMapped = update.changes.mapPos(this.decoratedTo, 1); + let sameLanguage = update.startState.facet(language) == update.state.facet(language); + let retainMapped = retainMappedHighlighting(update.docChanged, this.usedProvisional, this.fallbackExpired, sameLanguage, styleChange, tree.length, viewport.from, viewport.to, decoratedFromMapped, decoratedToMapped); + if (retainMapped) { + this.tree = tree; + this.decorations = this.decorations.map(update.changes); + this.decoratedFrom = decoratedFromMapped; + this.decoratedTo = decoratedToMapped; + this.provisionalVersion = -1; + this.usedProvisional = this.mappedFallback = true; + this.scheduleFallbackExpiry(update.view); + } + else if (retainMappedHighlightingWhileParsing(this.mappedFallback, this.fallbackExpired, sameLanguage, styleChange, !!provisional, provisionalFailed, tree.length, viewport.from, viewport.to, decoratedFromMapped, decoratedToMapped)) { + // Canonical parsing may publish several incomplete trees while an edit's + // replacement provisional tree is still pending. Keep the mapped visual + // state through those updates instead of rebuilding a plain viewport. + this.tree = tree; + if (!update.changes.empty) + this.decorations = this.decorations.map(update.changes); + this.decoratedFrom = decoratedFromMapped; + this.decoratedTo = decoratedToMapped; + } + else if (retainHighlightingDuringControlledPreparation(!!((_b = update.view.viewState) === null || _b === void 0 ? void 0 : _b.controlledScrollActive), this.usedProvisional, sameLanguage, styleChange, !!provisional, provisionalFailed, tree.length, viewport.to)) { + // A rolling corridor first expands the DOM, then publishes its + // provisional parse. Retain decorations for the already safe range so + // preparing the next chunk cannot flash the current viewport plain. + this.tree = tree; + } + else if (!this.mappedFallback && tree.length < viewport.to && !styleChange && + provisionalVersion == this.provisionalVersion && tree.type == this.tree.type && + decoratedFromMapped <= viewport.from && decoratedToMapped >= viewport.to) { + this.decorations = this.decorations.map(update.changes); + this.decoratedFrom = decoratedFromMapped; + this.decoratedTo = decoratedToMapped; + } + else if (this.mappedFallback && this.fallbackExpired || + provisionalFailed || + tree != this.tree || update.viewportChanged || styleChange || + provisionalVersion != this.provisionalVersion) { + this.tree = tree; + this.decorations = this.buildDeco(update.view, highlighters); + this.decoratedFrom = viewport.from; + this.decoratedTo = viewport.to; + this.provisionalVersion = provisionalVersion; + this.usedProvisional = !!provisional; + this.clearFallback(); + } + this.publishControlledCoverage(update.view, styleChange || !sameLanguage || provisionalFailed); + } + scheduleFallbackExpiry(view) { + if (this.fallbackTimer > -1) + clearTimeout(this.fallbackTimer); + let generation = ++this.fallbackGeneration; + this.fallbackExpired = false; + this.fallbackTimer = setTimeout(() => { + this.fallbackTimer = -1; + if (generation != this.fallbackGeneration || !this.mappedFallback) + return; + if (viewportPriorityParsingPending(view)) { + this.scheduleFallbackExpiry(view); + return; + } + this.fallbackExpired = true; + view.dispatch({}); + }, 5000); + } + clearFallback() { + this.mappedFallback = this.fallbackExpired = false; + this.fallbackGeneration++; + if (this.fallbackTimer > -1) + clearTimeout(this.fallbackTimer); + this.fallbackTimer = -1; + } + publishControlledCoverage(view, invalidate = false) { + var _a, _b, _c; + let controller = view.controlledTouchScrollController; + if (!view.state.facet(language) || !getHighlighters(view.state)) { + (_a = controller === null || controller === void 0 ? void 0 : controller.onHighlightCoverageRemoved) === null || _a === void 0 ? void 0 : _a.call(controller); + return; + } + let { viewport } = view; + let coveredTo = Math.max(viewport.from, this.tree.length); + for (let tree of viewportPriorityTrees(view)) { + if (tree.from <= coveredTo) + coveredTo = Math.max(coveredTo, tree.to); + } + let complete = coveredTo >= viewport.to || + (this.mappedFallback && this.decoratedFrom <= viewport.from && this.decoratedTo >= viewport.to); + if (complete) + (_b = controller === null || controller === void 0 ? void 0 : controller.onHighlightCoverageReady) === null || _b === void 0 ? void 0 : _b.call(controller); + else if (invalidate) + (_c = controller === null || controller === void 0 ? void 0 : controller.onHighlightCoverageInvalidated) === null || _c === void 0 ? void 0 : _c.call(controller); + } + destroy() { + var _a, _b; + this.clearFallback(); + (_b = (_a = this.view.controlledTouchScrollController) === null || _a === void 0 ? void 0 : _a.onHighlightCoverageRemoved) === null || _b === void 0 ? void 0 : _b.call(_a); + } + buildDeco(view, highlighters) { + if (!highlighters) + return Decoration.none; + let builder = new RangeSetBuilder(); + let provisionalTrees = viewportPriorityTrees(view); + for (let { from, to } of view.visibleRanges) { + let canonicalTo = Math.min(to, this.tree.length); + if (from < canonicalTo) + this.addTreeHighlights(builder, this.tree, highlighters, from, canonicalTo); + for (let provisional of provisionalTrees) { + let priority = provisionalHighlightRange(from, to, this.tree.length, provisional.from, provisional.to); + if (priority) + this.addTreeHighlights(builder, provisional.tree, outerLanguageHighlighters(provisional, highlighters), priority.from - provisional.from, priority.to - provisional.from, provisional.from); + } + } + return builder.finish(); + } + addTreeHighlights(builder, tree, highlighters, from, to, offset = 0) { + highlightTree(tree, highlighters, (from, to, style) => { + builder.add(from + offset, to + offset, this.markCache[style] || (this.markCache[style] = Decoration.mark({ class: style }))); + }, from, to); + } +} +/** +@internal +*/ +function retainMappedHighlighting(docChanged, usedProvisional, fallbackExpired, sameLanguage, styleChanged, canonicalLength, viewportFrom, viewportTo, decoratedFrom, decoratedTo) { + return docChanged && usedProvisional && !fallbackExpired && sameLanguage && !styleChanged && + canonicalLength < viewportTo && decoratedFrom <= viewportFrom && decoratedTo >= viewportTo; +} +/** +@internal +*/ +function retainMappedHighlightingWhileParsing(mappedFallback, fallbackExpired, sameLanguage, styleChanged, hasPublishedProvisional, provisionalFailed, canonicalLength, viewportFrom, viewportTo, decoratedFrom, decoratedTo) { + return mappedFallback && !fallbackExpired && sameLanguage && !styleChanged && + !hasPublishedProvisional && !provisionalFailed && canonicalLength < viewportTo && + decoratedFrom <= viewportFrom && decoratedTo >= viewportTo; +} +/** +@internal +*/ +function retainHighlightingDuringControlledPreparation(controlledActive, usedProvisional, sameLanguage, styleChanged, hasPublishedProvisional, provisionalFailed, canonicalLength, viewportTo) { + return controlledActive && usedProvisional && sameLanguage && !styleChanged && + !hasPublishedProvisional && !provisionalFailed && canonicalLength < viewportTo; +} +/** +@internal +*/ +function provisionalHighlightRange(from, to, canonicalLength, provisionalFrom, provisionalTo) { + let priorityFrom = Math.max(from, Math.min(to, canonicalLength), provisionalFrom); + let priorityTo = Math.min(to, provisionalTo); + return priorityFrom < priorityTo ? { from: priorityFrom, to: priorityTo } : null; +} +/** +@internal +*/ +function outerLanguageHighlighters(provisional, highlighters) { + let top = provisional.tree.topNode.type; + return highlighters.map(highlighter => ({ + style: highlighter.style, + scope: (type) => type == top && (!highlighter.scope || highlighter.scope(type)) + })); +} +const treeHighlighter = /*@__PURE__*/Prec.high(/*@__PURE__*/ViewPlugin.fromClass(TreeHighlighter, { + decorations: v => v.decorations +})); +/** +A default highlight style (works well with light themes). +*/ +const defaultHighlightStyle = /*@__PURE__*/HighlightStyle.define([ + { tag: tags.meta, + color: "#404740" }, + { tag: tags.link, + textDecoration: "underline" }, + { tag: tags.heading, + textDecoration: "underline", + fontWeight: "bold" }, + { tag: tags.emphasis, + fontStyle: "italic" }, + { tag: tags.strong, + fontWeight: "bold" }, + { tag: tags.strikethrough, + textDecoration: "line-through" }, + { tag: tags.keyword, + color: "#708" }, + { tag: [tags.atom, tags.bool, tags.url, tags.contentSeparator, tags.labelName], + color: "#219" }, + { tag: [tags.literal, tags.inserted], + color: "#164" }, + { tag: [tags.string, tags.deleted], + color: "#a11" }, + { tag: [tags.regexp, tags.escape, /*@__PURE__*/tags.special(tags.string)], + color: "#e40" }, + { tag: /*@__PURE__*/tags.definition(tags.variableName), + color: "#00f" }, + { tag: /*@__PURE__*/tags.local(tags.variableName), + color: "#30a" }, + { tag: [tags.typeName, tags.namespace], + color: "#085" }, + { tag: tags.className, + color: "#167" }, + { tag: [/*@__PURE__*/tags.special(tags.variableName), tags.macroName], + color: "#256" }, + { tag: /*@__PURE__*/tags.definition(tags.propertyName), + color: "#00c" }, + { tag: tags.comment, + color: "#940" }, + { tag: tags.invalid, + color: "#f00" } +]); + +const baseTheme = /*@__PURE__*/EditorView.baseTheme({ + "&.cm-focused .cm-matchingBracket": { backgroundColor: "#328c8252" }, + "&.cm-focused .cm-nonmatchingBracket": { backgroundColor: "#bb555544" } +}); +const DefaultScanDist = 10000, DefaultBrackets = "()[]{}"; +const bracketMatchingConfig = /*@__PURE__*/Facet.define({ + combine(configs) { + return combineConfig(configs, { + afterCursor: true, + brackets: DefaultBrackets, + maxScanDistance: DefaultScanDist, + renderMatch: defaultRenderMatch + }); + } +}); +const matchingMark = /*@__PURE__*/Decoration.mark({ class: "cm-matchingBracket" }), nonmatchingMark = /*@__PURE__*/Decoration.mark({ class: "cm-nonmatchingBracket" }); +function defaultRenderMatch(match) { + let decorations = []; + let mark = match.matched ? matchingMark : nonmatchingMark; + decorations.push(mark.range(match.start.from, match.start.to)); + if (match.end) + decorations.push(mark.range(match.end.from, match.end.to)); + return decorations; +} +function bracketDeco(state) { + let decorations = []; + let config = state.facet(bracketMatchingConfig); + for (let range of state.selection.ranges) { + if (!range.empty) + continue; + let match = matchBrackets(state, range.head, -1, config) + || (range.head > 0 && matchBrackets(state, range.head - 1, 1, config)) + || (config.afterCursor && + (matchBrackets(state, range.head, 1, config) || + (range.head < state.doc.length && matchBrackets(state, range.head + 1, -1, config)))); + if (match) + decorations = decorations.concat(config.renderMatch(match, state)); + } + return Decoration.set(decorations, true); +} +const bracketMatcher = /*@__PURE__*/ViewPlugin.fromClass(class { + constructor(view) { + this.paused = false; + this.decorations = bracketDeco(view.state); + } + update(update) { + if (update.docChanged || update.selectionSet || this.paused) { + if (update.view.composing) { + this.decorations = this.decorations.map(update.changes); + this.paused = true; + } + else { + this.decorations = bracketDeco(update.state); + this.paused = false; + } + } + } +}, { + decorations: v => v.decorations +}); +const bracketMatchingUnique = [ + bracketMatcher, + baseTheme +]; +/** +Create an extension that enables bracket matching. Whenever the +cursor is next to a bracket, that bracket and the one it matches +are highlighted. Or, when no matching bracket is found, another +highlighting style is used to indicate this. +*/ +function bracketMatching(config = {}) { + return [bracketMatchingConfig.of(config), bracketMatchingUnique]; +} +/** +When larger syntax nodes, such as HTML tags, are marked as +opening/closing, it can be a bit messy to treat the whole node as +a matchable bracket. This node prop allows you to define, for such +a node, a ‘handle’—the part of the node that is highlighted, and +that the cursor must be on to activate highlighting in the first +place. +*/ +const bracketMatchingHandle = /*@__PURE__*/new NodeProp(); +function matchingNodes(node, dir, brackets) { + let byProp = node.prop(dir < 0 ? NodeProp.openedBy : NodeProp.closedBy); + if (byProp) + return byProp; + if (node.name.length == 1) { + let index = brackets.indexOf(node.name); + if (index > -1 && index % 2 == (dir < 0 ? 1 : 0)) + return [brackets[index + dir]]; + } + return null; +} +function findHandle(node) { + let hasHandle = node.type.prop(bracketMatchingHandle); + return hasHandle ? hasHandle(node.node) : node; +} +/** +Find the matching bracket for the token at `pos`, scanning +direction `dir`. Only the `brackets` and `maxScanDistance` +properties are used from `config`, if given. Returns null if no +bracket was found at `pos`, or a match result otherwise. +*/ +function matchBrackets(state, pos, dir, config = {}) { + let maxScanDistance = config.maxScanDistance || DefaultScanDist, brackets = config.brackets || DefaultBrackets; + let tree = syntaxTree(state), node = tree.resolveInner(pos, dir); + for (let cur = node; cur; cur = cur.parent) { + let matches = matchingNodes(cur.type, dir, brackets); + if (matches && cur.from < cur.to) { + let handle = findHandle(cur); + if (handle && (dir > 0 ? pos >= handle.from && pos < handle.to : pos > handle.from && pos <= handle.to)) + return matchMarkedBrackets(state, pos, dir, cur, handle, matches, brackets); + } + } + return matchPlainBrackets(state, pos, dir, tree, node.type, maxScanDistance, brackets); +} +function matchMarkedBrackets(_state, _pos, dir, token, handle, matching, brackets) { + let parent = token.parent, firstToken = { from: handle.from, to: handle.to }; + let depth = 0, cursor = parent === null || parent === void 0 ? void 0 : parent.cursor(); + if (cursor && (dir < 0 ? cursor.childBefore(token.from) : cursor.childAfter(token.to))) + do { + if (dir < 0 ? cursor.to <= token.from : cursor.from >= token.to) { + if (depth == 0 && matching.indexOf(cursor.type.name) > -1 && cursor.from < cursor.to) { + let endHandle = findHandle(cursor); + return { start: firstToken, end: endHandle ? { from: endHandle.from, to: endHandle.to } : undefined, matched: true }; + } + else if (matchingNodes(cursor.type, dir, brackets)) { + depth++; + } + else if (matchingNodes(cursor.type, -dir, brackets)) { + if (depth == 0) { + let endHandle = findHandle(cursor); + return { + start: firstToken, + end: endHandle && endHandle.from < endHandle.to ? { from: endHandle.from, to: endHandle.to } : undefined, + matched: false + }; + } + depth--; + } + } + } while (dir < 0 ? cursor.prevSibling() : cursor.nextSibling()); + return { start: firstToken, matched: false }; +} +function matchPlainBrackets(state, pos, dir, tree, tokenType, maxScanDistance, brackets) { + if (dir < 0 ? !pos : pos == state.doc.length) + return null; + let startCh = dir < 0 ? state.sliceDoc(pos - 1, pos) : state.sliceDoc(pos, pos + 1); + let bracket = brackets.indexOf(startCh); + if (bracket < 0 || (bracket % 2 == 0) != (dir > 0)) + return null; + let startToken = { from: dir < 0 ? pos - 1 : pos, to: dir > 0 ? pos + 1 : pos }; + let iter = state.doc.iterRange(pos, dir > 0 ? state.doc.length : 0), depth = 0; + for (let distance = 0; !(iter.next()).done && distance <= maxScanDistance;) { + let text = iter.value; + if (dir < 0) + distance += text.length; + let basePos = pos + distance * dir; + for (let pos = dir > 0 ? 0 : text.length - 1, end = dir > 0 ? text.length : -1; pos != end; pos += dir) { + let found = brackets.indexOf(text[pos]); + if (found < 0 || tree.resolveInner(basePos + pos, 1).type != tokenType) + continue; + if ((found % 2 == 0) == (dir > 0)) { + depth++; + } + else if (depth == 1) { // Closing + return { start: startToken, end: { from: basePos + pos, to: basePos + pos + 1 }, matched: (found >> 1) == (bracket >> 1) }; + } + else { + depth--; + } + } + if (dir > 0) + distance += text.length; + } + return iter.done ? { start: startToken, matched: false } : null; +} + +// Counts the column offset in a string, taking tabs into account. +// Used mostly to find indentation. +function countCol(string, end, tabSize, startIndex = 0, startValue = 0) { + if (end == null) { + end = string.search(/[^\s\u00a0]/); + if (end == -1) + end = string.length; + } + let n = startValue; + for (let i = startIndex; i < end; i++) { + if (string.charCodeAt(i) == 9) + n += tabSize - (n % tabSize); + else + n++; + } + return n; +} +/** +Encapsulates a single line of input. Given to stream syntax code, +which uses it to tokenize the content. +*/ +class StringStream { + /** + Create a stream. + */ + constructor( + /** + The line. + */ + string, tabSize, + /** + The current indent unit size. + */ + indentUnit, overrideIndent) { + this.string = string; + this.tabSize = tabSize; + this.indentUnit = indentUnit; + this.overrideIndent = overrideIndent; + /** + The current position on the line. + */ + this.pos = 0; + /** + The start position of the current token. + */ + this.start = 0; + this.lastColumnPos = 0; + this.lastColumnValue = 0; + } + /** + True if we are at the end of the line. + */ + eol() { return this.pos >= this.string.length; } + /** + True if we are at the start of the line. + */ + sol() { return this.pos == 0; } + /** + Get the next code unit after the current position, or undefined + if we're at the end of the line. + */ + peek() { return this.string.charAt(this.pos) || undefined; } + /** + Read the next code unit and advance `this.pos`. + */ + next() { + if (this.pos < this.string.length) + return this.string.charAt(this.pos++); + } + /** + Match the next character against the given string, regular + expression, or predicate. Consume and return it if it matches. + */ + eat(match) { + let ch = this.string.charAt(this.pos); + let ok; + if (typeof match == "string") + ok = ch == match; + else + ok = ch && (match instanceof RegExp ? match.test(ch) : match(ch)); + if (ok) { + ++this.pos; + return ch; + } + } + /** + Continue matching characters that match the given string, + regular expression, or predicate function. Return true if any + characters were consumed. + */ + eatWhile(match) { + let start = this.pos; + while (this.eat(match)) { } + return this.pos > start; + } + /** + Consume whitespace ahead of `this.pos`. Return true if any was + found. + */ + eatSpace() { + let start = this.pos; + while (/[\s\u00a0]/.test(this.string.charAt(this.pos))) + ++this.pos; + return this.pos > start; + } + /** + Move to the end of the line. + */ + skipToEnd() { this.pos = this.string.length; } + /** + Move to directly before the given character, if found on the + current line. + */ + skipTo(ch) { + let found = this.string.indexOf(ch, this.pos); + if (found > -1) { + this.pos = found; + return true; + } + } + /** + Move back `n` characters. + */ + backUp(n) { this.pos -= n; } + /** + Get the column position at `this.pos`. + */ + column() { + if (this.lastColumnPos < this.start) { + this.lastColumnValue = countCol(this.string, this.start, this.tabSize, this.lastColumnPos, this.lastColumnValue); + this.lastColumnPos = this.start; + } + return this.lastColumnValue; + } + /** + Get the indentation column of the current line. + */ + indentation() { + var _a; + return (_a = this.overrideIndent) !== null && _a !== void 0 ? _a : countCol(this.string, null, this.tabSize); + } + /** + Match the input against the given string or regular expression + (which should start with a `^`). Return true or the regexp match + if it matches. + + Unless `consume` is set to `false`, this will move `this.pos` + past the matched text. + + When matching a string `caseInsensitive` can be set to true to + make the match case-insensitive. + */ + match(pattern, consume, caseInsensitive) { + if (typeof pattern == "string") { + let cased = (str) => caseInsensitive ? str.toLowerCase() : str; + let substr = this.string.substr(this.pos, pattern.length); + if (cased(substr) == cased(pattern)) { + if (consume !== false) + this.pos += pattern.length; + return true; + } + else + return null; + } + else { + let match = this.string.slice(this.pos).match(pattern); + if (match && match.index > 0) + return null; + if (match && consume !== false) + this.pos += match[0].length; + return match; + } + } + /** + Get the current token. + */ + current() { return this.string.slice(this.start, this.pos); } +} + +function fullParser(spec) { + return { + name: spec.name || "", + token: spec.token, + blankLine: spec.blankLine || (() => { }), + startState: spec.startState || (() => true), + copyState: spec.copyState || defaultCopyState, + indent: spec.indent || (() => null), + languageData: spec.languageData || {}, + tokenTable: spec.tokenTable || noTokens, + mergeTokens: spec.mergeTokens !== false + }; +} +function defaultCopyState(state) { + if (typeof state != "object") + return state; + let newState = {}; + for (let prop in state) { + let val = state[prop]; + newState[prop] = (val instanceof Array ? val.slice() : val); + } + return newState; +} +const IndentedFrom = /*@__PURE__*/new WeakMap(); +/** +A [language](https://codemirror.net/6/docs/ref/#language.Language) class based on a CodeMirror +5-style [streaming parser](https://codemirror.net/6/docs/ref/#language.StreamParser). +*/ +class StreamLanguage extends Language { + constructor(parser) { + let data = defineLanguageFacet(parser.languageData); + let p = fullParser(parser), self; + let impl = new class extends Parser { + createParse(input, fragments, ranges) { + return new Parse(self, input, fragments, ranges); + } + }; + super(data, impl, [], parser.name); + this.topNode = docID(data, this); + self = this; + this.streamParser = p; + this.stateAfter = new NodeProp({ perNode: true }); + this.tokenTable = parser.tokenTable ? new TokenTable(p.tokenTable) : defaultTokenTable; + } + /** + Define a stream language. + */ + static define(spec) { return new StreamLanguage(spec); } + /** + @internal + */ + getIndent(cx) { + let from = undefined; + let { overrideIndentation } = cx.options; + if (overrideIndentation) { + from = IndentedFrom.get(cx.state); + if (from != null && from < cx.pos - 1e4) + from = undefined; + } + let start = findState(this, cx.node.tree, cx.node.from, cx.node.from, from !== null && from !== void 0 ? from : cx.pos), statePos, state; + if (start) { + state = start.state; + statePos = start.pos + 1; + } + else { + state = this.streamParser.startState(cx.unit); + statePos = cx.node.from; + } + if (cx.pos - statePos > 10000 /* C.MaxIndentScanDist */) + return null; + while (statePos < cx.pos) { + let line = cx.state.doc.lineAt(statePos), end = Math.min(cx.pos, line.to); + if (line.length) { + let indentation = overrideIndentation ? overrideIndentation(line.from) : -1; + let stream = new StringStream(line.text, cx.state.tabSize, cx.unit, indentation < 0 ? undefined : indentation); + while (stream.pos < end - line.from) + readToken(this.streamParser.token, stream, state); + } + else { + this.streamParser.blankLine(state, cx.unit); + } + if (end == cx.pos) + break; + statePos = line.to + 1; + } + let line = cx.lineAt(cx.pos); + if (overrideIndentation && from == null) + IndentedFrom.set(cx.state, line.from); + return this.streamParser.indent(state, /^\s*(.*)/.exec(line.text)[1], cx); + } + get allowsNesting() { return false; } +} +function findState(lang, tree, off, startPos, before) { + let state = off >= startPos && off + tree.length <= before && tree.prop(lang.stateAfter); + if (state) + return { state: lang.streamParser.copyState(state), pos: off + tree.length }; + for (let i = tree.children.length - 1; i >= 0; i--) { + let child = tree.children[i], pos = off + tree.positions[i]; + let found = child instanceof Tree && pos < before && findState(lang, child, pos, startPos, before); + if (found) + return found; + } + return null; +} +function cutTree(lang, tree, from, to, inside) { + if (inside && from <= 0 && to >= tree.length) + return tree; + if (!inside && from == 0 && tree.type == lang.topNode) + inside = true; + for (let i = tree.children.length - 1; i >= 0; i--) { + let pos = tree.positions[i], child = tree.children[i], inner; + if (pos < to && child instanceof Tree) { + if (!(inner = cutTree(lang, child, from - pos, to - pos, inside))) + break; + return !inside ? inner + : new Tree(tree.type, tree.children.slice(0, i).concat(inner), tree.positions.slice(0, i + 1), pos + inner.length); + } + } + return null; +} +function findStartInFragments(lang, fragments, startPos, endPos, editorState) { + for (let f of fragments) { + let from = f.from + (f.openStart ? 25 : 0), to = f.to - (f.openEnd ? 25 : 0); + let found = from <= startPos && to > startPos && findState(lang, f.tree, 0 - f.offset, startPos, to), tree; + if (found && found.pos <= endPos && (tree = cutTree(lang, f.tree, startPos + f.offset, found.pos + f.offset, false))) + return { state: found.state, tree }; + } + return { state: lang.streamParser.startState(editorState ? getIndentUnit(editorState) : 4), tree: Tree.empty }; +} +class Parse { + constructor(lang, input, fragments, ranges) { + this.lang = lang; + this.input = input; + this.fragments = fragments; + this.ranges = ranges; + this.stoppedAt = null; + this.chunks = []; + this.chunkPos = []; + this.chunk = []; + this.chunkReused = undefined; + this.rangeIndex = 0; + this.to = ranges[ranges.length - 1].to; + let context = ParseContext.get(), from = ranges[0].from; + let { state, tree } = findStartInFragments(lang, fragments, from, this.to, context === null || context === void 0 ? void 0 : context.state); + this.state = state; + this.parsedPos = this.chunkStart = from + tree.length; + for (let i = 0; i < tree.children.length; i++) { + this.chunks.push(tree.children[i]); + this.chunkPos.push(tree.positions[i]); + } + if (context && this.parsedPos < context.viewport.from - 100000 /* C.MaxDistanceBeforeViewport */ && + ranges.some(r => r.from <= context.viewport.from && r.to >= context.viewport.from)) { + this.state = this.lang.streamParser.startState(getIndentUnit(context.state)); + context.skipUntilInView(this.parsedPos, context.viewport.from); + this.parsedPos = context.viewport.from; + } + this.moveRangeIndex(); + } + advance() { + let context = ParseContext.get(); + let parseEnd = this.stoppedAt == null ? this.to : Math.min(this.to, this.stoppedAt); + let end = Math.min(parseEnd, this.chunkStart + 512 /* C.ChunkSize */); + if (context) + end = Math.min(end, context.viewport.to); + while (this.parsedPos < end) + this.parseLine(context); + if (this.chunkStart < this.parsedPos) + this.finishChunk(); + if (this.parsedPos >= parseEnd) + return this.finish(); + if (context && this.parsedPos >= context.viewport.to) { + context.skipUntilInView(this.parsedPos, parseEnd); + return this.finish(); + } + return null; + } + stopAt(pos) { + this.stoppedAt = pos; + } + lineAfter(pos) { + let chunk = this.input.chunk(pos); + if (!this.input.lineChunks) { + let eol = chunk.indexOf("\n"); + if (eol > -1) + chunk = chunk.slice(0, eol); + } + else if (chunk == "\n") { + chunk = ""; + } + return pos + chunk.length <= this.to ? chunk : chunk.slice(0, this.to - pos); + } + nextLine() { + let from = this.parsedPos, line = this.lineAfter(from), end = from + line.length; + for (let index = this.rangeIndex;;) { + let rangeEnd = this.ranges[index].to; + if (rangeEnd >= end) + break; + line = line.slice(0, rangeEnd - (end - line.length)); + index++; + if (index == this.ranges.length) + break; + let rangeStart = this.ranges[index].from; + let after = this.lineAfter(rangeStart); + line += after; + end = rangeStart + after.length; + } + return { line, end }; + } + skipGapsTo(pos, offset, side) { + for (;;) { + let end = this.ranges[this.rangeIndex].to, offPos = pos + offset; + if (side > 0 ? end > offPos : end >= offPos) + break; + let start = this.ranges[++this.rangeIndex].from; + offset += start - end; + } + return offset; + } + moveRangeIndex() { + while (this.ranges[this.rangeIndex].to < this.parsedPos) + this.rangeIndex++; + } + emitToken(id, from, to, offset) { + let size = 4; + if (this.ranges.length > 1) { + offset = this.skipGapsTo(from, offset, 1); + from += offset; + let len0 = this.chunk.length; + offset = this.skipGapsTo(to, offset, -1); + to += offset; + size += this.chunk.length - len0; + } + let last = this.chunk.length - 4; + if (this.lang.streamParser.mergeTokens && size == 4 && last >= 0 && + this.chunk[last] == id && this.chunk[last + 2] == from) + this.chunk[last + 2] = to; + else + this.chunk.push(id, from, to, size); + return offset; + } + parseLine(context) { + let { line, end } = this.nextLine(), offset = 0, { streamParser } = this.lang; + let stream = new StringStream(line, context ? context.state.tabSize : 4, context ? getIndentUnit(context.state) : 2); + if (stream.eol()) { + streamParser.blankLine(this.state, stream.indentUnit); + } + else { + while (!stream.eol()) { + let token = readToken(streamParser.token, stream, this.state); + if (token) + offset = this.emitToken(this.lang.tokenTable.resolve(token), this.parsedPos + stream.start, this.parsedPos + stream.pos, offset); + if (stream.start > 10000 /* C.MaxLineLength */) + break; + } + } + this.parsedPos = end; + this.moveRangeIndex(); + if (this.parsedPos < this.to) + this.parsedPos++; + } + finishChunk() { + let tree = Tree.build({ + buffer: this.chunk, + start: this.chunkStart, + length: this.parsedPos - this.chunkStart, + nodeSet, + topID: 0, + maxBufferLength: 512 /* C.ChunkSize */, + reused: this.chunkReused + }); + tree = new Tree(tree.type, tree.children, tree.positions, tree.length, [[this.lang.stateAfter, this.lang.streamParser.copyState(this.state)]]); + this.chunks.push(tree); + this.chunkPos.push(this.chunkStart - this.ranges[0].from); + this.chunk = []; + this.chunkReused = undefined; + this.chunkStart = this.parsedPos; + } + finish() { + return new Tree(this.lang.topNode, this.chunks, this.chunkPos, this.parsedPos - this.ranges[0].from).balance(); + } +} +function readToken(token, stream, state) { + stream.start = stream.pos; + for (let i = 0; i < 10; i++) { + let result = token(stream, state); + if (stream.pos > stream.start) + return result; + } + throw new Error("Stream parser failed to advance stream."); +} +const noTokens = /*@__PURE__*/Object.create(null); +const typeArray = [NodeType.none]; +const nodeSet = /*@__PURE__*/new NodeSet(typeArray); +const warned = []; +// Cache of node types by name and tags +const byTag = /*@__PURE__*/Object.create(null); +const defaultTable = /*@__PURE__*/Object.create(null); +for (let [legacyName, name] of [ + ["variable", "variableName"], + ["variable-2", "variableName.special"], + ["string-2", "string.special"], + ["def", "variableName.definition"], + ["tag", "tagName"], + ["attribute", "attributeName"], + ["type", "typeName"], + ["builtin", "variableName.standard"], + ["qualifier", "modifier"], + ["error", "invalid"], + ["header", "heading"], + ["property", "propertyName"] +]) + defaultTable[legacyName] = /*@__PURE__*/createTokenType(noTokens, name); +class TokenTable { + constructor(extra) { + this.extra = extra; + this.table = Object.assign(Object.create(null), defaultTable); + } + resolve(tag) { + return !tag ? 0 : this.table[tag] || (this.table[tag] = createTokenType(this.extra, tag)); + } +} +const defaultTokenTable = /*@__PURE__*/new TokenTable(noTokens); +function warnForPart(part, msg) { + if (warned.indexOf(part) > -1) + return; + warned.push(part); + console.warn(msg); +} +function createTokenType(extra, tagStr) { + let tags$1 = []; + for (let name of tagStr.split(" ")) { + let found = []; + for (let part of name.split(".")) { + let value = (extra[part] || tags[part]); + if (!value) { + warnForPart(part, `Unknown highlighting tag ${part}`); + } + else if (typeof value == "function") { + if (!found.length) + warnForPart(part, `Modifier ${part} used at start of tag`); + else + found = found.map(value); + } + else { + if (found.length) + warnForPart(part, `Tag ${part} used as modifier`); + else + found = Array.isArray(value) ? value : [value]; + } + } + for (let tag of found) + tags$1.push(tag); + } + if (!tags$1.length) + return 0; + let name = tagStr.replace(/ /g, "_"), key = name + " " + tags$1.map(t => t.id); + let known = byTag[key]; + if (known) + return known.id; + let type = byTag[key] = NodeType.define({ + id: typeArray.length, + name, + props: [styleTags({ [name]: tags$1 })] + }); + typeArray.push(type); + return type.id; +} +function docID(data, lang) { + let type = NodeType.define({ id: typeArray.length, name: "Document", props: [ + languageDataProp.add(() => data), + indentNodeProp.add(() => cx => lang.getIndent(cx)) + ], top: true }); + typeArray.push(type); + return type; +} + +function buildForLine(line) { + return line.length <= 4096 && /[\u0590-\u05f4\u0600-\u06ff\u0700-\u08ac\ufb50-\ufdff]/.test(line); +} +function textHasRTL(text) { + for (let i = text.iter(); !i.next().done;) + if (buildForLine(i.value)) + return true; + return false; +} +function changeAddsRTL(change) { + let added = false; + change.iterChanges((fA, tA, fB, tB, ins) => { + if (!added && textHasRTL(ins)) + added = true; + }); + return added; +} +const alwaysIsolate = /*@__PURE__*/Facet.define({ combine: values => values.some(x => x) }); +/** +Make sure nodes +[marked](https://lezer.codemirror.net/docs/ref/#common.NodeProp^isolate) +as isolating for bidirectional text are rendered in a way that +isolates them from the surrounding text. +*/ +function bidiIsolates(options = {}) { + let extensions = [isolateMarks]; + if (options.alwaysIsolate) + extensions.push(alwaysIsolate.of(true)); + return extensions; +} +const isolateMarks = /*@__PURE__*/ViewPlugin.fromClass(class { + constructor(view) { + this.always = view.state.facet(alwaysIsolate) || + view.textDirection != Direction.LTR || + view.state.facet(EditorView.perLineTextDirection); + this.hasRTL = !this.always && textHasRTL(view.state.doc); + this.tree = syntaxTree(view.state); + this.decorations = this.always || this.hasRTL ? buildDeco(view, this.tree, this.always) : Decoration.none; + } + update(update) { + let always = update.state.facet(alwaysIsolate) || + update.view.textDirection != Direction.LTR || + update.state.facet(EditorView.perLineTextDirection); + if (!always && !this.hasRTL && changeAddsRTL(update.changes)) + this.hasRTL = true; + if (!always && !this.hasRTL) + return; + let tree = syntaxTree(update.state); + if (always != this.always || tree != this.tree || update.docChanged || update.viewportChanged) { + this.tree = tree; + this.always = always; + this.decorations = buildDeco(update.view, tree, always); + } + } +}, { + provide: plugin => { + function access(view) { + var _a, _b; + return (_b = (_a = view.plugin(plugin)) === null || _a === void 0 ? void 0 : _a.decorations) !== null && _b !== void 0 ? _b : Decoration.none; + } + return [EditorView.outerDecorations.of(access), + Prec.lowest(EditorView.bidiIsolatedRanges.of(access))]; + } +}); +function buildDeco(view, tree, always) { + let deco = new RangeSetBuilder(); + let ranges = view.visibleRanges; + if (!always) + ranges = clipRTLLines(ranges, view.state.doc); + for (let { from, to } of ranges) { + tree.iterate({ + enter: node => { + let iso = node.type.prop(NodeProp.isolate); + if (iso) + deco.add(node.from, node.to, marks[iso]); + }, + from, to + }); + } + return deco.finish(); +} +function clipRTLLines(ranges, doc) { + let cur = doc.iter(), pos = 0, result = [], last = null; + for (let { from, to } of ranges) { + if (last && last.to > from) { + from = last.to; + if (from >= to) + continue; + } + if (pos + cur.value.length < from) { + cur.next(from - (pos + cur.value.length)); + pos = from; + } + for (;;) { + let start = pos, end = pos + cur.value.length; + if (!cur.lineBreak && buildForLine(cur.value)) { + if (last && last.to > start - 10) + last.to = Math.min(to, end); + else + result.push(last = { from: start, to: Math.min(to, end) }); + } + if (end >= to) + break; + pos = end; + cur.next(); + } + } + return result; +} +const marks = { + rtl: /*@__PURE__*/Decoration.mark({ class: "cm-iso", inclusive: true, attributes: { dir: "rtl" }, bidiIsolate: Direction.RTL }), + ltr: /*@__PURE__*/Decoration.mark({ class: "cm-iso", inclusive: true, attributes: { dir: "ltr" }, bidiIsolate: Direction.LTR }), + auto: /*@__PURE__*/Decoration.mark({ class: "cm-iso", inclusive: true, attributes: { dir: "auto" }, bidiIsolate: null }) +}; + +export { DocInput, HighlightStyle, IndentContext, LRLanguage, Language, LanguageDescription, LanguageSupport, ParseContext, StreamLanguage, StringStream, TreeIndentContext, bidiIsolates, bracketMatching, bracketMatchingHandle, codeFolding, continuedIndent, defaultHighlightStyle, defineLanguageFacet, delimitedIndent, ensureSyntaxTree, flatIndent, foldAll, foldCode, foldEffect, foldGutter, foldInside, foldKeymap, foldNodeProp, foldService, foldState, foldable, foldedRanges, forceParsing, getIndentUnit, getIndentation, highlightingFor, indentNodeProp, indentOnInput, indentRange, indentService, indentString, indentUnit, language, languageDataProp, matchBrackets, sublanguageProp, syntaxHighlighting, syntaxParserRunning, syntaxTree, syntaxTreeAvailable, toggleFold, unfoldAll, unfoldCode, unfoldEffect, viewportPriorityParsing }; diff --git a/vendor/codemirror-language/package.json b/vendor/codemirror-language/package.json new file mode 100644 index 0000000000..560a2db658 --- /dev/null +++ b/vendor/codemirror-language/package.json @@ -0,0 +1,49 @@ +{ + "name": "@codemirror/language", + "version": "6.12.4", + "description": "Language support infrastructure for the CodeMirror code editor", + "scripts": { + "test": "cm-runtests", + "prepare": "cm-buildhelper src/index.ts" + }, + "keywords": [ + "editor", + "code" + ], + "author": { + "name": "Marijn Haverbeke", + "email": "marijn@haverbeke.berlin", + "url": "http://marijnhaverbeke.nl" + }, + "type": "module", + "main": "dist/index.cjs", + "exports": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "types": "dist/index.d.ts", + "module": "dist/index.js", + "sideEffects": false, + "files": [ + "dist", + "CHANGELOG.md", + "UPSTREAM.md" + ], + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + }, + "devDependencies": { + "@codemirror/buildhelper": "^1.0.0", + "@lezer/javascript": "^1.0.0" + }, + "repository": { + "type": "git", + "url": "git+https://code.haverbeke.berlin/codemirror/language.git" + } +} diff --git a/vendor/codemirror-language/src/README.md b/vendor/codemirror-language/src/README.md new file mode 100644 index 0000000000..271ee93ef4 --- /dev/null +++ b/vendor/codemirror-language/src/README.md @@ -0,0 +1,138 @@ +@languageDataProp + +@Language + +@defineLanguageFacet + +@Sublanguage + +@sublanguageProp + +@language + +@LRLanguage + +@ParseContext + +@syntaxTree + +@ensureSyntaxTree + +@syntaxTreeAvailable + +@forceParsing + +@syntaxParserRunning + +@LanguageSupport + +@LanguageDescription + +@DocInput + +### Highlighting + +@HighlightStyle + +@syntaxHighlighting + +@TagStyle + +@defaultHighlightStyle + +@highlightingFor + +@bidiIsolates + +### Folding + +These exports provide commands and other functionality related to code +folding (temporarily hiding pieces of code). + +@foldService + +@foldNodeProp + +@foldInside + +@foldable + +@foldCode + +@unfoldCode + +@toggleFold + +@foldAll + +@unfoldAll + +@foldKeymap + +@codeFolding + +@foldGutter + +The following functions provide more direct, low-level control over +the fold state. + +@foldedRanges + +@foldState + +@foldEffect + +@unfoldEffect + +### Indentation + +@indentService + +@indentNodeProp + +@getIndentation + +@indentRange + +@indentUnit + +@getIndentUnit + +@indentString + +@IndentContext + +@TreeIndentContext + +@delimitedIndent + +@continuedIndent + +@flatIndent + +@indentOnInput + +### Bracket Matching + +@bracketMatching + +@Config + +@matchBrackets + +@MatchResult + +@bracketMatchingHandle + +### Stream Parser + +Stream parsers provide a way to adapt language modes written in the +CodeMirror 5 style (see +[@codemirror/legacy-modes](https://code.haverbeke.berlin/codemirror/legacy-modes)) +to the `Language` interface. + +@StreamLanguage + +@StreamParser + +@StringStream diff --git a/vendor/codemirror-language/src/fold.ts b/vendor/codemirror-language/src/fold.ts new file mode 100644 index 0000000000..4e7ecdfb78 --- /dev/null +++ b/vendor/codemirror-language/src/fold.ts @@ -0,0 +1,477 @@ +import {NodeProp, SyntaxNode, NodeIterator} from "@lezer/common" +import {combineConfig, EditorState, StateEffect, ChangeDesc, Facet, StateField, Extension, + RangeSet, RangeSetBuilder} from "@codemirror/state" +import {EditorView, BlockInfo, Command, Decoration, DecorationSet, WidgetType, + KeyBinding, ViewPlugin, ViewUpdate, gutter, GutterMarker} from "@codemirror/view" +import {language, syntaxTree} from "./language" + +/// A facet that registers a code folding service. When called with +/// the extent of a line, such a function should return a foldable +/// range that starts on that line (but continues beyond it), if one +/// can be found. +export const foldService = Facet.define< + (state: EditorState, lineStart: number, lineEnd: number) => ({from: number, to: number} | null) +>() + +/// This node prop is used to associate folding information with +/// syntax node types. Given a syntax node, it should check whether +/// that tree is foldable and return the range that can be collapsed +/// when it is. +export const foldNodeProp = new NodeProp<(node: SyntaxNode, state: EditorState) => ({from: number, to: number} | null)>() + +/// [Fold](#language.foldNodeProp) function that folds everything but +/// the first and the last child of a syntax node. Useful for nodes +/// that start and end with delimiters. +export function foldInside(node: SyntaxNode): {from: number, to: number} | null { + let first = node.firstChild, last = node.lastChild + return first && first.to < last!.from ? {from: first.to, to: last!.type.isError ? node.to : last!.from} : null +} + +function syntaxFolding(state: EditorState, start: number, end: number) { + let tree = syntaxTree(state) + if (tree.length < end) return null + let stack = tree.resolveStack(end, 1) + let found: null | {from: number, to: number} = null + for (let iter: NodeIterator | null = stack; iter; iter = iter.next) { + let cur = iter.node + if (cur.to <= end || cur.from > end) continue + if (found && cur.from < start) break + let prop = cur.type.prop(foldNodeProp) + if (prop && (cur.to < tree.length - 50 || tree.length == state.doc.length || !isUnfinished(cur))) { + let value = prop(cur, state) + if (value && value.from <= end && value.from >= start && value.to > end) found = value + } + } + return found +} + +function isUnfinished(node: SyntaxNode) { + let ch = node.lastChild + return ch && ch.to == node.to && ch.type.isError +} + +/// Check whether the given line is foldable. First asks any fold +/// services registered through +/// [`foldService`](#language.foldService), and if none of them return +/// a result, tries to query the [fold node +/// prop](#language.foldNodeProp) of syntax nodes that cover the end +/// of the line. +export function foldable(state: EditorState, lineStart: number, lineEnd: number) { + for (let service of state.facet(foldService)) { + let result = service(state, lineStart, lineEnd) + if (result) return result + } + return syntaxFolding(state, lineStart, lineEnd) +} + +type DocRange = {from: number, to: number} + +function mapRange(range: DocRange, mapping: ChangeDesc) { + let from = mapping.mapPos(range.from, 1), to = mapping.mapPos(range.to, -1) + return from >= to ? undefined : {from, to} +} + +/// State effect that can be attached to a transaction to fold the +/// given range. (You probably only need this in exceptional +/// circumstances—usually you'll just want to let +/// [`foldCode`](#language.foldCode) and the [fold +/// gutter](#language.foldGutter) create the transactions.) +export const foldEffect = StateEffect.define({map: mapRange}) + +/// State effect that unfolds the given range (if it was folded). +export const unfoldEffect = StateEffect.define({map: mapRange}) + +function selectedLines(view: EditorView) { + let lines: BlockInfo[] = [] + for (let {head} of view.state.selection.ranges) { + if (lines.some(l => l.from <= head && l.to >= head)) continue + lines.push(view.lineBlockAt(head)) + } + return lines +} + +/// The state field that stores the folded ranges (as a [decoration +/// set](#view.DecorationSet)). Can be passed to +/// [`EditorState.toJSON`](#state.EditorState.toJSON) and +/// [`fromJSON`](#state.EditorState^fromJSON) to serialize the fold +/// state. +export const foldState = StateField.define({ + create() { + return Decoration.none + }, + update(folded, tr) { + if (tr.isUserEvent("delete")) + tr.changes.iterChangedRanges((fromA, toA) => folded = clearTouchedFolds(folded, fromA, toA)) + folded = folded.map(tr.changes) + let rangesToFold = [] + for (let e of tr.effects) { + if (e.is(foldEffect) && !foldExists(folded, e.value.from, e.value.to)) { + rangesToFold.push(e.value) + } else if (e.is(unfoldEffect)) { + folded = folded.update({filter: (from, to) => e.value.from != from || e.value.to != to, + filterFrom: e.value.from, filterTo: e.value.to}) + } + } + if (rangesToFold.length) { + let {preparePlaceholder} = tr.state.facet(foldConfig) + let decorations = rangesToFold.map(value => { + let widget = !preparePlaceholder ? foldWidget : + Decoration.replace({widget: new PreparedFoldWidget(preparePlaceholder(tr.state, value))}) + return widget.range(value.from, value.to) + }) + folded = folded.update({add: decorations}) + } + // Clear folded ranges that cover the selection head + if (tr.selection) folded = clearTouchedFolds(folded, tr.selection.main.head) + return folded + }, + provide: f => EditorView.decorations.from(f), + toJSON(folded, state) { + let ranges: number[] = [] + folded.between(0, state.doc.length, (from, to) => {ranges.push(from, to)}) + return ranges + }, + fromJSON(value) { + if (!Array.isArray(value) || value.length % 2) throw new RangeError("Invalid JSON for fold state") + let ranges = [] + for (let i = 0; i < value.length;) { + let from = value[i++], to = value[i++] + if (typeof from != "number" || typeof to != "number") throw new RangeError("Invalid JSON for fold state") + ranges.push(foldWidget.range(from, to)) + } + return Decoration.set(ranges, true) + } +}) + +function clearTouchedFolds(folded: DecorationSet, from: number, to = from) { + let touched = false + folded.between(from, to, (a, b) => { if (a < to && b > from) touched = true }) + return !touched ? folded : folded.update({ + filterFrom: from, + filterTo: to, + filter: (a, b) => a >= to || b <= from + }) +} + +/// Get a [range set](#state.RangeSet) containing the folded ranges +/// in the given state. +export function foldedRanges(state: EditorState): DecorationSet { + return state.field(foldState, false) || RangeSet.empty +} + +function findFold(state: EditorState, from: number, to: number) { + let found: {from: number, to: number} | null = null + state.field(foldState, false)?.between(from, to, (from, to) => { + if (!found || found.from > from) found = {from, to} + }) + return found +} + +function foldExists(folded: DecorationSet, from: number, to: number) { + let found = false + folded.between(from, from, (a, b) => { if (a == from && b == to) found = true }) + return found +} + +function maybeEnable(state: EditorState, other: readonly StateEffect[]) { + return state.field(foldState, false) ? other : other.concat(StateEffect.appendConfig.of(codeFolding())) +} + +/// Fold the lines that are selected, if possible. +export const foldCode: Command = view => { + for (let line of selectedLines(view)) { + let range = foldable(view.state, line.from, line.to) + if (range) { + view.dispatch({effects: maybeEnable(view.state, [foldEffect.of(range), announceFold(view, range)])}) + return true + } + } + return false +} + +/// Unfold folded ranges on selected lines. +export const unfoldCode: Command = view => { + if (!view.state.field(foldState, false)) return false + let effects = [] + for (let line of selectedLines(view)) { + let folded = findFold(view.state, line.from, line.to) + if (folded) effects.push(unfoldEffect.of(folded), announceFold(view, folded, false)) + } + if (effects.length) view.dispatch({effects}) + return effects.length > 0 +} + +function announceFold(view: EditorView, range: {from: number, to: number}, fold = true) { + let lineFrom = view.state.doc.lineAt(range.from).number, lineTo = view.state.doc.lineAt(range.to).number + return EditorView.announce.of(`${view.state.phrase(fold ? "Folded lines" : "Unfolded lines")} ${lineFrom} ${ + view.state.phrase("to")} ${lineTo}.`) +} + +/// Fold all top-level foldable ranges. Note that, in most cases, +/// folding information will depend on the [syntax +/// tree](#language.syntaxTree), and folding everything may not work +/// reliably when the document hasn't been fully parsed (either +/// because the editor state was only just initialized, or because the +/// document is so big that the parser decided not to parse it +/// entirely). +export const foldAll: Command = view => { + let {state} = view, effects = [] + for (let pos = 0; pos < state.doc.length;) { + let line = view.lineBlockAt(pos), range = foldable(state, line.from, line.to) + if (range) effects.push(foldEffect.of(range)) + pos = (range ? view.lineBlockAt(range.to) : line).to + 1 + } + if (effects.length) view.dispatch({effects: maybeEnable(view.state, effects)}) + return !!effects.length +} + +/// Unfold all folded code. +export const unfoldAll: Command = view => { + let field = view.state.field(foldState, false) + if (!field || !field.size) return false + let effects: StateEffect[] = [] + field.between(0, view.state.doc.length, (from, to) => { effects.push(unfoldEffect.of({from, to})) }) + view.dispatch({effects}) + return true +} + +// Find the foldable region containing the given line, if one exists +function foldableContainer(view: EditorView, lineBlock: BlockInfo) { + // Look backwards through line blocks until we find a foldable region that + // intersects with the line + for (let line = lineBlock;;) { + let foldableRegion = foldable(view.state, line.from, line.to) + if (foldableRegion && foldableRegion.to > lineBlock.from) return foldableRegion + if (!line.from) return null + line = view.lineBlockAt(line.from - 1) + } +} + +/// Toggle folding at cursors. Unfolds if there is an existing fold +/// starting in that line, tries to find a foldable range around it +/// otherwise. +export const toggleFold: Command = (view) => { + let effects: StateEffect[] = [] + for (let line of selectedLines(view)) { + let folded = findFold(view.state, line.from, line.to) + if (folded) { + effects.push(unfoldEffect.of(folded), announceFold(view, folded, false)) + } else { + let foldRange = foldableContainer(view, line) + if (foldRange) effects.push(foldEffect.of(foldRange), announceFold(view, foldRange)) + } + } + if (effects.length > 0) view.dispatch({effects: maybeEnable(view.state, effects)}) + return !!effects.length +} + +/// Default fold-related key bindings. +/// +/// - Ctrl-Shift-[ (Cmd-Alt-[ on macOS): [`foldCode`](#language.foldCode). +/// - Ctrl-Shift-] (Cmd-Alt-] on macOS): [`unfoldCode`](#language.unfoldCode). +/// - Ctrl-Alt-[: [`foldAll`](#language.foldAll). +/// - Ctrl-Alt-]: [`unfoldAll`](#language.unfoldAll). +export const foldKeymap: readonly KeyBinding[] = [ + {key: "Ctrl-Shift-[", mac: "Cmd-Alt-[", run: foldCode}, + {key: "Ctrl-Shift-]", mac: "Cmd-Alt-]", run: unfoldCode}, + {key: "Ctrl-Alt-[", run: foldAll}, + {key: "Ctrl-Alt-]", run: unfoldAll} +] + +interface FoldConfig { + /// A function that creates the DOM element used to indicate the + /// position of folded code. The `onclick` argument is the default + /// click event handler, which toggles folding on the line that + /// holds the element, and should probably be added as an event + /// handler to the returned element. If + /// [`preparePlaceholder`](#language.FoldConfig.preparePlaceholder) + /// is given, its result will be passed as 3rd argument. Otherwise, + /// this will be null. + /// + /// When this option isn't given, the `placeholderText` option will + /// be used to create the placeholder element. + placeholderDOM?: ((view: EditorView, onclick: (event: Event) => void, prepared: any) => HTMLElement) | null, + /// Text to use as placeholder for folded text. Defaults to `"…"`. + /// Will be styled with the `"cm-foldPlaceholder"` class. + placeholderText?: string + /// Given a range that is being folded, create a value that + /// describes it, to be used by `placeholderDOM` to render a custom + /// widget that, for example, indicates something about the folded + /// range's size or type. + preparePlaceholder?: (state: EditorState, range: {from: number, to: number}) => any +} + +const defaultConfig: Required = { + placeholderDOM: null, + preparePlaceholder: null as any, + placeholderText: "…" +} + +const foldConfig = Facet.define>({ + combine(values) { return combineConfig(values, defaultConfig) } +}) + +/// Create an extension that configures code folding. +export function codeFolding(config?: FoldConfig): Extension { + let result = [foldState, baseTheme] + if (config) result.push(foldConfig.of(config)) + return result +} + +function widgetToDOM(view: EditorView, prepared: any) { + let {state} = view, conf = state.facet(foldConfig) + let onclick = (event: Event) => { + let line = view.lineBlockAt(view.posAtDOM(event.target as HTMLElement)) + let folded = findFold(view.state, line.from, line.to) + if (folded) view.dispatch({effects: unfoldEffect.of(folded)}) + event.preventDefault() + } + if (conf.placeholderDOM) return conf.placeholderDOM(view, onclick, prepared) + let element = document.createElement("span") + element.textContent = conf.placeholderText + element.setAttribute("aria-label", state.phrase("folded code")) + element.title = state.phrase("unfold") + element.className = "cm-foldPlaceholder" + element.onclick = onclick + return element +} + +const foldWidget = Decoration.replace({widget: new class extends WidgetType { + toDOM(view: EditorView) { return widgetToDOM(view, null) } +}}) + +class PreparedFoldWidget extends WidgetType { + constructor(readonly value: any) { super() } + eq(other: PreparedFoldWidget) { return this.value == other.value } + toDOM(view: EditorView) { return widgetToDOM(view, this.value) } +} + +type Handlers = {[event: string]: (view: EditorView, line: BlockInfo, event: Event) => boolean} + +interface FoldGutterConfig { + /// A function that creates the DOM element used to indicate a + /// given line is folded or can be folded. + /// When not given, the `openText`/`closeText` option will be used instead. + markerDOM?: ((open: boolean) => HTMLElement) | null + /// Text used to indicate that a given line can be folded. + /// Defaults to `"⌄"`. + openText?: string + /// Text used to indicate that a given line is folded. + /// Defaults to `"›"`. + closedText?: string + /// Supply event handlers for DOM events on this gutter. + domEventHandlers?: Handlers + /// When given, if this returns true for a given view update, + /// recompute the fold markers. + foldingChanged?: (update: ViewUpdate) => boolean +} + +const foldGutterDefaults: Required = { + openText: "⌄", + closedText: "›", + markerDOM: null, + domEventHandlers: {}, + foldingChanged: () => false +} + +class FoldMarker extends GutterMarker { + constructor(readonly config: Required, + readonly open: boolean) { super() } + + eq(other: FoldMarker) { return this.config == other.config && this.open == other.open } + + toDOM(view: EditorView) { + if (this.config.markerDOM) return this.config.markerDOM(this.open) + + let span = document.createElement("span") + span.textContent = this.open ? this.config.openText : this.config.closedText + span.title = view.state.phrase(this.open ? "Fold line" : "Unfold line") + return span + } +} + +/// Create an extension that registers a fold gutter, which shows a +/// fold status indicator before foldable lines (which can be clicked +/// to fold or unfold the line). +export function foldGutter(config: FoldGutterConfig = {}): Extension { + let fullConfig = {...foldGutterDefaults, ...config} + let canFold = new FoldMarker(fullConfig, true), canUnfold = new FoldMarker(fullConfig, false) + + let markers = ViewPlugin.fromClass(class { + markers: RangeSet + from: number + + constructor(view: EditorView) { + this.from = view.viewport.from + this.markers = this.buildMarkers(view) + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged || + update.startState.facet(language) != update.state.facet(language) || + update.startState.field(foldState, false) != update.state.field(foldState, false) || + syntaxTree(update.startState) != syntaxTree(update.state) || + fullConfig.foldingChanged(update)) + this.markers = this.buildMarkers(update.view) + } + + buildMarkers(view: EditorView) { + let builder = new RangeSetBuilder() + for (let line of view.viewportLineBlocks) { + let mark = findFold(view.state, line.from, line.to) ? canUnfold + : foldable(view.state, line.from, line.to) ? canFold : null + if (mark) builder.add(line.from, line.from, mark) + } + return builder.finish() + } + }) + + let { domEventHandlers } = fullConfig; + + return [ + markers, + gutter({ + class: "cm-foldGutter", + markers(view) { return view.plugin(markers)?.markers || RangeSet.empty }, + initialSpacer() { + return new FoldMarker(fullConfig, false) + }, + domEventHandlers: { + ...domEventHandlers, + click: (view, line, event) => { + if (domEventHandlers.click && domEventHandlers.click(view, line, event)) return true + + let folded = findFold(view.state, line.from, line.to) + if (folded) { + view.dispatch({effects: unfoldEffect.of(folded)}) + return true + } + let range = foldable(view.state, line.from, line.to) + if (range) { + view.dispatch({effects: foldEffect.of(range)}) + return true + } + return false + } + } + }), + codeFolding() + ] +} + +const baseTheme = EditorView.baseTheme({ + ".cm-foldPlaceholder": { + backgroundColor: "#eee", + border: "1px solid #ddd", + color: "#888", + borderRadius: ".2em", + margin: "0 1px", + padding: "0 1px", + cursor: "pointer" + }, + + ".cm-foldGutter span": { + padding: "0 1px", + cursor: "pointer" + } +}) diff --git a/vendor/codemirror-language/src/highlight.ts b/vendor/codemirror-language/src/highlight.ts new file mode 100644 index 0000000000..0459e2d925 --- /dev/null +++ b/vendor/codemirror-language/src/highlight.ts @@ -0,0 +1,419 @@ +import {Tree, NodeType} from "@lezer/common" +import {Tag, tags, tagHighlighter, Highlighter, highlightTree} from "@lezer/highlight" +import {StyleSpec, StyleModule} from "style-mod" +import {EditorView, ViewPlugin, ViewUpdate, Decoration, DecorationSet} from "@codemirror/view" +import {EditorState, Prec, Facet, Extension, RangeSetBuilder} from "@codemirror/state" +import {syntaxTree, Language, language, languageDataProp, viewportPriorityTree, viewportPriorityTrees, + viewportPriorityParsingFailed, viewportPriorityParsingPending, + ViewportPriorityTree} from "./language" + +/// A highlight style associates CSS styles with highlighting +/// [tags](https://lezer.codemirror.net/docs/ref#highlight.Tag). +export class HighlightStyle implements Highlighter { + /// A style module holding the CSS rules for this highlight style. + /// When using + /// [`highlightTree`](https://lezer.codemirror.net/docs/ref#highlight.highlightTree) + /// outside of the editor, you may want to manually mount this + /// module to show the highlighting. + readonly module: StyleModule | null + + /// @internal + readonly themeType: "dark" | "light" | undefined + + readonly style: (tags: readonly Tag[]) => string | null + readonly scope?: (type: NodeType) => boolean + + private constructor( + /// The tag styles used to create this highlight style. + readonly specs: readonly TagStyle[], + options: {scope?: NodeType | Language, all?: string | StyleSpec, themeType?: "dark" | "light"} + ) { + let modSpec: {[name: string]: StyleSpec} | undefined + function def(spec: StyleSpec) { + let cls = StyleModule.newName() + ;(modSpec || (modSpec = Object.create(null)))["." + cls] = spec + return cls + } + + const all = typeof options.all == "string" ? options.all : options.all ? def(options.all) : undefined + + const scopeOpt = options.scope + this.scope = scopeOpt instanceof Language ? (type: NodeType) => type.prop(languageDataProp) == scopeOpt.data + : scopeOpt ? (type: NodeType) => type == scopeOpt : undefined + + this.style = tagHighlighter(specs.map(style => ({ + tag: style.tag, + class: style.class as string || def(Object.assign({}, style, {tag: null})) + })), { + all, + }).style + + this.module = modSpec ? new StyleModule(modSpec) : null + this.themeType = options.themeType + } + + /// Create a highlighter style that associates the given styles to + /// the given tags. The specs must be objects that hold a style tag + /// or array of tags in their `tag` property, and either a single + /// `class` property providing a static CSS class (for highlighter + /// that rely on external styling), or a + /// [`style-mod`](https://code.haverbeke.berlin/marijn/style-mod#documentation)-style + /// set of CSS properties (which define the styling for those tags). + /// + /// The CSS rules created for a highlighter will be emitted in the + /// order of the spec's properties. That means that for elements that + /// have multiple tags associated with them, styles defined further + /// down in the list will have a higher CSS precedence than styles + /// defined earlier. + static define(specs: readonly TagStyle[], options?: { + /// By default, highlighters apply to the entire document. You can + /// scope them to a single language by providing the language + /// object or a language's top node type here. + scope?: Language | NodeType, + /// Add a style to _all_ content. Probably only useful in + /// combination with `scope`. + all?: string | StyleSpec, + /// Specify that this highlight style should only be active then + /// the theme is dark or light. By default, it is active + /// regardless of theme. + themeType?: "dark" | "light" + }) { + return new HighlightStyle(specs, options || {}) + } +} + +const highlighterFacet = Facet.define() + +const fallbackHighlighter = Facet.define({ + combine(values) { return values.length ? [values[0]] : null } +}) + +function getHighlighters(state: EditorState): readonly Highlighter[] | null { + let main = state.facet(highlighterFacet) + return main.length ? main : state.facet(fallbackHighlighter) +} + +/// Wrap a highlighter in an editor extension that uses it to apply +/// syntax highlighting to the editor content. +/// +/// When multiple (non-fallback) styles are provided, the styling +/// applied is the union of the classes they emit. +export function syntaxHighlighting(highlighter: Highlighter, options?: { + /// When enabled, this marks the highlighter as a fallback, which + /// only takes effect if no other highlighters are registered. + fallback: boolean +}): Extension { + let ext: Extension[] = [treeHighlighter], themeType: string | undefined + if (highlighter instanceof HighlightStyle) { + if (highlighter.module) ext.push(EditorView.styleModule.of(highlighter.module)) + themeType = highlighter.themeType + } + if (options?.fallback) + ext.push(fallbackHighlighter.of(highlighter)) + else if (themeType) + ext.push(highlighterFacet.computeN([EditorView.darkTheme], state => { + return state.facet(EditorView.darkTheme) == (themeType == "dark") ? [highlighter] : [] + })) + else + ext.push(highlighterFacet.of(highlighter)) + return ext +} + +/// Returns the CSS classes (if any) that the highlighters active in +/// the state would assign to the given style +/// [tags](https://lezer.codemirror.net/docs/ref#highlight.Tag) and +/// (optional) language +/// [scope](#language.HighlightStyle^define^options.scope). +export function highlightingFor(state: EditorState, tags: readonly Tag[], scope?: NodeType): string | null { + let highlighters = getHighlighters(state) + let result = null + if (highlighters) for (let highlighter of highlighters) { + if (!highlighter.scope || scope && highlighter.scope(scope)) { + let cls = highlighter.style(tags) + if (cls) result = result ? result + " " + cls : cls + } + } + return result +} + +/// The type of object used in +/// [`HighlightStyle.define`](#language.HighlightStyle^define). +/// Assigns a style to one or more highlighting +/// [tags](https://lezer.codemirror.net/docs/ref#highlight.Tag), which can either be a fixed class name +/// (which must be defined elsewhere), or a set of CSS properties, for +/// which the library will define an anonymous class. +export interface TagStyle { + /// The tag or tags to target. + tag: Tag | readonly Tag[], + /// If given, this maps the tags to a fixed class name. + class?: string, + /// Any further properties (if `class` isn't given) will be + /// interpreted as in style objects given to + /// [style-mod](https://code.haverbeke.berlin/marijn/style-mod#documentation). + /// (The type here is `any` because of TypeScript limitations.) + [styleProperty: string]: any +} + +class TreeHighlighter { + decorations: DecorationSet + decoratedFrom: number + decoratedTo: number + tree: Tree + markCache: {[cls: string]: Decoration} = Object.create(null) + provisionalVersion = -1 + usedProvisional = false + mappedFallback = false + fallbackExpired = false + fallbackGeneration = 0 + fallbackTimer = -1 + + constructor(readonly view: EditorView) { + this.tree = syntaxTree(view.state) + this.decorations = this.buildDeco(view, getHighlighters(view.state)) + this.decoratedFrom = view.viewport.from + this.decoratedTo = view.viewport.to + let provisional = viewportPriorityTree(view) + this.provisionalVersion = provisional?.version ?? -1 + this.usedProvisional = !!provisional + this.publishControlledCoverage(view) + } + + update(update: ViewUpdate) { + let tree = syntaxTree(update.state), highlighters = getHighlighters(update.state) + let styleChange = highlighters != getHighlighters(update.startState) + let provisional = viewportPriorityTree(update.view) + let provisionalFailed = viewportPriorityParsingFailed(update.view) + let provisionalVersion = provisional?.version ?? -1 + let {viewport} = update.view + let decoratedFromMapped = update.changes.mapPos(this.decoratedFrom, -1) + let decoratedToMapped = update.changes.mapPos(this.decoratedTo, 1) + let sameLanguage = update.startState.facet(language) == update.state.facet(language) + let retainMapped = retainMappedHighlighting( + update.docChanged, this.usedProvisional, this.fallbackExpired, + sameLanguage, styleChange, + tree.length, viewport.from, viewport.to, decoratedFromMapped, decoratedToMapped) + if (retainMapped) { + this.tree = tree + this.decorations = this.decorations.map(update.changes) + this.decoratedFrom = decoratedFromMapped + this.decoratedTo = decoratedToMapped + this.provisionalVersion = -1 + this.usedProvisional = this.mappedFallback = true + this.scheduleFallbackExpiry(update.view) + } else if (retainMappedHighlightingWhileParsing( + this.mappedFallback, this.fallbackExpired, sameLanguage, styleChange, + !!provisional, provisionalFailed, tree.length, viewport.from, viewport.to, + decoratedFromMapped, decoratedToMapped)) { + // Canonical parsing may publish several incomplete trees while an edit's + // replacement provisional tree is still pending. Keep the mapped visual + // state through those updates instead of rebuilding a plain viewport. + this.tree = tree + if (!update.changes.empty) this.decorations = this.decorations.map(update.changes) + this.decoratedFrom = decoratedFromMapped + this.decoratedTo = decoratedToMapped + } else if (retainHighlightingDuringControlledPreparation( + !!(update.view as any).viewState?.controlledScrollActive, + this.usedProvisional, sameLanguage, styleChange, !!provisional, + provisionalFailed, tree.length, viewport.to)) { + // A rolling corridor first expands the DOM, then publishes its + // provisional parse. Retain decorations for the already safe range so + // preparing the next chunk cannot flash the current viewport plain. + this.tree = tree + } else if (!this.mappedFallback && tree.length < viewport.to && !styleChange && + provisionalVersion == this.provisionalVersion && tree.type == this.tree.type && + decoratedFromMapped <= viewport.from && decoratedToMapped >= viewport.to) { + this.decorations = this.decorations.map(update.changes) + this.decoratedFrom = decoratedFromMapped + this.decoratedTo = decoratedToMapped + } else if (this.mappedFallback && this.fallbackExpired || + provisionalFailed || + tree != this.tree || update.viewportChanged || styleChange || + provisionalVersion != this.provisionalVersion) { + this.tree = tree + this.decorations = this.buildDeco(update.view, highlighters) + this.decoratedFrom = viewport.from + this.decoratedTo = viewport.to + this.provisionalVersion = provisionalVersion + this.usedProvisional = !!provisional + this.clearFallback() + } + this.publishControlledCoverage( + update.view, styleChange || !sameLanguage || provisionalFailed) + } + + private scheduleFallbackExpiry(view: EditorView) { + if (this.fallbackTimer > -1) clearTimeout(this.fallbackTimer) + let generation = ++this.fallbackGeneration + this.fallbackExpired = false + this.fallbackTimer = setTimeout(() => { + this.fallbackTimer = -1 + if (generation != this.fallbackGeneration || !this.mappedFallback) return + if (viewportPriorityParsingPending(view)) { + this.scheduleFallbackExpiry(view) + return + } + this.fallbackExpired = true + view.dispatch({}) + }, 5000) as any + } + + private clearFallback() { + this.mappedFallback = this.fallbackExpired = false + this.fallbackGeneration++ + if (this.fallbackTimer > -1) clearTimeout(this.fallbackTimer) + this.fallbackTimer = -1 + } + + private publishControlledCoverage(view: EditorView, invalidate = false) { + let controller = (view as any).controlledTouchScrollController + if (!view.state.facet(language) || !getHighlighters(view.state)) { + controller?.onHighlightCoverageRemoved?.() + return + } + let {viewport} = view + let coveredTo = Math.max(viewport.from, this.tree.length) + for (let tree of viewportPriorityTrees(view)) { + if (tree.from <= coveredTo) coveredTo = Math.max(coveredTo, tree.to) + } + let complete = coveredTo >= viewport.to || + (this.mappedFallback && this.decoratedFrom <= viewport.from && this.decoratedTo >= viewport.to) + if (complete) controller?.onHighlightCoverageReady?.() + else if (invalidate) controller?.onHighlightCoverageInvalidated?.() + } + + destroy() { + this.clearFallback() + ;(this.view as any).controlledTouchScrollController?.onHighlightCoverageRemoved?.() + } + + buildDeco(view: EditorView, highlighters: readonly Highlighter[] | null) { + if (!highlighters) return Decoration.none + + let builder = new RangeSetBuilder() + let provisionalTrees = viewportPriorityTrees(view) + for (let {from, to} of view.visibleRanges) { + let canonicalTo = Math.min(to, this.tree.length) + if (from < canonicalTo) this.addTreeHighlights(builder, this.tree, highlighters, from, canonicalTo) + for (let provisional of provisionalTrees) { + let priority = provisionalHighlightRange(from, to, this.tree.length, provisional.from, provisional.to) + if (priority) + this.addTreeHighlights(builder, provisional.tree, outerLanguageHighlighters(provisional, highlighters), + priority.from - provisional.from, priority.to - provisional.from, + provisional.from) + } + } + return builder.finish() + } + + private addTreeHighlights(builder: RangeSetBuilder, tree: Tree, + highlighters: readonly Highlighter[], from: number, to: number, + offset = 0) { + highlightTree(tree, highlighters, (from, to, style) => { + builder.add(from + offset, to + offset, + this.markCache[style] || (this.markCache[style] = Decoration.mark({class: style}))) + }, from, to) + } +} + +/// @internal +export function retainMappedHighlighting(docChanged: boolean, usedProvisional: boolean, + fallbackExpired: boolean, sameLanguage: boolean, + styleChanged: boolean, canonicalLength: number, + viewportFrom: number, viewportTo: number, + decoratedFrom: number, decoratedTo: number) { + return docChanged && usedProvisional && !fallbackExpired && sameLanguage && !styleChanged && + canonicalLength < viewportTo && decoratedFrom <= viewportFrom && decoratedTo >= viewportTo +} + +/// @internal +export function retainMappedHighlightingWhileParsing(mappedFallback: boolean, + fallbackExpired: boolean, + sameLanguage: boolean, + styleChanged: boolean, + hasPublishedProvisional: boolean, + provisionalFailed: boolean, + canonicalLength: number, + viewportFrom: number, viewportTo: number, + decoratedFrom: number, decoratedTo: number) { + return mappedFallback && !fallbackExpired && sameLanguage && !styleChanged && + !hasPublishedProvisional && !provisionalFailed && canonicalLength < viewportTo && + decoratedFrom <= viewportFrom && decoratedTo >= viewportTo +} + +/// @internal +export function retainHighlightingDuringControlledPreparation(controlledActive: boolean, + usedProvisional: boolean, + sameLanguage: boolean, + styleChanged: boolean, + hasPublishedProvisional: boolean, + provisionalFailed: boolean, + canonicalLength: number, + viewportTo: number) { + return controlledActive && usedProvisional && sameLanguage && !styleChanged && + !hasPublishedProvisional && !provisionalFailed && canonicalLength < viewportTo +} + +/// @internal +export function provisionalHighlightRange(from: number, to: number, canonicalLength: number, + provisionalFrom: number, provisionalTo: number) { + let priorityFrom = Math.max(from, Math.min(to, canonicalLength), provisionalFrom) + let priorityTo = Math.min(to, provisionalTo) + return priorityFrom < priorityTo ? {from: priorityFrom, to: priorityTo} : null +} + +/// @internal +export function outerLanguageHighlighters(provisional: ViewportPriorityTree, + highlighters: readonly Highlighter[]): readonly Highlighter[] { + let top = provisional.tree.topNode.type + return highlighters.map(highlighter => ({ + style: highlighter.style, + scope: (type: NodeType) => type == top && (!highlighter.scope || highlighter.scope(type)) + })) +} + +const treeHighlighter = Prec.high(ViewPlugin.fromClass(TreeHighlighter, { + decorations: v => v.decorations +})) + +/// A default highlight style (works well with light themes). +export const defaultHighlightStyle = HighlightStyle.define([ + {tag: tags.meta, + color: "#404740"}, + {tag: tags.link, + textDecoration: "underline"}, + {tag: tags.heading, + textDecoration: "underline", + fontWeight: "bold"}, + {tag: tags.emphasis, + fontStyle: "italic"}, + {tag: tags.strong, + fontWeight: "bold"}, + {tag: tags.strikethrough, + textDecoration: "line-through"}, + {tag: tags.keyword, + color: "#708"}, + {tag: [tags.atom, tags.bool, tags.url, tags.contentSeparator, tags.labelName], + color: "#219"}, + {tag: [tags.literal, tags.inserted], + color: "#164"}, + {tag: [tags.string, tags.deleted], + color: "#a11"}, + {tag: [tags.regexp, tags.escape, tags.special(tags.string)], + color: "#e40"}, + {tag: tags.definition(tags.variableName), + color: "#00f"}, + {tag: tags.local(tags.variableName), + color: "#30a"}, + {tag: [tags.typeName, tags.namespace], + color: "#085"}, + {tag: tags.className, + color: "#167"}, + {tag: [tags.special(tags.variableName), tags.macroName], + color: "#256"}, + {tag: tags.definition(tags.propertyName), + color: "#00c"}, + {tag: tags.comment, + color: "#940"}, + {tag: tags.invalid, + color: "#f00"} +]) diff --git a/vendor/codemirror-language/src/indent.ts b/vendor/codemirror-language/src/indent.ts new file mode 100644 index 0000000000..6d5407edac --- /dev/null +++ b/vendor/codemirror-language/src/indent.ts @@ -0,0 +1,396 @@ +import {NodeProp, SyntaxNode, NodeIterator, Tree} from "@lezer/common" +import {EditorState, Extension, Facet, countColumn, ChangeSpec} from "@codemirror/state" +import {syntaxTree} from "./language" + +/// Facet that defines a way to provide a function that computes the +/// appropriate indentation depth, as a column number (see +/// [`indentString`](#language.indentString)), at the start of a given +/// line. A return value of `null` indicates no indentation can be +/// determined, and the line should inherit the indentation of the one +/// above it. A return value of `undefined` defers to the next indent +/// service. +export const indentService = Facet.define<(context: IndentContext, pos: number) => number | null | undefined>() + +/// Facet for overriding the unit by which indentation happens. Should +/// be a string consisting entirely of the same whitespace character. +/// When not set, this defaults to 2 spaces. +export const indentUnit = Facet.define({ + combine: values => { + if (!values.length) return " " + let unit = values[0] + if (!unit || /\S/.test(unit) || Array.from(unit).some(e => e != unit[0])) + throw new Error("Invalid indent unit: " + JSON.stringify(values[0])) + return unit + } +}) + +/// Return the _column width_ of an indent unit in the state. +/// Determined by the [`indentUnit`](#language.indentUnit) +/// facet, and [`tabSize`](#state.EditorState^tabSize) when that +/// contains tabs. +export function getIndentUnit(state: EditorState) { + let unit = state.facet(indentUnit) + return unit.charCodeAt(0) == 9 ? state.tabSize * unit.length : unit.length +} + +/// Create an indentation string that covers columns 0 to `cols`. +/// Will use tabs for as much of the columns as possible when the +/// [`indentUnit`](#language.indentUnit) facet contains +/// tabs. +export function indentString(state: EditorState, cols: number) { + let result = "", ts = state.tabSize, ch = state.facet(indentUnit)[0] + if (ch == "\t") { + while (cols >= ts) { + result += "\t" + cols -= ts + } + ch = " " + } + for (let i = 0; i < cols; i++) result += ch + return result +} + +/// Get the indentation, as a column number, at the given position. +/// Will first consult any [indent services](#language.indentService) +/// that are registered, and if none of those return an indentation, +/// this will check the syntax tree for the [indent node +/// prop](#language.indentNodeProp) and use that if found. Returns a +/// number when an indentation could be determined, and null +/// otherwise. +export function getIndentation(context: IndentContext | EditorState, pos: number): number | null { + if (context instanceof EditorState) context = new IndentContext(context) + for (let service of context.state.facet(indentService)) { + let result = service(context, pos) + if (result !== undefined) return result + } + let tree = syntaxTree(context.state) + return tree.length >= pos ? syntaxIndentation(context, tree, pos) : null +} + +/// Create a change set that auto-indents all lines touched by the +/// given document range. +export function indentRange(state: EditorState, from: number, to: number) { + let updated: {[lineStart: number]: number} = Object.create(null) + let context = new IndentContext(state, {overrideIndentation: start => updated[start] ?? -1}) + let changes: ChangeSpec[] = [] + for (let pos = from; pos <= to;) { + let line = state.doc.lineAt(pos) + pos = line.to + 1 + let indent = getIndentation(context, line.from) + if (indent == null) continue + if (!/\S/.test(line.text)) indent = 0 + let cur = /^\s*/.exec(line.text)![0] + let norm = indentString(state, indent) + if (cur != norm) { + updated[line.from] = indent + changes.push({from: line.from, to: line.from + cur.length, insert: norm}) + } + } + return state.changes(changes) +} + +/// Indentation contexts are used when calling [indentation +/// services](#language.indentService). They provide helper utilities +/// useful in indentation logic, and can selectively override the +/// indentation reported for some lines. +export class IndentContext { + /// The indent unit (number of columns per indentation level). + unit: number + + /// Create an indent context. + constructor( + /// The editor state. + readonly state: EditorState, + /// @internal + readonly options: { + /// Override line indentations provided to the indentation + /// helper function, which is useful when implementing region + /// indentation, where indentation for later lines needs to refer + /// to previous lines, which may have been reindented compared to + /// the original start state. If given, this function should + /// return -1 for lines (given by start position) that didn't + /// change, and an updated indentation otherwise. + overrideIndentation?: (pos: number) => number, + /// Make it look, to the indent logic, like a line break was + /// added at the given position (which is mostly just useful for + /// implementing something like + /// [`insertNewlineAndIndent`](#commands.insertNewlineAndIndent)). + simulateBreak?: number, + /// When `simulateBreak` is given, this can be used to make the + /// simulated break behave like a double line break. + simulateDoubleBreak?: boolean + } = {} + ) { + this.unit = getIndentUnit(state) + } + + /// Get a description of the line at the given position, taking + /// [simulated line + /// breaks](#language.IndentContext.constructor^options.simulateBreak) + /// into account. If there is such a break at `pos`, the `bias` + /// argument determines whether the part of the line line before or + /// after the break is used. + lineAt(pos: number, bias: -1 | 1 = 1): {text: string, from: number} { + let line = this.state.doc.lineAt(pos) + let {simulateBreak, simulateDoubleBreak} = this.options + if (simulateBreak != null && simulateBreak >= line.from && simulateBreak <= line.to) { + if (simulateDoubleBreak && simulateBreak == pos) + return {text: "", from: pos} + else if (bias < 0 ? simulateBreak < pos : simulateBreak <= pos) + return {text: line.text.slice(simulateBreak - line.from), from: simulateBreak} + else + return {text: line.text.slice(0, simulateBreak - line.from), from: line.from} + } + return line + } + + /// Get the text directly after `pos`, either the entire line + /// or the next 100 characters, whichever is shorter. + textAfterPos(pos: number, bias: -1 | 1 = 1) { + if (this.options.simulateDoubleBreak && pos == this.options.simulateBreak) return "" + let {text, from} = this.lineAt(pos, bias) + return text.slice(pos - from, Math.min(text.length, pos + 100 - from)) + } + + /// Find the column for the given position. + column(pos: number, bias: -1 | 1 = 1) { + let {text, from} = this.lineAt(pos, bias) + let result = this.countColumn(text, pos - from) + let override = this.options.overrideIndentation ? this.options.overrideIndentation(from) : -1 + if (override > -1) result += override - this.countColumn(text, text.search(/\S|$/)) + return result + } + + /// Find the column position (taking tabs into account) of the given + /// position in the given string. + countColumn(line: string, pos: number = line.length) { + return countColumn(line, this.state.tabSize, pos) + } + + /// Find the indentation column of the line at the given point. + lineIndent(pos: number, bias: -1 | 1 = 1) { + let {text, from} = this.lineAt(pos, bias) + let override = this.options.overrideIndentation + if (override) { + let overriden = override(from) + if (overriden > -1) return overriden + } + return this.countColumn(text, text.search(/\S|$/)) + } + + /// Returns the [simulated line + /// break](#language.IndentContext.constructor^options.simulateBreak) + /// for this context, if any. + get simulatedBreak(): number | null { + return this.options.simulateBreak || null + } +} + +/// A syntax tree node prop used to associate indentation strategies +/// with node types. Such a strategy is a function from an indentation +/// context to a column number (see also +/// [`indentString`](#language.indentString)) or null, where null +/// indicates that no definitive indentation can be determined. +export const indentNodeProp = new NodeProp<(context: TreeIndentContext) => number | null>() + +// Compute the indentation for a given position from the syntax tree. +function syntaxIndentation(cx: IndentContext, ast: Tree, pos: number) { + let stack = ast.resolveStack(pos) + let inner = ast.resolveInner(pos, -1).resolve(pos, 0).enterUnfinishedNodesBefore(pos) + if (inner != stack.node) { + let add = [] + for (let cur = inner; cur && !( + cur.from < stack.node.from || cur.to > stack.node.to || + cur.from == stack.node.from && cur.type == stack.node.type + ); cur = cur.parent!) add.push(cur) + for (let i = add.length - 1; i >= 0; i--) stack = {node: add[i], next: stack} + } + return indentFor(stack, cx, pos) +} + +function indentFor(stack: NodeIterator | null, cx: IndentContext, pos: number): number | null { + for (let cur: NodeIterator | null = stack; cur; cur = cur.next) { + let strategy = indentStrategy(cur.node) + if (strategy) return strategy(TreeIndentContext.create(cx, pos, cur)) + } + return 0 +} + +function ignoreClosed(cx: TreeIndentContext) { + return cx.pos == cx.options.simulateBreak && cx.options.simulateDoubleBreak +} + +function indentStrategy(tree: SyntaxNode): ((context: TreeIndentContext) => number | null) | null { + let strategy = tree.type.prop(indentNodeProp) + if (strategy) return strategy + let first = tree.firstChild, close: readonly string[] | undefined + if (first && (close = first.type.prop(NodeProp.closedBy))) { + let last = tree.lastChild, closed = last && close.indexOf(last.name) > -1 + return cx => delimitedStrategy(cx, true, 1, undefined, closed && !ignoreClosed(cx) ? last!.from : undefined) + } + return tree.parent == null ? topIndent : null +} + +function topIndent() { return 0 } + +/// Objects of this type provide context information and helper +/// methods to indentation functions registered on syntax nodes. +export class TreeIndentContext extends IndentContext { + private constructor( + private base: IndentContext, + /// The position at which indentation is being computed. + readonly pos: number, + /// @internal + readonly context: NodeIterator + ) { + super(base.state, base.options) + } + + /// The syntax tree node to which the indentation strategy + /// applies. + get node(): SyntaxNode { return this.context.node } + + + /// @internal + static create(base: IndentContext, pos: number, context: NodeIterator) { + return new TreeIndentContext(base, pos, context) + } + + /// Get the text directly after `this.pos`, either the entire line + /// or the next 100 characters, whichever is shorter. + get textAfter() { + return this.textAfterPos(this.pos) + } + + /// Get the indentation at the reference line for `this.node`, which + /// is the line on which it starts, unless there is a node that is + /// _not_ a parent of this node covering the start of that line. If + /// so, the line at the start of that node is tried, again skipping + /// on if it is covered by another such node. + get baseIndent() { + return this.baseIndentFor(this.node) + } + + /// Get the indentation for the reference line of the given node + /// (see [`baseIndent`](#language.TreeIndentContext.baseIndent)). + baseIndentFor(node: SyntaxNode) { + let line = this.state.doc.lineAt(node.from) + // Skip line starts that are covered by a sibling (or cousin, etc) + for (;;) { + let atBreak = node.resolve(line.from) + while (atBreak.parent && atBreak.parent.from == atBreak.from) atBreak = atBreak.parent + if (isParent(atBreak, node)) break + line = this.state.doc.lineAt(atBreak.from) + } + return this.lineIndent(line.from) + } + + /// Continue looking for indentations in the node's parent nodes, + /// and return the result of that. + continue() { + return indentFor(this.context.next, this.base, this.pos) + } +} + +function isParent(parent: SyntaxNode, of: SyntaxNode) { + for (let cur: SyntaxNode | null = of; cur; cur = cur.parent) if (parent == cur) return true + return false +} + +// Check whether a delimited node is aligned (meaning there are +// non-skipped nodes on the same line as the opening delimiter). And +// if so, return the opening token. +function bracketedAligned(context: TreeIndentContext) { + let tree = context.node + let openToken = tree.childAfter(tree.from), last = tree.lastChild + if (!openToken) return null + let sim = context.options.simulateBreak + let openLine = context.state.doc.lineAt(openToken.from) + let lineEnd = sim == null || sim <= openLine.from ? openLine.to : Math.min(openLine.to, sim) + for (let pos = openToken.to;;) { + let next = tree.childAfter(pos) + if (!next || next == last) return null + if (!next.type.isSkipped) { + if (next.from >= lineEnd) return null + let space = /^ */.exec(openLine.text.slice(openToken.to - openLine.from))![0].length + return {from: openToken.from, to: openToken.to + space} + } + pos = next.to + } +} + +/// An indentation strategy for delimited (usually bracketed) nodes. +/// Will, by default, indent one unit more than the parent's base +/// indent unless the line starts with a closing token. When `align` +/// is true and there are non-skipped nodes on the node's opening +/// line, the content of the node will be aligned with the end of the +/// opening node, like this: +/// +/// foo(bar, +/// baz) +export function delimitedIndent({closing, align = true, units = 1}: {closing: string, align?: boolean, units?: number}) { + return (context: TreeIndentContext) => delimitedStrategy(context, align, units, closing) +} + +function delimitedStrategy(context: TreeIndentContext, align: boolean, units: number, closing?: string, closedAt?: number) { + let after = context.textAfter, space = after.match(/^\s*/)![0].length + let closed = closing && after.slice(space, space + closing.length) == closing || closedAt == context.pos + space + let aligned = align ? bracketedAligned(context) : null + if (aligned) return closed ? context.column(aligned.from) : context.column(aligned.to) + return context.baseIndent + (closed ? 0 : context.unit * units) +} + +/// An indentation strategy that aligns a node's content to its base +/// indentation. +export const flatIndent = (context: TreeIndentContext) => context.baseIndent + +/// Creates an indentation strategy that, by default, indents +/// continued lines one unit more than the node's base indentation. +/// You can provide `except` to prevent indentation of lines that +/// match a pattern (for example `/^else\b/` in `if`/`else` +/// constructs), and you can change the amount of units used with the +/// `units` option. +export function continuedIndent({except, units = 1}: {except?: RegExp, units?: number} = {}) { + return (context: TreeIndentContext) => { + let matchExcept = except && except.test(context.textAfter) + return context.baseIndent + (matchExcept ? 0 : units * context.unit) + } +} + +const DontIndentBeyond = 200 + +/// Enables reindentation on input. When a language defines an +/// `indentOnInput` field in its [language +/// data](#state.EditorState.languageDataAt), which must hold a regular +/// expression, the line at the cursor will be reindented whenever new +/// text is typed and the input from the start of the line up to the +/// cursor matches that regexp. +/// +/// To avoid unneccesary reindents, it is recommended to start the +/// regexp with `^` (usually followed by `\s*`), and end it with `$`. +/// For example, `/^\s*\}$/` will reindent when a closing brace is +/// added at the start of a line. +export function indentOnInput(): Extension { + return EditorState.transactionFilter.of(tr => { + if (!tr.docChanged || !tr.isUserEvent("input.type") && !tr.isUserEvent("input.complete")) return tr + let rules = tr.startState.languageDataAt("indentOnInput", tr.startState.selection.main.head) + if (!rules.length) return tr + let doc = tr.newDoc, {head} = tr.newSelection.main, line = doc.lineAt(head) + if (head > line.from + DontIndentBeyond) return tr + let lineStart = doc.sliceString(line.from, head) + if (!rules.some(r => r.test(lineStart))) return tr + let {state} = tr, last = -1, changes = [] + for (let {head} of state.selection.ranges) { + let line = state.doc.lineAt(head) + if (line.from == last) continue + last = line.from + let indent = getIndentation(state, line.from) + if (indent == null) continue + let cur = /^\s*/.exec(line.text)![0] + let norm = indentString(state, indent) + if (cur != norm) + changes.push({from: line.from, to: line.from + cur.length, insert: norm}) + } + return changes.length ? [tr, {changes, sequential: true}] : tr + }) +} diff --git a/vendor/codemirror-language/src/index.ts b/vendor/codemirror-language/src/index.ts new file mode 100644 index 0000000000..8ff31fc38e --- /dev/null +++ b/vendor/codemirror-language/src/index.ts @@ -0,0 +1,21 @@ +export {language, Language, LRLanguage, Sublanguage, sublanguageProp, defineLanguageFacet, + syntaxTree, ensureSyntaxTree, languageDataProp, + ParseContext, LanguageSupport, LanguageDescription, + syntaxTreeAvailable, syntaxParserRunning, forceParsing, DocInput, + viewportPriorityParsing, ViewportPriorityParsingConfig} from "./language" + +export {IndentContext, getIndentUnit, indentString, indentOnInput, indentService, getIndentation, indentRange, indentUnit, + TreeIndentContext, indentNodeProp, delimitedIndent, continuedIndent, flatIndent} from "./indent" + +export {foldService, foldNodeProp, foldInside, foldable, foldCode, unfoldCode, toggleFold, foldAll, unfoldAll, + foldKeymap, codeFolding, foldGutter, foldedRanges, foldEffect, unfoldEffect, foldState} from "./fold" + +export {HighlightStyle, syntaxHighlighting, highlightingFor, TagStyle, defaultHighlightStyle} from "./highlight" + +export {bracketMatching, Config, matchBrackets, MatchResult, bracketMatchingHandle} from "./matchbrackets" + +export {StreamLanguage, StreamParser} from "./stream-parser" + +export {StringStream} from "./stringstream" + +export {bidiIsolates} from "./isolate" diff --git a/vendor/codemirror-language/src/isolate.ts b/vendor/codemirror-language/src/isolate.ts new file mode 100644 index 0000000000..265168f626 --- /dev/null +++ b/vendor/codemirror-language/src/isolate.ts @@ -0,0 +1,128 @@ +import {EditorView, ViewUpdate, ViewPlugin, DecorationSet, Decoration, Direction} from "@codemirror/view" +import {syntaxTree} from "./language" +import {NodeProp, Tree} from "@lezer/common" +import {RangeSetBuilder, Prec, Text, Extension, ChangeSet, Facet} from "@codemirror/state" + +function buildForLine(line: string) { + return line.length <= 4096 && /[\u0590-\u05f4\u0600-\u06ff\u0700-\u08ac\ufb50-\ufdff]/.test(line) +} + +function textHasRTL(text: Text) { + for (let i = text.iter(); !i.next().done;) + if (buildForLine(i.value)) return true + return false +} + +function changeAddsRTL(change: ChangeSet) { + let added = false + change.iterChanges((fA, tA, fB, tB, ins) => { + if (!added && textHasRTL(ins)) added = true + }) + return added +} + +const alwaysIsolate = Facet.define({combine: values => values.some(x => x)}) + +/// Make sure nodes +/// [marked](https://lezer.codemirror.net/docs/ref/#common.NodeProp^isolate) +/// as isolating for bidirectional text are rendered in a way that +/// isolates them from the surrounding text. +export function bidiIsolates(options: { + /// By default, isolating elements are only added when the editor + /// direction isn't uniformly left-to-right, or if it is, on lines + /// that contain right-to-left character. When true, disable this + /// optimization and add them everywhere. + alwaysIsolate?: boolean +} = {}): Extension { + let extensions: Extension[] = [isolateMarks] + if (options.alwaysIsolate) extensions.push(alwaysIsolate.of(true)) + return extensions +} + +const isolateMarks = ViewPlugin.fromClass(class { + decorations: DecorationSet + tree: Tree + hasRTL: boolean + always: boolean + + constructor(view: EditorView) { + this.always = view.state.facet(alwaysIsolate) || + view.textDirection != Direction.LTR || + view.state.facet(EditorView.perLineTextDirection) + this.hasRTL = !this.always && textHasRTL(view.state.doc) + this.tree = syntaxTree(view.state) + this.decorations = this.always || this.hasRTL ? buildDeco(view, this.tree, this.always) : Decoration.none + } + + update(update: ViewUpdate) { + let always = update.state.facet(alwaysIsolate) || + update.view.textDirection != Direction.LTR || + update.state.facet(EditorView.perLineTextDirection) + if (!always && !this.hasRTL && changeAddsRTL(update.changes)) + this.hasRTL = true + + if (!always && !this.hasRTL) return + + let tree = syntaxTree(update.state) + if (always != this.always || tree != this.tree || update.docChanged || update.viewportChanged) { + this.tree = tree + this.always = always + this.decorations = buildDeco(update.view, tree, always) + } + } +}, { + provide: plugin => { + function access(view: EditorView) { + return view.plugin(plugin)?.decorations ?? Decoration.none + } + return [EditorView.outerDecorations.of(access), + Prec.lowest(EditorView.bidiIsolatedRanges.of(access))] + } +}) + +function buildDeco(view: EditorView, tree: Tree, always: boolean) { + let deco = new RangeSetBuilder() + let ranges = view.visibleRanges + if (!always) ranges = clipRTLLines(ranges, view.state.doc) + for (let {from, to} of ranges) { + tree.iterate({ + enter: node => { + let iso = node.type.prop(NodeProp.isolate) + if (iso) deco.add(node.from, node.to, marks[iso]) + }, + from, to + }) + } + return deco.finish() +} + +function clipRTLLines(ranges: readonly {from: number, to: number}[], doc: Text) { + let cur = doc.iter(), pos = 0, result: {from: number, to: number}[] = [], last = null + for (let {from, to} of ranges) { + if (last && last.to > from) { + from = last.to + if (from >= to) continue + } + if (pos + cur.value.length < from) { + cur.next(from - (pos + cur.value.length)) + pos = from + } + for (;;) { + let start = pos, end = pos + cur.value.length + if (!cur.lineBreak && buildForLine(cur.value)) { + if (last && last.to > start - 10) last.to = Math.min(to, end) + else result.push(last = {from: start, to: Math.min(to, end)}) + } + if (end >= to) break + pos = end + cur.next() + } + } + return result +} + +const marks = { + rtl: Decoration.mark({class: "cm-iso", inclusive: true, attributes: {dir: "rtl"}, bidiIsolate: Direction.RTL}), + ltr: Decoration.mark({class: "cm-iso", inclusive: true, attributes: {dir: "ltr"}, bidiIsolate: Direction.LTR}), + auto: Decoration.mark({class: "cm-iso", inclusive: true, attributes: {dir: "auto"}, bidiIsolate: null}) +} diff --git a/vendor/codemirror-language/src/language.ts b/vendor/codemirror-language/src/language.ts new file mode 100644 index 0000000000..11cbfc95c9 --- /dev/null +++ b/vendor/codemirror-language/src/language.ts @@ -0,0 +1,1089 @@ +import {Tree, SyntaxNode, ChangedRange, TreeFragment, NodeProp, NodeType, Input, + PartialParse, Parser, IterMode} from "@lezer/common" +import type {LRParser, ParserConfig} from "@lezer/lr" +import {EditorState, StateField, Transaction, Extension, StateEffect, Facet, + ChangeDesc, Text, TextIterator} from "@codemirror/state" +import {ViewPlugin, ViewUpdate, EditorView, logException} from "@codemirror/view" + +/// Node prop stored in a parser's top syntax node to provide the +/// facet that stores language-specific data for that language. +export const languageDataProp = new NodeProp>() + +/// Helper function to define a facet (to be added to the top syntax +/// node(s) for a language via +/// [`languageDataProp`](#language.languageDataProp)), that will be +/// used to associate language data with the language. You +/// probably only need this when subclassing +/// [`Language`](#language.Language). +export function defineLanguageFacet(baseData?: {[name: string]: any}) { + return Facet.define<{[name: string]: any}>({ + combine: baseData ? values => values.concat(baseData!) : undefined + }) +} + +/// Some languages need to return different [language +/// data](#state.EditorState.languageDataAt) for some parts of their +/// tree. Sublanguages, registered by adding a [node +/// prop](#language.sublanguageProp) to the language's top syntax +/// node, provide a mechanism to do this. +/// +/// (Note that when using nested parsing, where nested syntax is +/// parsed by a different parser and has its own top node type, you +/// don't need a sublanguage.) +export interface Sublanguage { + /// Determines whether the data provided by this sublanguage should + /// completely replace the regular data or be added to it (with + /// higher-precedence). The default is `"extend"`. + type?: "replace" | "extend", + /// A predicate that returns whether the node at the queried + /// position is part of the sublanguage. + test: (node: SyntaxNode, state: EditorState) => boolean, + /// The language data facet that holds the sublanguage's data. + /// You'll want to use + /// [`defineLanguageFacet`](#language.defineLanguageFacet) to create + /// this. + facet: Facet<{[name: string]: any}> +} + +/// Syntax node prop used to register sublanguages. Should be added to +/// the top level node type for the language. +export const sublanguageProp = new NodeProp() + +/// A language object manages parsing and per-language +/// [metadata](#state.EditorState.languageDataAt). Parse data is +/// managed as a [Lezer](https://lezer.codemirror.net) tree. The class +/// can be used directly, via the [`LRLanguage`](#language.LRLanguage) +/// subclass for [Lezer](https://lezer.codemirror.net/) LR parsers, or +/// via the [`StreamLanguage`](#language.StreamLanguage) subclass +/// for stream parsers. +export class Language { + /// The extension value to install this as the document language. + readonly extension: Extension + + /// The parser object. Can be useful when using this as a [nested + /// parser](https://lezer.codemirror.net/docs/ref#common.Parser). + parser: Parser + + /// Construct a language object. If you need to invoke this + /// directly, first define a data facet with + /// [`defineLanguageFacet`](#language.defineLanguageFacet), and then + /// configure your parser to [attach](#language.languageDataProp) it + /// to the language's outer syntax node. + constructor( + /// The [language data](#state.EditorState.languageDataAt) facet + /// used for this language. + readonly data: Facet<{[name: string]: any}>, + parser: Parser, + extraExtensions: Extension[] = [], + /// A language name. + readonly name: string = "" + ) { + // Kludge to define EditorState.tree as a debugging helper, + // without the EditorState package actually knowing about + // languages and lezer trees. + if (!EditorState.prototype.hasOwnProperty("tree")) + Object.defineProperty(EditorState.prototype, "tree", {get() { return syntaxTree(this) }}) + + this.parser = parser + this.extension = [ + language.of(this), + EditorState.languageData.of((state, pos, side) => { + let top = topNodeAt(state, pos, side), data = top.type.prop(languageDataProp) + if (!data) return [] + let base = state.facet(data), sub = top.type.prop(sublanguageProp) + if (sub) { + let innerNode = top.resolve(pos - top.from, side) + for (let sublang of sub) if (sublang.test(innerNode, state)) { + let data = state.facet(sublang.facet) + return sublang.type == "replace" ? data : data.concat(base) + } + } + return base + }) + ].concat(extraExtensions) + } + + /// Query whether this language is active at the given position. + isActiveAt(state: EditorState, pos: number, side: -1 | 0 | 1 = -1) { + return topNodeAt(state, pos, side).type.prop(languageDataProp) == this.data + } + + /// Find the document regions that were parsed using this language. + /// The returned regions will _include_ any nested languages rooted + /// in this language, when those exist. + findRegions(state: EditorState) { + let lang = state.facet(language) + if (lang?.data == this.data) return [{from: 0, to: state.doc.length}] + if (!lang || !lang.allowsNesting) return [] + let result: {from: number, to: number}[] = [] + let explore = (tree: Tree, from: number) => { + if (tree.prop(languageDataProp) == this.data) { + result.push({from, to: from + tree.length}) + return + } + let mount = tree.prop(NodeProp.mounted) + if (mount) { + if (mount.tree.prop(languageDataProp) == this.data) { + if (mount.overlay) for (let r of mount.overlay) result.push({from: r.from + from, to: r.to + from}) + else result.push({from: from, to: from + tree.length}) + return + } else if (mount.overlay) { + let size = result.length + explore(mount.tree, mount.overlay[0].from + from) + if (result.length > size) return + } + } + for (let i = 0; i < tree.children.length; i++) { + let ch = tree.children[i] + if (ch instanceof Tree) explore(ch, tree.positions[i] + from) + } + } + explore(syntaxTree(state), 0) + return result + } + + /// Indicates whether this language allows nested languages. The + /// default implementation returns true. + get allowsNesting() { return true } + + /// @internal + static state: StateField + + /// @internal + static setState = StateEffect.define() +} + +function topNodeAt(state: EditorState, pos: number, side: -1 | 0 | 1) { + let topLang = state.facet(language), tree = syntaxTree(state).topNode + if (!topLang || topLang.allowsNesting) { + for (let node: SyntaxNode | null = tree; node; node = node.enter(pos, side, IterMode.ExcludeBuffers | IterMode.EnterBracketed)) + if (node.type.isTop) tree = node + } + return tree +} + +/// A subclass of [`Language`](#language.Language) for use with Lezer +/// [LR parsers](https://lezer.codemirror.net/docs/ref#lr.LRParser). +export class LRLanguage extends Language { + private constructor(data: Facet<{[name: string]: any}>, readonly parser: LRParser, name?: string) { + super(data, parser, [], name) + } + + /// Define a language from a parser. + static define(spec: { + /// The [name](#Language.name) of the language. + name?: string, + /// The parser to use. Should already have added editor-relevant + /// node props (and optionally things like dialect and top rule) + /// configured. + parser: LRParser, + /// [Language data](#state.EditorState.languageDataAt) + /// to register for this language. + languageData?: {[name: string]: any} + }) { + let data = defineLanguageFacet(spec.languageData) + return new LRLanguage(data, spec.parser.configure({ + props: [languageDataProp.add(type => type.isTop ? data : undefined)] + }), spec.name) + } + + /// Create a new instance of this language with a reconfigured + /// version of its parser and optionally a new name. + configure(options: ParserConfig, name?: string): LRLanguage { + return new LRLanguage(this.data, this.parser.configure(options), name || this.name) + } + + get allowsNesting() { return this.parser.hasWrappers() } +} + +/// Get the syntax tree for a state, which is the current (possibly +/// incomplete) parse tree of the active +/// [language](#language.Language), or the empty tree if there is no +/// language available. +export function syntaxTree(state: EditorState): Tree { + let field = state.field(Language.state, false) + return field ? field.tree : Tree.empty +} + +/// Try to get a parse tree that spans at least up to `upto`. The +/// method will do at most `timeout` milliseconds of work to parse +/// up to that point if the tree isn't already available. +export function ensureSyntaxTree(state: EditorState, upto: number, timeout = 50): Tree | null { + let parse = state.field(Language.state, false)?.context + if (!parse) return null + let oldVieport = parse.viewport + parse.updateViewport({from: 0, to: upto}) + let result = parse.isDone(upto) || parse.work(timeout, upto) ? parse.tree : null + parse.updateViewport(oldVieport) + return result +} + +/// Queries whether there is a full syntax tree available up to the +/// given document position. If there isn't, the background parse +/// process _might_ still be working and update the tree further, but +/// there is no guarantee of that—the parser will [stop +/// working](#language.syntaxParserRunning) when it has spent a +/// certain amount of time or has moved beyond the visible viewport. +/// Always returns false if no language has been enabled. +export function syntaxTreeAvailable(state: EditorState, upto = state.doc.length) { + return state.field(Language.state, false)?.context.isDone(upto) || false +} + +/// Move parsing forward, and update the editor state afterwards to +/// reflect the new tree. Will work for at most `timeout` +/// milliseconds. Returns true if the parser managed get to the given +/// position in that time. +export function forceParsing(view: EditorView, upto = view.viewport.to, timeout = 100): boolean { + let success = ensureSyntaxTree(view.state, upto, timeout) + if (success != syntaxTree(view.state)) view.dispatch({}) + return !!success +} + +/// Tells you whether the language parser is planning to do more +/// parsing work (in a `requestIdleCallback` pseudo-thread) or has +/// stopped running, either because it parsed the entire document, +/// because it spent too much time and was cut off, or because there +/// is no language parser enabled. +export function syntaxParserRunning(view: EditorView) { + return view.plugin(parseWorker)?.isWorking() || false +} + +/// Configuration for viewport-priority provisional parsing. +export interface ViewportPriorityParsingConfig { + /// Cooperative budget for initial, edit, background, and provisional parse + /// slices. Individual parser advances and tree finalization cannot be + /// preempted. Provisional work uses at most 2 ms per slice during gestures. + sliceMs?: number, + /// Provisional trees only apply highlighting from the outer language. + approximate: "outer-language" +} + +interface ResolvedViewportPriorityParsing { + sliceMs: number, + approximate: "outer-language" +} + +const viewportPriorityParsingFacet = Facet.define({ + combine(values) { + if (!values.length) return null + let value = values[0] + return { + sliceMs: Math.max(1, Math.min(8, Number.isFinite(value.sliceMs) ? value.sliceMs! : 4)), + approximate: "outer-language" + } + } +}) + +/// Enable temporary viewport-priority highlighting while the canonical parser +/// is still working through an earlier part of a large document. The +/// provisional tree is never exposed through `syntaxTree` or other language +/// services. +export function viewportPriorityParsing(config: ViewportPriorityParsingConfig): Extension { + return viewportPriorityParsingFacet.of(config) +} + +/// Lezer-style +/// [`Input`](https://lezer.codemirror.net/docs/ref#common.Input) +/// object for a [`Text`](#state.Text) object. +export class DocInput implements Input { + private cursor: TextIterator + private cursorPos = 0 + private string = "" + + /// Create an input object for the given document. + constructor(readonly doc: Text) { + this.cursor = doc.iter() + } + + get length() { return this.doc.length } + + private syncTo(pos: number) { + this.string = this.cursor.next(pos - this.cursorPos).value + this.cursorPos = pos + this.string.length + return this.cursorPos - this.string.length + } + + chunk(pos: number) { + this.syncTo(pos) + return this.string + } + + get lineChunks() { return true } + + read(from: number, to: number) { + let stringStart = this.cursorPos - this.string.length + if (from < stringStart || to >= this.cursorPos) + return this.doc.sliceString(from, to) + else + return this.string.slice(from - stringStart, to - stringStart) + } +} + +const enum Work { + // Milliseconds of work time to perform immediately for a state doc change + Apply = 20, + // Minimum amount of work time to perform in an idle callback + MinSlice = 25, + // Amount of work time to perform in pseudo-thread when idle callbacks aren't supported + Slice = 100, + // Minimum pause between pseudo-thread slices + MinPause = 100, + // Maximum pause (timeout) for the pseudo-thread + MaxPause = 500, + // Parse time budgets are assigned per chunk—the parser can run for + // ChunkBudget milliseconds at most during ChunkTime milliseconds. + // After that, no further background parsing is scheduled until the + // next chunk in which the editor is active. + ChunkBudget = 3000, + ChunkTime = 30000, + // For every change the editor receives while focused, it gets a + // small bonus to its parsing budget (as a way to allow active + // editors to continue doing work). + ChangeBonus = 50, + // Don't eagerly parse this far beyond the end of the viewport + MaxParseAhead = 1e5, + // When initializing the state field (before viewport info is + // available), pretend the viewport goes from 0 to here. + InitViewport = 3000, +} + +let currentContext: ParseContext | null = null + +/// A parse context provided to parsers working on the editor content. +export class ParseContext { + private parse: PartialParse | null = null + /// @internal + tempSkipped: {from: number, to: number}[] = [] + + private constructor( + private parser: Parser, + /// The current editor state. + readonly state: EditorState, + /// Tree fragments that can be reused by incremental re-parses. + public fragments: readonly TreeFragment[] = [], + /// @internal + public tree: Tree, + /// @internal + public treeLen: number, + /// The current editor viewport (or some overapproximation + /// thereof). Intended to be used for opportunistically avoiding + /// work (in which case + /// [`skipUntilInView`](#language.ParseContext.skipUntilInView) + /// should be called to make sure the parser is restarted when the + /// skipped region becomes visible). + public viewport: {from: number, to: number}, + /// @internal + public skipped: {from: number, to: number}[], + /// This is where skipping parsers can register a promise that, + /// when resolved, will schedule a new parse. It is cleared when + /// the parse worker picks up the promise. @internal + public scheduleOn: Promise | null + ) {} + + /// @internal + static create(parser: Parser, state: EditorState, viewport: {from: number, to: number}) { + return new ParseContext(parser, state, [], Tree.empty, 0, viewport, [], null) + } + + private startParse() { + return this.parser.startParse(new DocInput(this.state.doc), this.fragments) + } + + /// @internal + work(until: number | (() => boolean), upto?: number) { + if (upto != null && upto >= this.state.doc.length) upto = undefined + if (this.tree != Tree.empty && this.isDone(upto ?? this.state.doc.length)) { + this.takeTree() + return true + } + return this.withContext(() => { + if (typeof until == "number") { + let endTime = Date.now() + until + until = () => Date.now() > endTime + } + if (!this.parse) this.parse = this.startParse() + if (upto != null && (this.parse.stoppedAt == null || this.parse.stoppedAt > upto) && + upto < this.state.doc.length) this.parse.stopAt(upto) + for (;;) { + let done = this.parse.advance() + if (done) { + this.fragments = this.withoutTempSkipped(TreeFragment.addTree(done, this.fragments, this.parse.stoppedAt != null)) + this.treeLen = this.parse.stoppedAt ?? this.state.doc.length + this.tree = done + this.parse = null + if (this.treeLen < (upto ?? this.state.doc.length)) + this.parse = this.startParse() + else + return true + } + if (until()) return false + } + }) + } + + /// @internal + takeTree() { + let pos, tree: Tree | undefined | null + if (this.parse && (pos = this.parse.parsedPos) >= this.treeLen) { + if (this.parse.stoppedAt == null || this.parse.stoppedAt > pos) this.parse.stopAt(pos) + this.withContext(() => { while (!(tree = this.parse!.advance())) {} }) + this.treeLen = pos + this.tree = tree! + this.fragments = this.withoutTempSkipped(TreeFragment.addTree(this.tree, this.fragments, true)) + this.parse = null + } + } + + private withContext(f: () => T): T { + let prev = currentContext + currentContext = this + try { return f() } + finally { currentContext = prev } + } + + private withoutTempSkipped(fragments: readonly TreeFragment[]) { + for (let r; r = this.tempSkipped.pop();) + fragments = cutFragments(fragments, r.from, r.to) + return fragments + } + + /// @internal + changes(changes: ChangeDesc, newState: EditorState) { + let {fragments, tree, treeLen, viewport, skipped} = this + this.takeTree() + if (!changes.empty) { + let ranges: ChangedRange[] = [] + changes.iterChangedRanges((fromA, toA, fromB, toB) => ranges.push({fromA, toA, fromB, toB})) + fragments = TreeFragment.applyChanges(fragments, ranges) + tree = Tree.empty + treeLen = 0 + viewport = {from: changes.mapPos(viewport.from, -1), to: changes.mapPos(viewport.to, 1)} + if (this.skipped.length) { + skipped = [] + for (let r of this.skipped) { + let from = changes.mapPos(r.from, 1), to = changes.mapPos(r.to, -1) + if (from < to) skipped.push({from, to}) + } + } + } + return new ParseContext(this.parser, newState, fragments, tree, treeLen, viewport, skipped, this.scheduleOn) + } + + /// @internal + updateViewport(viewport: {from: number, to: number}) { + if (this.viewport.from == viewport.from && this.viewport.to == viewport.to) return false + this.viewport = viewport + let startLen = this.skipped.length + for (let i = 0; i < this.skipped.length; i++) { + let {from, to} = this.skipped[i] + if (from < viewport.to && to > viewport.from) { + this.fragments = cutFragments(this.fragments, from, to) + this.skipped.splice(i--, 1) + } + } + if (this.skipped.length >= startLen) return false + this.reset() + return true + } + + /// @internal + reset() { + if (this.parse) { + this.takeTree() + this.parse = null + } + } + + /// Notify the parse scheduler that the given region was skipped + /// because it wasn't in view, and the parse should be restarted + /// when it comes into view. + skipUntilInView(from: number, to: number) { + this.skipped.push({from, to}) + } + + /// Returns a parser intended to be used as placeholder when + /// asynchronously loading a nested parser. It'll skip its input and + /// mark it as not-really-parsed, so that the next update will parse + /// it again. + /// + /// When `until` is given, a reparse will be scheduled when that + /// promise resolves. + static getSkippingParser(until?: Promise): Parser { + return new class extends Parser { + createParse( + input: Input, + fragments: readonly TreeFragment[], + ranges: readonly {from: number, to: number}[] + ): PartialParse { + let from = ranges[0].from, to = ranges[ranges.length - 1].to + let parser = { + parsedPos: from, + advance() { + let cx = currentContext + if (cx) { + for (let r of ranges) cx.tempSkipped.push(r) + if (until) cx.scheduleOn = cx.scheduleOn ? Promise.all([cx.scheduleOn, until]) : until + } + this.parsedPos = to + return new Tree(NodeType.none, [], [], to - from) + }, + stoppedAt: null, + stopAt() {} + } + return parser + } + } + } + + /// @internal + isDone(upto: number) { + upto = Math.min(upto, this.state.doc.length) + let frags = this.fragments + return this.treeLen >= upto && frags.length && frags[0].from == 0 && frags[0].to >= upto + } + + /// Get the context for the current parse, or `null` if no editor + /// parse is in progress. + static get() { return currentContext } +} + +function cutFragments(fragments: readonly TreeFragment[], from: number, to: number) { + return TreeFragment.applyChanges(fragments, [{fromA: from, toA: to, fromB: from, toB: to}]) +} + +class LanguageState { + // The current tree. Immutable, because directly accessible from + // the editor state. + readonly tree: Tree + + constructor( + // A mutable parse state that is used to preserve work done during + // the lifetime of a state when moving to the next state. + readonly context: ParseContext + ) { + this.tree = context.tree + } + + apply(tr: Transaction) { + if (!tr.docChanged && this.tree == this.context.tree) return this + let newCx = this.context.changes(tr.changes, tr.state) + // If the previous parse wasn't done, go forward only up to its + // end position or the end of the viewport, to avoid slowing down + // state updates with parse work beyond the viewport. + let upto = this.context.treeLen == tr.startState.doc.length ? undefined + : Math.max(tr.changes.mapPos(this.context.treeLen), newCx.viewport.to) + let budget = tr.state.facet(viewportPriorityParsingFacet)?.sliceMs ?? Work.Apply + if (!newCx.work(budget, upto)) newCx.takeTree() + return new LanguageState(newCx) + } + + static init(state: EditorState) { + let vpTo = Math.min(Work.InitViewport, state.doc.length) + let parseState = ParseContext.create(state.facet(language)!.parser, state, {from: 0, to: vpTo}) + let budget = state.facet(viewportPriorityParsingFacet)?.sliceMs ?? Work.Apply + if (!parseState.work(budget, vpTo)) parseState.takeTree() + return new LanguageState(parseState) + } +} + +Language.state = StateField.define({ + create: LanguageState.init, + update(value, tr) { + for (let e of tr.effects) if (e.is(Language.setState)) return e.value + if (tr.startState.facet(language) != tr.state.facet(language)) return LanguageState.init(tr.state) + return value.apply(tr) + } +}) + +let requestIdle = (callback: (deadline?: IdleDeadline) => void, shortSlices = false) => { + let timeout = setTimeout(() => callback(), shortSlices ? 16 : Work.MaxPause) + return () => clearTimeout(timeout) +} + +if (typeof requestIdleCallback != "undefined") requestIdle = (callback: (deadline?: IdleDeadline) => void, shortSlices = false) => { + let pause = shortSlices ? 16 : Work.MinPause + let idle = -1, timeout = setTimeout(() => { + idle = requestIdleCallback(callback, {timeout: Work.MaxPause - pause}) + }, pause) + return () => idle < 0 ? clearTimeout(timeout) : cancelIdleCallback(idle) +} + +const isInputPending = typeof navigator != "undefined" && (navigator as any).scheduling?.isInputPending + ? () => (navigator as any).scheduling.isInputPending() : null + +const ProvisionalEditDebounce = 50 +const ProvisionalDebug = Symbol.for("@codemirror/language.viewportPriorityDebug") + +interface ProvisionalParse { + generation: number, + doc: Text, + language: Language, + from: number, + to: number, + parse: PartialParse | null, + tree: Tree | null, + published: boolean, + failed: boolean +} + +/// @internal +export function provisionalParseIsCurrent(task: ProvisionalParse | null, generation: number, + doc: Text, lang: Language | null, destroyed: boolean) { + return !!task?.parse && !destroyed && task.generation == generation && + task.doc == doc && task.language == lang +} + +export interface ViewportPriorityTree { + tree: Tree, + from: number, + to: number, + version: number, + approximate: "outer-language" +} + +/// @internal +export class ParseWorker { + working: (() => void) | null = null + workScheduled = 0 + // End of the current time chunk + chunkEnd = -1 + // Milliseconds of budget left for this chunk + chunkBudget = -1 + provisional: ProvisionalParse | null = null + provisionalTimer = -1 + provisionalGeneration = 0 + provisionalVersion = 0 + // Completed windows survive viewport changes. Only document/language changes + // invalidate these visual trees; canonical syntax state remains independent. + provisionalCache: ViewportPriorityTree[] = [] + destroyed = false + + constructor(readonly view: EditorView) { + this.work = this.work.bind(this) + this.workProvisional = this.workProvisional.bind(this) + this.scheduleWork() + this.restartProvisional() + Object.defineProperty(this.view, ProvisionalDebug, { + configurable: true, + value: () => ({ + generation: this.provisionalGeneration, + version: this.provisionalVersion, + pending: !!this.provisional?.parse, + published: this.provisionalCache.length > 0, + cachedWindows: this.provisionalCache.length, + failed: !!this.provisional?.failed + }) + }) + } + + update(update: ViewUpdate) { + let cx = this.view.state.field(Language.state).context + if (cx.updateViewport(update.view.viewport) || this.view.viewport.to > cx.treeLen) + this.scheduleWork() + if (update.docChanged || update.selectionSet) { + if (this.view.hasFocus) this.chunkBudget += Work.ChangeBonus + this.scheduleWork() + } + let languageChanged = update.startState.facet(language) != update.state.facet(language) + if (update.docChanged || languageChanged) + this.clearProvisional() + this.restartProvisional(update.docChanged ? ProvisionalEditDebounce : 0) + if (update.viewportChanged && !update.docChanged && !languageChanged) this.workProvisional(false) + this.checkAsyncSchedule(cx) + } + + private provisionalRanges() { + let ranges = this.view.visibleRanges + if (!ranges.length) return [] + let doc = this.view.state.doc, size = 128 + let first = Math.floor((doc.lineAt(ranges[0].from).number - 1) / size) + let last = Math.floor((doc.lineAt(ranges[ranges.length - 1].to).number - 1) / size) + let result: {from: number, to: number}[] = [] + let add = (block: number) => { + let line = block * size + 1 + if (line < 1 || line > doc.lines) return + let end = Math.min(doc.lines, line + size - 1) + result.push({from: doc.line(line).from, + to: end == doc.lines ? doc.length : doc.line(end + 1).from}) + } + // Finish stable windows instead of restarting on each pixel of movement. + // Visible windows come first, followed by two windows in each direction. + for (let block = first; block <= last; block++) add(block) + for (let ahead = 1; ahead <= 2; ahead++) { + add(last + ahead) + add(first - ahead) + } + return result.filter(range => range.from < range.to).slice(0, 12) + } + + private clearProvisional(notify = false, keepCache = false) { + if (this.provisionalTimer > -1) clearTimeout(this.provisionalTimer) + this.provisionalTimer = -1 + this.provisionalGeneration++ + let hadTree = this.provisionalCache.length > 0 + this.provisional = null + if (!keepCache) this.provisionalCache = [] + if (notify && hadTree) this.provisionalVersion++ + } + + private restartProvisional(delay = 0) { + let config = this.view.state.facet(viewportPriorityParsingFacet) + let lang = this.view.state.facet(language) + let cx = this.view.state.field(Language.state).context + if (!config || !lang) { + this.clearProvisional(true) + return + } + let ranges = this.provisionalRanges() + let missing = ranges.filter(range => !cx.isDone(range.to) && + !this.provisionalCache.some(tree => tree.from == range.from && tree.to == range.to)) + let current = this.provisional + if (current && current.doc == this.view.state.doc && current.language == lang && + missing.some(range => range.from == current.from && range.to == current.to)) return + if (!current && !missing.length) return + this.clearProvisional(false, true) + let range = missing[0] + if (!range) return + + let generation = ++this.provisionalGeneration + try { + this.provisional = { + generation, + doc: this.view.state.doc, + language: lang, + from: range.from, + to: range.to, + parse: lang.parser.startParse(new DocInput(this.view.state.doc), [], [range]), + tree: null, + published: false, + failed: false + } + this.scheduleProvisional(delay) + } catch (error) { + this.provisional = { + generation, + doc: this.view.state.doc, + language: lang, + from: range.from, + to: range.to, + parse: null, + tree: null, + published: false, + failed: true + } + this.provisionalTimer = setTimeout(() => { + this.provisionalTimer = -1 + if (!this.destroyed && this.provisional?.generation == generation && this.provisional.failed) + this.view.dispatch({}) + }, 0) as any + logException(this.view.state, error, "viewport-priority parse") + } + } + + private scheduleProvisional(delay = 0) { + if (this.provisionalTimer > -1 || !this.provisional || this.destroyed) return + this.provisionalTimer = setTimeout(this.workProvisional, Math.max(0, delay)) as any + } + + private renderCritical() { + return !!(this.view as any).controlledTouchScrollController?.renderCritical + } + + private publishProvisional(task: ProvisionalParse, notify: boolean) { + task.published = true + this.provisionalVersion++ + this.provisionalCache.push({tree: task.tree!, from: task.from, to: task.to, + version: this.provisionalVersion, approximate: "outer-language"}) + // Keep every requested window before nearby history. Character distance + // alone can evict a requested window containing an unusually long line, + // causing it to be parsed repeatedly without ever staying in the cache. + let wanted = new Set(this.provisionalRanges().map(range => range.from)) + let center = (this.view.viewport.from + this.view.viewport.to) / 2 + this.provisionalCache.sort((a, b) => + Number(wanted.has(b.from)) - Number(wanted.has(a.from)) || + Math.abs((a.from + a.to) / 2 - center) - Math.abs((b.from + b.to) / 2 - center)) + this.provisionalCache.length = Math.min(this.provisionalCache.length, 12) + if (notify) { + this.view.dispatch({}) + this.restartProvisional() + } else { + let generation = task.generation + this.provisionalTimer = setTimeout(() => { + this.provisionalTimer = -1 + if (!this.destroyed && this.provisional?.generation == generation && this.provisional.published) { + this.view.dispatch({}) + this.restartProvisional() + } + }, 0) as any + } + } + + private workProvisional(notify = true) { + if (this.provisionalTimer > -1) clearTimeout(this.provisionalTimer) + this.provisionalTimer = -1 + let task = this.provisional + let config = this.view.state.facet(viewportPriorityParsingFacet) + if (task?.tree && !task.published) { + if (isInputPending?.()) this.scheduleProvisional(16) + else this.publishProvisional(task, notify) + return + } + if (!config || !provisionalParseIsCurrent(task, this.provisionalGeneration, this.view.state.doc, + this.view.state.facet(language), this.destroyed)) return + + // Keep canonical parsing deferred during gestures, but prepare visual + // windows in short slices so input and momentum can continue between them. + if (isInputPending?.()) { + this.scheduleProvisional(16) + return + } + + let clock = this.view.win.performance + let end = clock.now() + (this.renderCritical() ? Math.min(2, config.sliceMs) : config.sliceMs) + let tree: Tree | null = null + do { + tree = task.parse.advance() + if (tree || notify && isInputPending && isInputPending()) break + } while (clock.now() < end) + + if (tree) { + task.tree = tree + task.parse = null + if (isInputPending?.()) this.scheduleProvisional(16) + else this.publishProvisional(task, notify) + } else { + this.scheduleProvisional(this.renderCritical() ? 16 : 0) + } + } + + viewportTree(): ViewportPriorityTree | null { + let trees = this.viewportTrees() + let tree = trees.find(tree => tree.to > this.view.viewport.from && tree.from < this.view.viewport.to) + return tree ? {...tree, version: this.provisionalVersion} : null + } + + viewportTrees(): readonly ViewportPriorityTree[] { + return this.provisionalCache.slice().sort((a, b) => a.from - b.from) + } + + provisionalPending() { + return !!(this.provisionalTimer > -1 || this.provisional?.parse || + this.provisional?.tree && !this.provisional.published) + } + + scheduleWork() { + if (this.working) return + let {state} = this.view, field = state.field(Language.state) + if (field.tree != field.context.tree || !field.context.isDone(state.doc.length)) + this.working = requestIdle(this.work, !!state.facet(viewportPriorityParsingFacet)) + } + + work(deadline?: IdleDeadline) { + this.working = null + + if (this.renderCritical()) { + this.scheduleWork() + return + } + + let now = Date.now() + if (this.chunkEnd < now && (this.chunkEnd < 0 || this.view.hasFocus)) { // Start a new chunk + this.chunkEnd = now + Work.ChunkTime + this.chunkBudget = Work.ChunkBudget + } + if (this.chunkBudget <= 0) return // No more budget + + let {state, viewport: {to: vpTo}} = this.view, field = state.field(Language.state) + if (field.tree == field.context.tree && field.context.isDone(vpTo + Work.MaxParseAhead)) return + let config = state.facet(viewportPriorityParsingFacet) + let endTime = Date.now() + Math.min( + this.chunkBudget, config?.sliceMs ?? Work.Slice, + deadline && !isInputPending ? Math.max(config ? 0 : Work.MinSlice, deadline.timeRemaining() - 5) : 1e9) + let viewportFirst = field.context.treeLen < vpTo && state.doc.length > vpTo + 1000 + let done = field.context.work(() => { + return this.renderCritical() || isInputPending && isInputPending() || Date.now() > endTime + } , vpTo + (viewportFirst ? 0 : Work.MaxParseAhead)) + this.chunkBudget -= Date.now() - now + if (done || this.chunkBudget <= 0) { + field.context.takeTree() + this.view.dispatch({effects: Language.setState.of(new LanguageState(field.context))}) + } + if (this.chunkBudget > 0 && !(done && !viewportFirst)) this.scheduleWork() + this.checkAsyncSchedule(field.context) + } + + checkAsyncSchedule(cx: ParseContext) { + if (cx.scheduleOn) { + this.workScheduled++ + cx.scheduleOn + .then(() => this.scheduleWork()) + .catch(err => logException(this.view.state, err)) + .then(() => this.workScheduled--) + cx.scheduleOn = null + } + } + + destroy() { + this.destroyed = true + if (this.working) this.working() + this.clearProvisional() + delete (this.view as any)[ProvisionalDebug] + } + + isWorking() { + return !!(this.working || this.workScheduled > 0 || this.provisionalTimer > -1 || this.provisional?.parse) + } +} + +const parseWorker = ViewPlugin.fromClass(ParseWorker, { + eventHandlers: {focus() { this.scheduleWork() }} +}) + +/// @internal +export function viewportPriorityTree(view: EditorView): ViewportPriorityTree | null { + return view.plugin(parseWorker)?.viewportTree() || null +} + +/// @internal +export function viewportPriorityTrees(view: EditorView): readonly ViewportPriorityTree[] { + return view.plugin(parseWorker)?.viewportTrees() || [] +} + +/// @internal +export function viewportPriorityParsingPending(view: EditorView) { + return !!view.plugin(parseWorker)?.provisionalPending() +} + +/// @internal +export function viewportPriorityParsingFailed(view: EditorView) { + return !!view.plugin(parseWorker)?.provisional?.failed +} + +/// The facet used to associate a language with an editor state. Used +/// by `Language` object's `extension` property (so you don't need to +/// manually wrap your languages in this). Can be used to access the +/// current language on a state. +export const language = Facet.define({ + combine(languages) { return languages.length ? languages[0] : null }, + enables: language => [ + Language.state, + parseWorker, + EditorView.contentAttributes.compute([language], state => { + let lang = state.facet(language) + return lang && lang.name ? {"data-language": lang.name} : {} as {} + }) + ] +}) + +/// This class bundles a [language](#language.Language) with an +/// optional set of supporting extensions. Language packages are +/// encouraged to export a function that optionally takes a +/// configuration object and returns a `LanguageSupport` instance, as +/// the main way for client code to use the package. +export class LanguageSupport { + /// An extension including both the language and its support + /// extensions. (Allowing the object to be used as an extension + /// value itself.) + extension: Extension + + /// Create a language support object. + constructor( + /// The language object. + readonly language: Language, + /// An optional set of supporting extensions. When nesting a + /// language in another language, the outer language is encouraged + /// to include the supporting extensions for its inner languages + /// in its own set of support extensions. + readonly support: Extension = [] + ) { + this.extension = [language, support] + } +} + +/// Language descriptions are used to store metadata about languages +/// and to dynamically load them. Their main role is finding the +/// appropriate language for a filename or dynamically loading nested +/// parsers. +export class LanguageDescription { + private loading: Promise | null = null + + private constructor( + /// The name of this language. + readonly name: string, + /// Alternative names for the mode (lowercased, includes `this.name`). + readonly alias: readonly string[], + /// File extensions associated with this language. + readonly extensions: readonly string[], + /// Optional filename pattern that should be associated with this + /// language. + readonly filename: RegExp | undefined, + private loadFunc: () => Promise, + /// If the language has been loaded, this will hold its value. + public support: LanguageSupport | undefined = undefined + ) {} + + /// Start loading the the language. Will return a promise that + /// resolves to a [`LanguageSupport`](#language.LanguageSupport) + /// object when the language successfully loads. + load(): Promise { + return this.loading || (this.loading = this.loadFunc().then( + support => this.support = support, + err => { this.loading = null; throw err } + )) + } + + /// Create a language description. + static of(spec: { + /// The language's name. + name: string, + /// An optional array of alternative names. + alias?: readonly string[], + /// An optional array of filename extensions associated with this + /// language. + extensions?: readonly string[], + /// An optional filename pattern associated with this language. + filename?: RegExp, + /// A function that will asynchronously load the language. + load?: () => Promise, + /// Alternatively to `load`, you can provide an already loaded + /// support object. Either this or `load` should be provided. + support?: LanguageSupport + }) { + let {load, support} = spec + if (!load) { + if (!support) throw new RangeError("Must pass either 'load' or 'support' to LanguageDescription.of") + load = () => Promise.resolve(support!) + } + return new LanguageDescription(spec.name, (spec.alias || []).concat(spec.name).map(s => s.toLowerCase()), + spec.extensions || [], spec.filename, load, support) + } + + /// Look for a language in the given array of descriptions that + /// matches the filename. Will first match + /// [`filename`](#language.LanguageDescription.filename) patterns, + /// and then [extensions](#language.LanguageDescription.extensions), + /// and return the first language that matches. + static matchFilename(descs: readonly LanguageDescription[], filename: string) { + for (let d of descs) if (d.filename && d.filename.test(filename)) return d + let ext = /\.([^.]+)$/.exec(filename) + if (ext) for (let d of descs) if (d.extensions.indexOf(ext[1]) > -1) return d + return null + } + + /// Look for a language whose name or alias matches the the given + /// name (case-insensitively). If `fuzzy` is true, and no direct + /// matchs is found, this'll also search for a language whose name + /// or alias occurs in the string (for names shorter than three + /// characters, only when surrounded by non-word characters). + static matchLanguageName(descs: readonly LanguageDescription[], name: string, fuzzy = true) { + name = name.toLowerCase() + for (let d of descs) if (d.alias.some(a => a == name)) return d + if (fuzzy) for (let d of descs) for (let a of d.alias) { + let found = name.indexOf(a) + if (found > -1 && (a.length > 2 || !/\w/.test(name[found - 1]) && !/\w/.test(name[found + a.length]))) + return d + } + return null + } +} diff --git a/vendor/codemirror-language/src/matchbrackets.ts b/vendor/codemirror-language/src/matchbrackets.ts new file mode 100644 index 0000000000..e3f7700a56 --- /dev/null +++ b/vendor/codemirror-language/src/matchbrackets.ts @@ -0,0 +1,215 @@ +import {combineConfig, EditorState, Facet, Extension, Range} from "@codemirror/state" +import {syntaxTree} from "./language" +import {EditorView, Decoration, DecorationSet, ViewPlugin, ViewUpdate} from "@codemirror/view" +import {Tree, SyntaxNode, SyntaxNodeRef, NodeType, NodeProp} from "@lezer/common" + +export interface Config { + /// Whether the bracket matching should look at the character after + /// the cursor when matching (if the one before isn't a bracket). + /// Defaults to true. + afterCursor?: boolean + /// The bracket characters to match, as a string of pairs. Defaults + /// to `"()[]{}"`. Note that these are only used as fallback when + /// there is no [matching + /// information](https://lezer.codemirror.net/docs/ref/#common.NodeProp^closedBy) + /// in the syntax tree. + brackets?: string + /// The maximum distance to scan for matching brackets. This is only + /// relevant for brackets not encoded in the syntax tree. Defaults + /// to 10 000. + maxScanDistance?: number + /// Can be used to configure the way in which brackets are + /// decorated. The default behavior is to add the + /// `cm-matchingBracket` class for matching pairs, and + /// `cm-nonmatchingBracket` for mismatched pairs or single brackets. + renderMatch?: (match: MatchResult, state: EditorState) => readonly Range[] +} + +const baseTheme = EditorView.baseTheme({ + "&.cm-focused .cm-matchingBracket": {backgroundColor: "#328c8252"}, + "&.cm-focused .cm-nonmatchingBracket": {backgroundColor: "#bb555544"} +}) + +const DefaultScanDist = 10000, DefaultBrackets = "()[]{}" + +const bracketMatchingConfig = Facet.define>({ + combine(configs) { + return combineConfig(configs, { + afterCursor: true, + brackets: DefaultBrackets, + maxScanDistance: DefaultScanDist, + renderMatch: defaultRenderMatch + }) + } +}) + +const matchingMark = Decoration.mark({class: "cm-matchingBracket"}), + nonmatchingMark = Decoration.mark({class: "cm-nonmatchingBracket"}) + +function defaultRenderMatch(match: MatchResult) { + let decorations = [] + let mark = match.matched ? matchingMark : nonmatchingMark + decorations.push(mark.range(match.start.from, match.start.to)) + if (match.end) decorations.push(mark.range(match.end.from, match.end.to)) + return decorations +} + +function bracketDeco(state: EditorState) { + let decorations: Range[] = [] + let config = state.facet(bracketMatchingConfig) + for (let range of state.selection.ranges) { + if (!range.empty) continue + let match = matchBrackets(state, range.head, -1, config) + || (range.head > 0 && matchBrackets(state, range.head - 1, 1, config)) + || (config.afterCursor && + (matchBrackets(state, range.head, 1, config) || + (range.head < state.doc.length && matchBrackets(state, range.head + 1, -1, config)))) + if (match) + decorations = decorations.concat(config.renderMatch(match, state)) + } + return Decoration.set(decorations, true) +} + +const bracketMatcher = ViewPlugin.fromClass(class { + decorations: DecorationSet + paused = false + constructor(view: EditorView) { + this.decorations = bracketDeco(view.state) + } + update(update: ViewUpdate) { + if (update.docChanged || update.selectionSet || this.paused) { + if (update.view.composing) { + this.decorations = this.decorations.map(update.changes) + this.paused = true + } else { + this.decorations = bracketDeco(update.state) + this.paused = false + } + } + } +}, { + decorations: v => v.decorations +}) + +const bracketMatchingUnique = [ + bracketMatcher, + baseTheme +] + +/// Create an extension that enables bracket matching. Whenever the +/// cursor is next to a bracket, that bracket and the one it matches +/// are highlighted. Or, when no matching bracket is found, another +/// highlighting style is used to indicate this. +export function bracketMatching(config: Config = {}): Extension { + return [bracketMatchingConfig.of(config), bracketMatchingUnique] +} + +/// When larger syntax nodes, such as HTML tags, are marked as +/// opening/closing, it can be a bit messy to treat the whole node as +/// a matchable bracket. This node prop allows you to define, for such +/// a node, a ‘handle’—the part of the node that is highlighted, and +/// that the cursor must be on to activate highlighting in the first +/// place. +export const bracketMatchingHandle = new NodeProp<(node: SyntaxNode) => SyntaxNode | null>() + +function matchingNodes(node: NodeType, dir: -1 | 1, brackets: string): null | readonly string[] { + let byProp = node.prop(dir < 0 ? NodeProp.openedBy : NodeProp.closedBy) + if (byProp) return byProp + if (node.name.length == 1) { + let index = brackets.indexOf(node.name) + if (index > -1 && index % 2 == (dir < 0 ? 1 : 0)) + return [brackets[index + dir]] + } + return null +} + + +/// The result returned from `matchBrackets`. +export interface MatchResult { + /// The extent of the bracket token found. + start: {from: number, to: number}, + /// The extent of the matched token, if any was found. + end?: {from: number, to: number}, + /// Whether the tokens match. This can be false even when `end` has + /// a value, if that token doesn't match the opening token. + matched: boolean +} + +function findHandle(node: SyntaxNodeRef) { + let hasHandle = node.type.prop(bracketMatchingHandle) + return hasHandle ? hasHandle(node.node) : node +} + +/// Find the matching bracket for the token at `pos`, scanning +/// direction `dir`. Only the `brackets` and `maxScanDistance` +/// properties are used from `config`, if given. Returns null if no +/// bracket was found at `pos`, or a match result otherwise. +export function matchBrackets(state: EditorState, pos: number, dir: -1 | 1, config: Config = {}): MatchResult | null { + let maxScanDistance = config.maxScanDistance || DefaultScanDist, brackets = config.brackets || DefaultBrackets + let tree = syntaxTree(state), node = tree.resolveInner(pos, dir) + for (let cur: SyntaxNode | null = node; cur; cur = cur.parent) { + let matches = matchingNodes(cur.type, dir, brackets) + if (matches && cur.from < cur.to) { + let handle = findHandle(cur) + if (handle && (dir > 0 ? pos >= handle.from && pos < handle.to : pos > handle.from && pos <= handle.to)) + return matchMarkedBrackets(state, pos, dir, cur, handle, matches, brackets) + } + } + return matchPlainBrackets(state, pos, dir, tree, node.type, maxScanDistance, brackets) +} + +function matchMarkedBrackets(_state: EditorState, _pos: number, dir: -1 | 1, token: SyntaxNode, + handle: SyntaxNodeRef, matching: readonly string[], brackets: string) { + let parent = token.parent, firstToken = {from: handle.from, to: handle.to} + let depth = 0, cursor = parent?.cursor() + if (cursor && (dir < 0 ? cursor.childBefore(token.from) : cursor.childAfter(token.to))) do { + if (dir < 0 ? cursor.to <= token.from : cursor.from >= token.to) { + if (depth == 0 && matching.indexOf(cursor.type.name) > -1 && cursor.from < cursor.to) { + let endHandle = findHandle(cursor) + return {start: firstToken, end: endHandle ? {from: endHandle.from, to: endHandle.to} : undefined, matched: true} + } else if (matchingNodes(cursor.type, dir, brackets)) { + depth++ + } else if (matchingNodes(cursor.type, -dir as -1 | 1, brackets)) { + if (depth == 0) { + let endHandle = findHandle(cursor) + return { + start: firstToken, + end: endHandle && endHandle.from < endHandle.to ? {from: endHandle.from, to: endHandle.to} : undefined, + matched: false + } + } + depth-- + } + } + } while (dir < 0 ? cursor.prevSibling() : cursor.nextSibling()) + return {start: firstToken, matched: false} +} + +function matchPlainBrackets(state: EditorState, pos: number, dir: number, tree: Tree, + tokenType: NodeType, maxScanDistance: number, brackets: string) { + if (dir < 0 ? !pos : pos == state.doc.length) return null + let startCh = dir < 0 ? state.sliceDoc(pos - 1, pos) : state.sliceDoc(pos, pos + 1) + let bracket = brackets.indexOf(startCh) + if (bracket < 0 || (bracket % 2 == 0) != (dir > 0)) return null + + let startToken = {from: dir < 0 ? pos - 1 : pos, to: dir > 0 ? pos + 1 : pos} + let iter = state.doc.iterRange(pos, dir > 0 ? state.doc.length : 0), depth = 0 + for (let distance = 0; !(iter.next()).done && distance <= maxScanDistance;) { + let text = iter.value + if (dir < 0) distance += text.length + let basePos = pos + distance * dir + for (let pos = dir > 0 ? 0 : text.length - 1, end = dir > 0 ? text.length : -1; pos != end; pos += dir) { + let found = brackets.indexOf(text[pos]) + if (found < 0 || tree.resolveInner(basePos + pos, 1).type != tokenType) continue + if ((found % 2 == 0) == (dir > 0)) { + depth++ + } else if (depth == 1) { // Closing + return {start: startToken, end: {from: basePos + pos, to: basePos + pos + 1}, matched: (found >> 1) == (bracket >> 1)} + } else { + depth-- + } + } + if (dir > 0) distance += text.length + } + return iter.done ? {start: startToken, matched: false} : null +} diff --git a/vendor/codemirror-language/src/stream-parser.ts b/vendor/codemirror-language/src/stream-parser.ts new file mode 100644 index 0000000000..40139eefed --- /dev/null +++ b/vendor/codemirror-language/src/stream-parser.ts @@ -0,0 +1,438 @@ +import {Tree, Input, TreeFragment, NodeType, NodeSet, PartialParse, Parser, NodeProp} from "@lezer/common" +import {Tag, tags as highlightTags, styleTags} from "@lezer/highlight" +import {EditorState, Facet} from "@codemirror/state" +import {Language, defineLanguageFacet, languageDataProp, ParseContext} from "./language" +import {TreeIndentContext, IndentContext, indentNodeProp, getIndentUnit} from "./indent" +import {StringStream} from "./stringstream" + +export {StringStream} + +/// A stream parser parses or tokenizes content from start to end, +/// emitting tokens as it goes over it. It keeps a mutable (but +/// copyable) object with state, in which it can store information +/// about the current context. +export interface StreamParser { + /// A name for this language. + name?: string + /// Produce a start state for the parser. + startState?(indentUnit: number): State + /// Read one token, advancing the stream past it, and returning a + /// string indicating the token's style tag—either the name of one + /// of the tags in + /// [`tags`](https://lezer.codemirror.net/docs/ref#highlight.tags) + /// or [`tokenTable`](#language.StreamParser.tokenTable), or such a + /// name suffixed by one or more tag + /// [modifier](https://lezer.codemirror.net/docs/ref#highlight.Tag^defineModifier) + /// names, separated by periods. For example `"keyword"` or + /// "`variableName.constant"`, or a space-separated set of such + /// token types. + /// + /// It is okay to return a zero-length token, but only if that + /// updates the state so that the next call will return a non-empty + /// token again. + token(stream: StringStream, state: State): string | null + /// This notifies the parser of a blank line in the input. It can + /// update its state here if it needs to. + blankLine?(state: State, indentUnit: number): void + /// Copy a given state. By default, a shallow object copy is done + /// which also copies arrays held at the top level of the object. + copyState?(state: State): State + /// Compute automatic indentation for the line that starts with the + /// given state and text. + indent?(state: State, textAfter: string, context: IndentContext): number | null + /// Default [language data](#state.EditorState.languageDataAt) to + /// attach to this language. + languageData?: {[name: string]: any} + /// Extra tokens to use in this parser. When the tokenizer returns a + /// token name that exists as a property in this object, the + /// corresponding tags will be assigned to the token. + tokenTable?: {[name: string]: Tag | readonly Tag[]} + /// By default, adjacent tokens of the same type are merged in the + /// output tree. Set this to false to disable that. + mergeTokens?: boolean +} + +function fullParser(spec: StreamParser): Required> { + return { + name: spec.name || "", + token: spec.token, + blankLine: spec.blankLine || (() => {}), + startState: spec.startState || (() => (true as any)), + copyState: spec.copyState || defaultCopyState, + indent: spec.indent || (() => null), + languageData: spec.languageData || {}, + tokenTable: spec.tokenTable || noTokens, + mergeTokens: spec.mergeTokens !== false + } +} + +function defaultCopyState(state: State) { + if (typeof state != "object") return state + let newState = {} as State + for (let prop in state) { + let val = state[prop] + newState[prop] = (val instanceof Array ? val.slice() : val) as any + } + return newState +} + +const IndentedFrom = new WeakMap() + +/// A [language](#language.Language) class based on a CodeMirror +/// 5-style [streaming parser](#language.StreamParser). +export class StreamLanguage extends Language { + /// @internal + streamParser: Required> + /// @internal + stateAfter: NodeProp + /// @internal + tokenTable: TokenTable + /// @internal + topNode: NodeType + + private constructor(parser: StreamParser) { + let data = defineLanguageFacet(parser.languageData) + let p = fullParser(parser), self: StreamLanguage + let impl = new class extends Parser { + createParse(input: Input, fragments: readonly TreeFragment[], ranges: readonly {from: number, to: number}[]) { + return new Parse(self, input, fragments, ranges) + } + } + super(data, impl, [], parser.name) + this.topNode = docID(data, this) + self = this + this.streamParser = p + this.stateAfter = new NodeProp({perNode: true}) + this.tokenTable = parser.tokenTable ? new TokenTable(p.tokenTable) : defaultTokenTable + } + + /// Define a stream language. + static define(spec: StreamParser) { return new StreamLanguage(spec) } + + /// @internal + getIndent(cx: TreeIndentContext) { + let from = undefined + let {overrideIndentation} = cx.options + if (overrideIndentation) { + from = IndentedFrom.get(cx.state) + if (from != null && from < cx.pos - 1e4) from = undefined + } + let start = findState(this, cx.node.tree!, cx.node.from, cx.node.from, from ?? cx.pos), statePos, state + if (start) { state = start.state; statePos = start.pos + 1 } + else { state = this.streamParser.startState(cx.unit) ; statePos = cx.node.from } + if (cx.pos - statePos > C.MaxIndentScanDist) return null + while (statePos < cx.pos) { + let line = cx.state.doc.lineAt(statePos), end = Math.min(cx.pos, line.to) + if (line.length) { + let indentation = overrideIndentation ? overrideIndentation(line.from) : -1 + let stream = new StringStream(line.text, cx.state.tabSize, cx.unit, indentation < 0 ? undefined : indentation) + while (stream.pos < end - line.from) + readToken(this.streamParser.token, stream, state) + } else { + this.streamParser.blankLine(state, cx.unit) + } + if (end == cx.pos) break + statePos = line.to + 1 + } + let line = cx.lineAt(cx.pos) + if (overrideIndentation && from == null) IndentedFrom.set(cx.state, line.from) + return this.streamParser.indent(state, /^\s*(.*)/.exec(line.text)![1], cx) + } + + get allowsNesting() { return false } +} + +function findState( + lang: StreamLanguage, tree: Tree, off: number, startPos: number, before: number +): {state: State, pos: number} | null { + let state = off >= startPos && off + tree.length <= before && tree.prop(lang.stateAfter) + if (state) return {state: lang.streamParser.copyState(state), pos: off + tree.length} + for (let i = tree.children.length - 1; i >= 0; i--) { + let child = tree.children[i], pos = off + tree.positions[i] + let found = child instanceof Tree && pos < before && findState(lang, child, pos, startPos, before) + if (found) return found + } + return null +} + +function cutTree(lang: StreamLanguage, tree: Tree, from: number, to: number, inside: boolean): Tree | null { + if (inside && from <= 0 && to >= tree.length) return tree + if (!inside && from == 0 && tree.type == lang.topNode) inside = true + for (let i = tree.children.length - 1; i >= 0; i--) { + let pos = tree.positions[i], child = tree.children[i], inner + if (pos < to && child instanceof Tree) { + if (!(inner = cutTree(lang, child, from - pos, to - pos, inside))) break + return !inside ? inner + : new Tree(tree.type, tree.children.slice(0, i).concat(inner), tree.positions.slice(0, i + 1), pos + inner.length) + } + } + return null +} + +function findStartInFragments(lang: StreamLanguage, fragments: readonly TreeFragment[], + startPos: number, endPos: number, editorState?: EditorState) { + for (let f of fragments) { + let from = f.from + (f.openStart ? 25 : 0), to = f.to - (f.openEnd ? 25 : 0) + let found = from <= startPos && to > startPos && findState(lang, f.tree, 0 - f.offset, startPos, to), tree + if (found && found.pos <= endPos && (tree = cutTree(lang, f.tree, startPos + f.offset, found.pos + f.offset, false))) + return {state: found.state, tree} + } + return {state: lang.streamParser.startState(editorState ? getIndentUnit(editorState) : 4), tree: Tree.empty} +} + +const enum C { + ChunkSize = 512, + MaxDistanceBeforeViewport = 1e5, + MaxIndentScanDist = 1e4, + MaxLineLength = 1e4 +} + +class Parse implements PartialParse { + state: State + parsedPos: number + stoppedAt: number | null = null + chunks: Tree[] = [] + chunkPos: number[] = [] + chunkStart: number + chunk: number[] = [] + chunkReused: undefined | Tree[] = undefined + rangeIndex = 0 + to: number + + constructor(readonly lang: StreamLanguage, + readonly input: Input, + readonly fragments: readonly TreeFragment[], + readonly ranges: readonly {from: number, to: number}[]) { + this.to = ranges[ranges.length - 1].to + let context = ParseContext.get(), from = ranges[0].from + let {state, tree} = findStartInFragments(lang, fragments, from, this.to, context?.state) + this.state = state + this.parsedPos = this.chunkStart = from + tree.length + for (let i = 0; i < tree.children.length; i++) { + this.chunks.push(tree.children[i] as Tree) + this.chunkPos.push(tree.positions[i]) + } + if (context && this.parsedPos < context.viewport.from - C.MaxDistanceBeforeViewport && + ranges.some(r => r.from <= context!.viewport.from && r.to >= context!.viewport.from)) { + this.state = this.lang.streamParser.startState(getIndentUnit(context.state)) + context.skipUntilInView(this.parsedPos, context.viewport.from) + this.parsedPos = context.viewport.from + } + this.moveRangeIndex() + } + + advance() { + let context = ParseContext.get() + let parseEnd = this.stoppedAt == null ? this.to : Math.min(this.to, this.stoppedAt) + let end = Math.min(parseEnd, this.chunkStart + C.ChunkSize) + if (context) end = Math.min(end, context.viewport.to) + while (this.parsedPos < end) this.parseLine(context) + if (this.chunkStart < this.parsedPos) this.finishChunk() + if (this.parsedPos >= parseEnd) return this.finish() + if (context && this.parsedPos >= context.viewport.to) { + context.skipUntilInView(this.parsedPos, parseEnd) + return this.finish() + } + return null + } + + stopAt(pos: number) { + this.stoppedAt = pos + } + + lineAfter(pos: number) { + let chunk = this.input.chunk(pos) + if (!this.input.lineChunks) { + let eol = chunk.indexOf("\n") + if (eol > -1) chunk = chunk.slice(0, eol) + } else if (chunk == "\n") { + chunk = "" + } + return pos + chunk.length <= this.to ? chunk : chunk.slice(0, this.to - pos) + } + + nextLine() { + let from = this.parsedPos, line = this.lineAfter(from), end = from + line.length + for (let index = this.rangeIndex;;) { + let rangeEnd = this.ranges[index].to + if (rangeEnd >= end) break + line = line.slice(0, rangeEnd - (end - line.length)) + index++ + if (index == this.ranges.length) break + let rangeStart = this.ranges[index].from + let after = this.lineAfter(rangeStart) + line += after + end = rangeStart + after.length + } + return {line, end} + } + + skipGapsTo(pos: number, offset: number, side: -1 | 1) { + for (;;) { + let end = this.ranges[this.rangeIndex].to, offPos = pos + offset + if (side > 0 ? end > offPos : end >= offPos) break + let start = this.ranges[++this.rangeIndex].from + offset += start - end + } + return offset + } + + moveRangeIndex() { + while (this.ranges[this.rangeIndex].to < this.parsedPos) this.rangeIndex++ + } + + emitToken(id: number, from: number, to: number, offset: number) { + let size = 4 + if (this.ranges.length > 1) { + offset = this.skipGapsTo(from, offset, 1) + from += offset + let len0 = this.chunk.length + offset = this.skipGapsTo(to, offset, -1) + to += offset + size += this.chunk.length - len0 + } + let last = this.chunk.length - 4 + if (this.lang.streamParser.mergeTokens && size == 4 && last >= 0 && + this.chunk[last] == id && this.chunk[last + 2] == from) + this.chunk[last + 2] = to + else + this.chunk.push(id, from, to, size) + return offset + } + + parseLine(context: ParseContext | null) { + let {line, end} = this.nextLine(), offset = 0, {streamParser} = this.lang + let stream = new StringStream(line, context ? context.state.tabSize : 4, context ? getIndentUnit(context.state) : 2) + if (stream.eol()) { + streamParser.blankLine(this.state, stream.indentUnit) + } else { + while (!stream.eol()) { + let token = readToken(streamParser.token, stream, this.state) + if (token) + offset = this.emitToken(this.lang.tokenTable.resolve(token), this.parsedPos + stream.start, + this.parsedPos + stream.pos, offset) + if (stream.start > C.MaxLineLength) break + } + } + this.parsedPos = end + this.moveRangeIndex() + if (this.parsedPos < this.to) this.parsedPos++ + } + + finishChunk() { + let tree = Tree.build({ + buffer: this.chunk, + start: this.chunkStart, + length: this.parsedPos - this.chunkStart, + nodeSet, + topID: 0, + maxBufferLength: C.ChunkSize, + reused: this.chunkReused + }) + tree = new Tree(tree.type, tree.children, tree.positions, tree.length, + [[this.lang.stateAfter, this.lang.streamParser.copyState(this.state)]]) + this.chunks.push(tree) + this.chunkPos.push(this.chunkStart - this.ranges[0].from) + this.chunk = [] + this.chunkReused = undefined + this.chunkStart = this.parsedPos + } + + finish() { + return new Tree(this.lang.topNode, this.chunks, this.chunkPos, this.parsedPos - this.ranges[0].from).balance() + } +} + +function readToken(token: (stream: StringStream, state: State) => string | null, + stream: StringStream, state: State) { + stream.start = stream.pos + for (let i = 0; i < 10; i++) { + let result = token(stream, state) + if (stream.pos > stream.start) return result + } + throw new Error("Stream parser failed to advance stream.") +} + +const noTokens: {[name: string]: Tag} = Object.create(null) + +const typeArray: NodeType[] = [NodeType.none] +const nodeSet = new NodeSet(typeArray) +const warned: string[] = [] + +// Cache of node types by name and tags +const byTag: {[key: string]: NodeType} = Object.create(null) + +const defaultTable: {[name: string]: number} = Object.create(null) +for (let [legacyName, name] of [ + ["variable", "variableName"], + ["variable-2", "variableName.special"], + ["string-2", "string.special"], + ["def", "variableName.definition"], + ["tag", "tagName"], + ["attribute", "attributeName"], + ["type", "typeName"], + ["builtin", "variableName.standard"], + ["qualifier", "modifier"], + ["error", "invalid"], + ["header", "heading"], + ["property", "propertyName"] +]) defaultTable[legacyName] = createTokenType(noTokens, name) + +class TokenTable { + table: {[name: string]: number} = Object.assign(Object.create(null), defaultTable) + + constructor(readonly extra: {[name: string]: Tag | readonly Tag[]}) {} + + resolve(tag: string) { + return !tag ? 0 : this.table[tag] || (this.table[tag] = createTokenType(this.extra, tag)) + } +} + +const defaultTokenTable = new TokenTable(noTokens) + +function warnForPart(part: string, msg: string) { + if (warned.indexOf(part) > -1) return + warned.push(part) + console.warn(msg) +} + +function createTokenType(extra: {[name: string]: Tag | readonly Tag[]}, tagStr: string) { + let tags = [] + for (let name of tagStr.split(" ")) { + let found: readonly Tag[] = [] + for (let part of name.split(".")) { + let value = (extra[part] || (highlightTags as any)[part]) as Tag | readonly Tag[] | ((t: Tag) => Tag) | undefined + if (!value) { + warnForPart(part, `Unknown highlighting tag ${part}`) + } else if (typeof value == "function") { + if (!found.length) warnForPart(part, `Modifier ${part} used at start of tag`) + else found = found.map(value) as Tag[] + } else { + if (found.length) warnForPart(part, `Tag ${part} used as modifier`) + else found = Array.isArray(value) ? value : [value] + } + } + for (let tag of found) tags.push(tag) + } + if (!tags.length) return 0 + + let name = tagStr.replace(/ /g, "_"), key = name + " " + tags.map(t => (t as any).id) + let known = byTag[key] + if (known) return known.id + let type = byTag[key] = NodeType.define({ + id: typeArray.length, + name, + props: [styleTags({[name]: tags})] + }) + typeArray.push(type) + return type.id +} + +function docID(data: Facet<{[name: string]: any}>, lang: StreamLanguage) { + let type = NodeType.define({id: typeArray.length, name: "Document", props: [ + languageDataProp.add(() => data), + indentNodeProp.add(() => cx => lang.getIndent(cx)) + ], top: true}) + typeArray.push(type) + return type +} diff --git a/vendor/codemirror-language/src/stringstream.ts b/vendor/codemirror-language/src/stringstream.ts new file mode 100644 index 0000000000..ab81bef879 --- /dev/null +++ b/vendor/codemirror-language/src/stringstream.ts @@ -0,0 +1,133 @@ +// Counts the column offset in a string, taking tabs into account. +// Used mostly to find indentation. +function countCol(string: string, end: number | null, tabSize: number, startIndex = 0, startValue = 0): number { + if (end == null) { + end = string.search(/[^\s\u00a0]/) + if (end == -1) end = string.length + } + let n = startValue + for (let i = startIndex; i < end; i++) { + if (string.charCodeAt(i) == 9) n += tabSize - (n % tabSize) + else n++ + } + return n +} + +/// Encapsulates a single line of input. Given to stream syntax code, +/// which uses it to tokenize the content. +export class StringStream { + /// The current position on the line. + pos: number = 0 + /// The start position of the current token. + start: number = 0 + private lastColumnPos: number = 0 + private lastColumnValue: number = 0 + + /// Create a stream. + constructor( + /// The line. + public string: string, + private tabSize: number, + /// The current indent unit size. + public indentUnit: number, + private overrideIndent?: number + ) {} + + /// True if we are at the end of the line. + eol(): boolean {return this.pos >= this.string.length} + + /// True if we are at the start of the line. + sol(): boolean {return this.pos == 0} + + /// Get the next code unit after the current position, or undefined + /// if we're at the end of the line. + peek() {return this.string.charAt(this.pos) || undefined} + + /// Read the next code unit and advance `this.pos`. + next(): string | void { + if (this.pos < this.string.length) + return this.string.charAt(this.pos++) + } + + /// Match the next character against the given string, regular + /// expression, or predicate. Consume and return it if it matches. + eat(match: string | RegExp | ((ch: string) => boolean)): string | void { + let ch = this.string.charAt(this.pos) + let ok + if (typeof match == "string") ok = ch == match + else ok = ch && (match instanceof RegExp ? match.test(ch) : match(ch)) + if (ok) {++this.pos; return ch} + } + + /// Continue matching characters that match the given string, + /// regular expression, or predicate function. Return true if any + /// characters were consumed. + eatWhile(match: string | RegExp | ((ch: string) => boolean)): boolean { + let start = this.pos + while (this.eat(match)){} + return this.pos > start + } + + /// Consume whitespace ahead of `this.pos`. Return true if any was + /// found. + eatSpace() { + let start = this.pos + while (/[\s\u00a0]/.test(this.string.charAt(this.pos))) ++this.pos + return this.pos > start + } + + /// Move to the end of the line. + skipToEnd() {this.pos = this.string.length} + + /// Move to directly before the given character, if found on the + /// current line. + skipTo(ch: string): boolean | void { + let found = this.string.indexOf(ch, this.pos) + if (found > -1) {this.pos = found; return true} + } + + /// Move back `n` characters. + backUp(n: number) {this.pos -= n} + + /// Get the column position at `this.pos`. + column() { + if (this.lastColumnPos < this.start) { + this.lastColumnValue = countCol(this.string, this.start, this.tabSize, this.lastColumnPos, this.lastColumnValue) + this.lastColumnPos = this.start + } + return this.lastColumnValue + } + + /// Get the indentation column of the current line. + indentation() { + return this.overrideIndent ?? countCol(this.string, null, this.tabSize) + } + + /// Match the input against the given string or regular expression + /// (which should start with a `^`). Return true or the regexp match + /// if it matches. + /// + /// Unless `consume` is set to `false`, this will move `this.pos` + /// past the matched text. + /// + /// When matching a string `caseInsensitive` can be set to true to + /// make the match case-insensitive. + match(pattern: string | RegExp, consume?: boolean, caseInsensitive?: boolean): boolean | RegExpMatchArray | null { + if (typeof pattern == "string") { + let cased = (str: string) => caseInsensitive ? str.toLowerCase() : str + let substr = this.string.substr(this.pos, pattern.length) + if (cased(substr) == cased(pattern)) { + if (consume !== false) this.pos += pattern.length + return true + } else return null + } else { + let match = this.string.slice(this.pos).match(pattern) + if (match && match.index! > 0) return null + if (match && consume !== false) this.pos += match[0].length + return match + } + } + + /// Get the current token. + current(){return this.string.slice(this.start, this.pos)} +} diff --git a/vendor/codemirror-language/test/test-fold.ts b/vendor/codemirror-language/test/test-fold.ts new file mode 100644 index 0000000000..538437513c --- /dev/null +++ b/vendor/codemirror-language/test/test-fold.ts @@ -0,0 +1,34 @@ +import ist from "ist" +import {foldEffect, unfoldEffect, foldState} from "@codemirror/language" +import {EditorState} from "@codemirror/state" +import {DecorationSet} from "@codemirror/view" + +let doc = "1\n2\n3\n4\n5\n6\n7\n8\n" + +function ranges(set: DecorationSet) { + let result: string[] = [] + set.between(0, 1e8, (f, t) => {result.push(`${f}-${t}`)}) + return result.join(" ") +} + +describe("Folding", () => { + it("stores fold state", () => { + let state = EditorState.create({doc, extensions: foldState}).update({ + effects: [foldEffect.of({from: 0, to: 3}), foldEffect.of({from: 4, to: 7})] + }).state + ist(ranges(state.field(foldState)), "0-3 4-7") + state = state.update({ + effects: unfoldEffect.of({from: 4, to: 7}) + }).state + ist(ranges(state.field(foldState)), "0-3") + }) + + it("can store fold state as JSON", () => { + let state = EditorState.create({doc, extensions: foldState}).update({ + effects: [foldEffect.of({from: 4, to: 7}), foldEffect.of({from: 8, to: 11})] + }).state + let fields = {fold: foldState} + state = EditorState.fromJSON(state.toJSON(fields), {}, fields) + ist(ranges(state.field(foldState)), "4-7 8-11") + }) +}) diff --git a/vendor/codemirror-language/test/test-stream-parser.ts b/vendor/codemirror-language/test/test-stream-parser.ts new file mode 100644 index 0000000000..f1b9b3a28e --- /dev/null +++ b/vendor/codemirror-language/test/test-stream-parser.ts @@ -0,0 +1,127 @@ +import ist from "ist" +import {EditorState} from "@codemirror/state" +import {Tag} from "@lezer/highlight" +import {StreamLanguage, syntaxTree, getIndentation, Language} from "@codemirror/language" +import {SyntaxNode} from "@lezer/common" + +let startStates = 0, keywords = ["if", "else", "return"] + +const language = StreamLanguage.define<{count: number}>({ + startState() { + startStates++ + return {count: 0} + }, + + token(stream, state) { + if (stream.eatSpace()) return null + state.count++ + if (stream.match(/^\/\/.*/)) return "lineComment" + if (stream.match(/^"[^"]*"/)) return "string" + if (stream.match(/^\d+/)) return "number" + if (stream.match(/^\w+/)) return keywords.indexOf(stream.current()) >= 0 ? "keyword" : "variableName" + if (stream.match(/^[();{}]/)) return "punctuation" + stream.next() + return "invalid" + }, + + indent(state) { + return state.count + } +}) + +describe("StreamLanguage", () => { + it("can parse content", () => { + ist(language.parser.parse("if (x) return 500").toString(), + "Document(keyword,punctuation,variableName,punctuation,keyword,number)") + }) + + it("can reuse state on updates", () => { + let state = EditorState.create({ + doc: "// filler content\nif (a) foo()\nelse if (b) bar()\nelse quux()\n\n".repeat(100), + extensions: language + }) + + startStates = 0 + state = state.update({changes: {from: 5000, to: 5001}}).state + ist(startStates, 0) + }) + + it("can find the correct parse state for indentation", () => { + let state = EditorState.create({ + doc: '"abcdefg"\n'.repeat(200), + extensions: language + }) + ist(getIndentation(state, 0), 0) + ist(getIndentation(state, 10), 1) + ist(getIndentation(state, 100), 10) + ist(getIndentation(state, 1000), 100) + }) + + // Fragile kludge to set the parser context viewport without + // actually having access to the relevant field + function setViewport(state: EditorState, from: number, to: number) { + let field = (Language as any).state + ;(state.field(field) as any).context.updateViewport({from, to}) + } + + it("will make up a state when the viewport is far away from the frontier", () => { + let line = "1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0\n" + let state = EditorState.create({doc: line.repeat(100), extensions: language}) + setViewport(state, 4000, 8000) + state = state.update({changes: {from: 3000, insert: line.repeat(10000)}}).state + let tree = syntaxTree(state) + // No nodes in the skipped range + ist(tree.resolve(10000, 1).name, "Document") + // But the viewport is populated + ist(tree.resolve(805000, 1).name, "number") + let treeSize = 0 + tree.iterate({enter() { treeSize++ }}) + ist(treeSize, 2000, ">") + ist(treeSize, 4000, "<") + setViewport(state, 4000, 8000) + state = state.update({changes: {from: 100000, insert: "?"}}).state + tree = syntaxTree(state) + ist(tree.resolve(5000, 1).name, "number") + ist(tree.resolve(50000, 1).name, "Document") + }) + + it("doesn't parse beyond the viewport", () => { + let line = "1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0\n" + let state = EditorState.create({doc: line.repeat(100), extensions: language}) + setViewport(state, 0, 4000) + state = state.update({changes: {from: 5000, insert: line.repeat(100)}}).state + ist(syntaxTree(state).resolve(2000, 1).name, "number") + ist(syntaxTree(state).resolve(6000, 1).name, "Document") + }) + + function isNode(node: SyntaxNode | null, name: string, from: number, to: number) { + ist(node) + ist(node!.type.name, name) + ist(node!.from, from) + ist(node!.to, to) + } + + it("supports gaps", () => { + let text = "1 50 xxx\nxxx\nxxx 60\n70 xxx80xxx 9xxx0" + let ranges = [{from: 0, to: 5}, {from: 16, to: 23}, {from: 26, to: 28}, {from: 31, to: 33}, {from: 36, to: 37}] + let tree = language.parser.parse(text, [], ranges) + ist(tree.toString(), "Document(number,number,number,number,number,number)") + isNode(tree.resolve(17, 1), "number", 17, 19) + isNode(tree.resolve(20, 1), "number", 20, 22) + isNode(tree.resolve(26, 1), "number", 26, 28) + isNode(tree.resolve(32, 1), "number", 32, 37) + }) + + it("accepts custom token types", () => { + let tag = Tag.define() + let lang = StreamLanguage.define({ + token(stream) { + if (stream.match(/^\w+/)) return "foo" + stream.next() + return null + }, + tokenTable: {foo: tag} + }) + ist(lang.parser.parse("hello").toString(), "Document(foo)") + }) +}) diff --git a/vendor/codemirror-language/test/test-syntax.ts b/vendor/codemirror-language/test/test-syntax.ts new file mode 100644 index 0000000000..7b4830e119 --- /dev/null +++ b/vendor/codemirror-language/test/test-syntax.ts @@ -0,0 +1,64 @@ +import ist from "ist" +import {getIndentUnit, indentString, indentUnit, ParseContext} from "@codemirror/language" +import {EditorState, ChangeSet, Text} from "@codemirror/state" +import {parser} from "@lezer/javascript" + +let lines = `const {readFile} = require("fs"); +readFile("package.json", "utf8", (err, data) => { + console.log(data); +}); +`.split("\n") +for (let l0 = lines.length, i = l0; i < 5000; i++) lines[i] = lines[i % l0] +let doc = Text.of(lines) + +function pContext(doc: Text) { + return ParseContext.create(parser, EditorState.create({doc}), {from: 0, to: doc.length}) +} + +describe("ParseContext", () => { + it("can parse a document", () => { + let cx = pContext(Text.of(["let x = 10"])) + cx.work(1e8) + ist(cx.tree.toString(), "Script(VariableDeclaration(let,VariableDefinition,Equals,Number))") + }) + + it("can parse incrementally", () => { + let cx = pContext(doc), t0 = Date.now() + if (cx.work(10)) { + console.warn("Machine too fast for the incremental parsing test, skipping") + return + } + ist(Date.now() - t0, 25, "<") + ist(cx.work(1e8)) + ist(cx.tree.length, doc.length) + let change = ChangeSet.of({from: 0, to: 5, insert: "let"}, doc.length) + let newDoc = change.apply(doc) + cx = cx.changes(change, EditorState.create({doc: newDoc})) + ist(cx.work(50)) + ist(cx.tree.length, newDoc.length) + ist(cx.tree.toString().slice(0, 31), "Script(VariableDeclaration(let,") + }) +}) + +describe("Indentation", () => { + it("tracks indent units", () => { + let s0 = EditorState.create({}) + ist(getIndentUnit(s0), 2) + ist(indentString(s0, 4), " ") + let s1 = EditorState.create({extensions: indentUnit.of(" ")}) + ist(getIndentUnit(s1), 3) + ist(indentString(s1, 4), " ") + let s2 = EditorState.create({extensions: [indentUnit.of("\t"), EditorState.tabSize.of(8)]}) + ist(getIndentUnit(s2), 8) + ist(indentString(s2, 16), "\t\t") + let s3 = EditorState.create({extensions: indentUnit.of(" ")}) + ist(getIndentUnit(s3), 1) + ist(indentString(s3, 2), "  ") + }) + + it("errors for bad indent units", () => { + ist.throws(() => EditorState.create({extensions: indentUnit.of("")}), /Invalid indent unit/) + ist.throws(() => EditorState.create({extensions: indentUnit.of("\t ")}), /Invalid indent unit/) + ist.throws(() => EditorState.create({extensions: indentUnit.of("hello")}), /Invalid indent unit/) + }) +}) diff --git a/vendor/codemirror-view/ANDROID-NOTICE.md b/vendor/codemirror-view/ANDROID-NOTICE.md new file mode 100644 index 0000000000..52291773a5 --- /dev/null +++ b/vendor/codemirror-view/ANDROID-NOTICE.md @@ -0,0 +1,14 @@ +# Android spline attribution + +The one-dimensional fling calculations in `src/android-spline.ts` are derived +from Android Open Source Project `android.widget.OverScroller`, as distributed +with Android SDK Platform 36. Acode ported the calculations to TypeScript and +modified them for CSS-pixel, one-dimensional, render-gated scrolling. + +Copyright (C) 2010 The Android Open Source Project + +Licensed under the Apache License, Version 2.0. The complete license is included +in [`LICENSE-APACHE-2.0`](LICENSE-APACHE-2.0). + +Upstream source: + diff --git a/vendor/codemirror-view/CHANGELOG.md b/vendor/codemirror-view/CHANGELOG.md new file mode 100644 index 0000000000..d54f94e367 --- /dev/null +++ b/vendor/codemirror-view/CHANGELOG.md @@ -0,0 +1,2394 @@ +## 6.43.9 (2026-08-16) + +### Bug fixes + +Fix an issue where the scroll position would incorrectly be moved up when a document scrolled to the bottom would lose height. + +## 6.43.8 (2026-08-04) + +### Bug fixes + +Fix an issue that could cause identical `posAtCoords` queries to return different results. + +Fix an issue where scrolling a position into view vertically on Android or iOS could cause horizontal scrolling motion. + +## 6.43.7 (2026-07-27) + +### Bug fixes + +Make the panel container top/bottom style a regular rule, rather than an inline style. + +Fix an issue where widgets changing length could break content updates. + +Fix incorrectly drawn selection when a line wrap point lies between widgets. + +Work around regression in Firefox 153 breaking selection hiding. + +## 6.43.6 (2026-07-06) + +### Bug fixes + +Fix another tile tree corruption bug that could occur on zero-length content updates. + +## 6.43.5 (2026-07-04) + +### Bug fixes + +Fix an issue what would cause `domAtPos` to return positions on the wrong side of widgets. + +Fix an issue that could cause the DOM cursor to be drawn on the wrong side of a collapsing decoration. + +Don't return off-by-one coordinate positions at the edge of right-to-left text node in Safari. + +Don't abort momentum scrolling on iOS by stabilizing the scroll position. + +## 6.43.4 (2026-06-27) + +### Bug fixes + +Fix a regression in DOM document updates that could corrupt the tile tree and cause crashes. + +## 6.43.3 (2026-06-25) + +### Bug fixes + +Fix a bug in the content DOM update code that could corrupt the editor state. + +## 6.43.2 (2026-06-23) + +### Bug fixes + +Fix using select-all from the native context menu on Chrome Android. + +On iOS when autocapitalize is enabled, ignore the shift modifier on virtual keyboard Enter or Backspace presses. + +Work around an issue where Chrome Android scrolled the editor up when tapping an empty line to focus it. + +Create undirectional selection ranges for double and triple clicks. + +## 6.43.1 (2026-06-09) + +### Bug fixes + +Work around an issue in with some IMEs (like Tibetan) in MacOS Chrome where composition could get interrupted needlessly. + +Fix a crash when calling on a line with only `display: none` content. + +Improve reuse of widgets near composition, which in some cases prevents Safari from doing odd thing with the selection. + +## 6.43.0 (2026-05-14) + +### New features + +Block wrappers can now be given a rank to set a specific nesting order for wrappers coming from the same source. + +## 6.42.1 (2026-05-07) + +### Bug fixes + +Fix a bug where block wrappers could be applied incorrectly during composition. + +Fix an issue leading to constant text geometry resets in variable-width fonts. + +## 6.42.0 (2026-05-06) + +### Bug fixes + +Make sure `posAtCoords` doesn't recurse endlessly. + +### New features + +The new `activateHover` function can be used to explicitly activate hover tooltips at a given position. + +`closeHoverTooltip` now allows you to close a specific hover tooltip. + +## 6.41.1 (2026-04-18) + +### Bug fixes + +Fix an issue where, on some platforms, clicking after the end of a wrapped line would put the cursor in the wrong position. + +## 6.41.0 (2026-04-01) + +### Bug fixes + +Fix an issue where `EditorView.posAtCoords` could incorrectly return a position near a higher element on the line, in mixed-font-size lines. + +Expand the workaround for the Webkit bug that causes nonexistent selections to stay visible to be active on non-Safari Webkit browsers. + +### New features + +The new `EditorView.cursorScrollMargin` facet can now be used to configure the extra space used when scrolling the cursor into view. + +## 6.40.0 (2026-03-12) + +### Bug fixes + +Fix a bug that caused Shift-Enter/Backspace/Delete on iOS to lose the shift modifier when delivered to key event handlers. + +Fix an issue where `EditorView.moveVertically` could move to the wrong place in wrapped lines with a large line height. + +Make sure the selection head associativity is properly set for mouse selections made with shift held down. + +### New features + +`WidgetType.updateDOM` is now called with the previous widget value as third argument. + +## 6.39.17 (2026-03-10) + +### Bug fixes + +Improve touch tap-selection on line wrapping boundaries. + +Make `drawSelection` draw our own selection handles on iOS. + +Fix an issue where `posAtCoords`, when querying line wrapping points, got confused by extra empty client rectangles produced by Safari. + +## 6.39.16 (2026-03-02) + +### Bug fixes + +Perform scroll stabilization on the document or wrapping scrollable elements, when the user scrolls the editor. + +Fix an issue where changing decorations right before a composition could end up corrupting the visible DOM. + +Fix an issue where some types of text input over a selection would be read as happening in wrong position. + +## 6.39.15 (2026-02-20) + +### Bug fixes + +Fix a regression where the editor would forget previously measured line heights without good reason. + +Fix an issue where scrolling the cursor into view sometimes wouldn't work on Chrome Android. + +Fix a bug that broke composition inside of block wrappers. + +## 6.39.14 (2026-02-12) + +### Bug fixes + +Improve performance of `posAtCoords` on long lines. + +Fix a regression where copy and cut in a shadow DOM on Safari would fall back to the native behavior, often copying the wrong text. + +## 6.39.13 (2026-02-08) + +### Bug fixes + +Fix an issue where a widget at start or end of line, when wrapped to cover that whole line, could block vertical cursor motion. + +Fix an issue `EditorView.moveVertically` that would sometimes cause selection-extending vertical motion to get stuck on line wrapping points. + +## 6.39.12 (2026-01-30) + +### Bug fixes + +Fix a bug where the visual selection drawn by `drawSelection` could fail to update properly in some circumstances. + +Fix a bug where PageUp/PageDown near the edge of the viewport might completely skip to the start/end of the document. + +Fix a regression that caused mark decorations to be split on text node chunk boundaries again. + +## 6.39.11 (2026-01-14) + +### Bug fixes + +Avoid handling copy events for parent editors. + +## 6.39.10 (2026-01-13) + +### Bug fixes + +Fix a regression in the way widget are reused when content next to them changes. + +Make sure font metrics get recomputed on `fonts.ready` even if the line height doesn't change. + +Fix an issue where compositions next to a widget that create a new text node could get needlessly interrupted during an editor update. + +## 6.39.9 (2026-01-06) + +### Bug fixes + +Fix a bug where `EditorSelection.cursor()` with a non-zero `assoc` value would not be visually respected at soft-wrap boundaries on initial view creation. + +Fix error caused by hover tooltips running a scheduled timeout after their editor has been destroyed. + +Fix a bug that caused `EditorView.outerDecorations` to not affect the content height map. + +Fix an issue where composition near a widget could get unnecessarily interrupted. + +## 6.39.8 (2025-12-30) + +### Bug fixes + +Fix a bug that cause `coordsAtPos` to use the dimensions of widget buffers when there were more meaningful elements to use nearby. + +Fix a data structure corruption that could cause crashes during viewport changes. + +## 6.39.7 (2025-12-24) + +### Bug fixes + +Fix a bug that could sometimes cause the document to become mangled during composition. + +## 6.39.6 (2025-12-23) + +### Bug fixes + +Fix an issue when composing on the boundary of a decoration, where the text after the composition would get garbled. + +## 6.39.5 (2025-12-22) + +### Bug fixes + +Fix an issue where replaced widgets alone on a line weren't reused and didn't get their `updateDOM` method called. + +Fix a bug where, when selecting full lines at the end of the document and inserting a character on Chrome, an inappropriate extra newline was inserted. + +## 6.39.4 (2025-12-12) + +### Bug fixes + +Fix a bug where paste events handlers on Chrome could fail to run when pasting on a blank line. + +Fix a regression causing the native cursor to get stuck before block widgets with side>0. + +Fix a crash in content DOM building after a block widget. + +Fix a bug in `posAtCoords` that would in some circumstances make it return positions on the wrong side of a block widget. + +## 6.39.3 (2025-12-11) + +### Bug fixes + +Fix a bug that could corrupt the rendered document in some situations involving adjacent mark decorations of the same type. + +## 6.39.2 (2025-12-09) + +### Bug fixes + +Fix an issue where `moveVertially` was sometimes unable to escape lines with thick borders or padding. + +## 6.39.1 (2025-12-09) + +### Bug fixes + +Restore a workaround for a Chrome selection bug that had regressed in the previous release. + +## 6.39.0 (2025-12-08) + +### Bug fixes + +Properly handle bidirectional text in `posAtCoords`. + +Avoid computing a zero character width (leading to divisions by zero) when the editor is hidden and the browser doesn't have a layout for it. + +### New features + +The `posAndSideAtCoords` method is an extended version of `posAtCoords` that also tells you which side of the position the coordinates are associated with. + +Add support for block wrappers, decoration-like things that allow extension code to create DOM nodes around groups of lines. + +## 6.38.8 (2025-11-17) + +### Bug fixes + +Improve handling of composition with multiple cursors on MacOS. + +Fix an issue where computing a document position from screen coordinates would sometimes go wrong in right-to-left text. + +## 6.38.7 (2025-11-14) + +### Bug fixes + +Make detection of transformed tooltip parent elements (forcing absolute positioning) more robust on current browsers. + +Avoid an issue where on Chrome and Safari, typing over a cross-line selection can replace widgets on the line after the selection with their plain text content. + +Fix a bug that broke insertion of composed input at multiple cursors when the IME keeps the selection at the start of the composed text. + +## 6.38.6 (2025-10-13) + +### Bug fixes + +Work around a regression in Safari 26 that causes fragments of old selections to remain visible. + +## 6.38.5 (2025-10-07) + +### Bug fixes + +Avoid firing text changes that cover unchanged text on Android. + +Fix an issue where the editor could, in some circumstances, insert a stray newline when typing over a document that ended in a block widget. + +Work around an issue in Safari 26 that causes inappropriate scrolling on focus in some circumstances. + +## 6.38.4 (2025-09-28) + +### Bug fixes + +Work around a Chrome Android issue where the browser doesn't properly fire composition end events, leaving CodeMirror to believe the user was still composing. + +## 6.38.3 (2025-09-22) + +### Bug fixes + +Work around a rendering bug in Mobile Safari by completely hiding empty layers. + +Fix vertical cursor motion in Chrome around decorations with bottom borders or margins. + +Fix an issue that caused mark decorations longer than 512 characters to needlessly be split. + +Move the cursor out of atomic ranges when text input happens. + +## 6.38.2 (2025-09-01) + +### Bug fixes + +Re-enable falling dispatching keys by key code for Cmd-Alt- combinations on macOS. + +Make sure all pointer selections skip atomic ranges. + +## 6.38.1 (2025-07-15) + +### Bug fixes + +Make the keymap not dispatch Alt key combos on macOS by key code, because those are generally used to type special characters. + +Fix a layout bug that could occur with very narrow editors. + +## 6.38.0 (2025-06-27) + +### New features + +Gutters can now specify that they should be displayed after the content (which would be to the right in a left-to-right layout). + +## 6.37.2 (2025-06-12) + +### Bug fixes + +Fix an issue where moving the cursor vertically from the one-but-last character on a line would sometimes move incorrectly on Safari. + +Fix an issue causing coordinates between lines of text to sometimes be inappropriately placed at the end of the line by `posAtCoords`. + +## 6.37.1 (2025-05-30) + +### Bug fixes + +Properly add `crelt` as a dependency. + +## 6.37.0 (2025-05-29) + +### New features + +View plugins can now take an argument, in which case they must be instantiated with their `of` method in order to be added to a configuration. + +The new `showDialog` function makes it easy to show a notification or prompt using a CodeMirror panel. + +## 6.36.8 (2025-05-12) + +### Bug fixes + +Make `logException` log errors to the console when `onerror` returns a falsy value. + +Fix an issue in `MatchDecorator` causing `updateDeco` to sometimes not do the right thing for deletions. + +## 6.36.7 (2025-05-02) + +### Bug fixes + +Use the `aria-placeholder` attribute to communicate the placeholder text to screen readers. + +Fix a crash when `EditorView.composing` or `.compositionStarted` are accessed during view initialization. + +## 6.36.6 (2025-04-24) + +### Bug fixes + +Fix an issue where `drawSelection` would draw selections starting at a block widget not at a line break in an odd way. + +Fix an issue where the editor would inappropriately scroll when editing near the bottom of the document with line wrapping enabled, in some cases. + +Fix an issue that caused unnecessary transactions on focus change. + +## 6.36.5 (2025-03-29) + +### Bug fixes + +Fix an issue where some browsers wouldn't enable context menu paste when clicking on placeholder text. + +Fix an issue where cursor height would unnecessarily be based on a placeholder node's dimensions, and thus be off from the text height. + +## 6.36.4 (2025-03-03) + +### Bug fixes + +Fix an issue where scrolling down to a range higher than the viewport could in some situations fail to scroll to the proper position. + +## 6.36.3 (2025-02-18) + +### Bug fixes + +Make sure event handlers registered with `domEventHandlers` are not called during view updates, to avoid triggering nested update errors. + +Don't include the window scrollbars in the space available for displaying tooltips. + +Work around an issue with Chrome's `EditContext` that shows up when using autocompletion while composing with Samsung's virtual Android keyboard. + +## 6.36.2 (2025-01-09) + +### Bug fixes + +Fix an issue where some kinds of relayouts could put the editor in a state where it believed it wasn't in window, preventing relayout, though it in fact was. + +Make sure macOS double-space-to-period conversions are properly suppressed. + +Fix an issue where native selection changes, such as mobile spacebar-drag, weren't being picked up in edit context mode. + +## 6.36.1 (2024-12-19) + +### Bug fixes + +Fix a crash in MatchDecorator when updating matches at the end of the document. + +## 6.36.0 (2024-12-17) + +### Bug fixes + +Make selection rectangles verticaly align precisely, rather than introducing a slight overlap. + +Fix an issue in `MatchDecorator` that caused it to fully rebuild its decorations on normal edits. + +### New features + +View updates now have a `viewportMoved` flag that is only true when a viewport change originated from something other than mapping the viewport over a document change. + +## 6.35.3 (2024-12-09) + +### Bug fixes + +Fix an issue where mark decorations that got merged or split weren't properly redrawn. + +Avoid spurious focus events by not updating the DOM selection when the editor is unfocused but focusable. + +Disable `writingsuggestions` for the editable element, to opt out of Safari's new intelligence completions (which mess up in the editor). + +## 6.35.2 (2024-12-07) + +### Bug fixes + +Fix an issue on Chrome where typing at the end of the document would insert a character after the cursor. + +## 6.35.1 (2024-12-06) + +### Bug fixes + +Work around another crash caused by incorrect composition positions reported by `EditContext`. + +Stop disabling custom cursors on Safari version 11.4 and up, which support `caret-color`. + +Fix an issue where a tooltip with wrapped content could, in some circumstances, fail to find a stable position due to a cyclic dependency between its width and its position. + +## 6.35.0 (2024-11-21) + +### New features + +Tooltips can now use the `clip` option to control whether they are hidden when outside the visible editor content. + +## 6.34.3 (2024-11-15) + +### Bug fixes + +Make sure positions covered by a gutter or a panel aren't treated as visible for the purpose of displaying tooltips. + +Properly include the tooltip arrow height when checking whether a tooltip fits in its preferred above/below position. + +Fix an issue with compositions on Chrome inserting their content in the wrong position when another document change came in during composition. + +## 6.34.2 (2024-11-05) + +### Bug fixes + +Fix the default cursor color for dark themes, which was way too dark. + +## 6.34.1 (2024-09-27) + +### Bug fixes + +Avoid a stack overflow that could happen when updating a line with a lot of text tokens. + +Improve the way enormously long (non-wrapped) lines are displayed by making sure they stay shorter than the maximal pixel size the browser's CSS engine can handle. + +## 6.34.0 (2024-09-25) + +### Bug fixes + +Fix an issue where the dots past the wrapping point were displayed incorrectly when using `highlightWhitespace` with a wrapped sequence of spaces. + +Improve performance of documents displaying lots of highlighted spaces by using a CSS background instead of pseudo-element. + +### New features + +`placeholder` now allows a function that constructs the placedholder DOM to be passed in, and uses `cloneNode` when a raw element is passed in, to prevent adding the same element to multiple editors. + +## 6.33.1 (2024-08-30) + +### Bug fixes + +Work around odd behavior in Chrome's newly supported `caretPositionFromPoint` method, which could cause CodeMirror to crash with a null dereference. + +## 6.33.0 (2024-08-24) + +### Bug fixes + +Make it easier to move the pointer over a hover tooltip with an arrow by not closing the tooltip when the pointer is moving over the gap for the arrow. + +### New features + +The new `EditorView.clipboardInputFilter` and `clipboardOutputFilter` facets allow you to register filter functions that change text taken from or sent to the clipboard. + +## 6.32.0 (2024-08-12) + +### Bug fixes + +Fix a bug where the editor could draw way too big a viewport when not managing its own scrollbar. + +### New features + +The new `gutterWidgetClass` facet makes it possible to add a class to gutter elements next to widgets. + +## 6.31.0 (2024-08-11) + +### Bug fixes + +Avoid the editor's geometry measurements becoming incorrect when fonts finish loading by scheduling a measure on `document.fonts.ready`. + +Avoid an issue where Chrome would incorrectly scroll the window when deleting lines in the editor. + +Fix an issue where in some layouts editor content would be drawn on top of panel elements. + +Fix an issue where `coordsAtPos` would return null when querying a position in a block widget. + +### New features + +The new `lineNumberWidgetMarker` facet makes it possible to insert markers into the line number gutter for widgets. + +## 6.30.0 (2024-08-05) + +### Bug fixes + +Make spell check corrections work again on `EditContext`-enabled Chrome versions. + +### New features + +The value returned by `hoverTooltip` now has an `active` property providing the state field used to store the open tooltips. + +## 6.29.1 (2024-07-29) + +### Bug fixes + +Fix a crash on old Safari browsers that don't support `MediaQueryList.addEventListener`. + +Fix an issue where `EditorView.viewportLineBlocks` (and thus other things like the gutter) might be out of date after some kinds of decoration changes. + +## 6.29.0 (2024-07-25) + +### Bug fixes + +Fix an issue that caused typing into an editor marked read-only to cause document changes when using `EditContext`. + +Associate a cursor created by clicking above the end of the text on a wrap point with the line before it. + +### New features + +The package now exports the type of hover tooltip sources as `HoverTooltipSource`. + +## 6.28.6 (2024-07-19) + +### Bug fixes + +Fix an issue where the editor got confused about the position of inserted text when using Chrome's `EditContext` and canceling transactions for typed text. + +## 6.28.5 (2024-07-17) + +### Bug fixes + +Fix a bug that broke drag scrolling along one axis when the innermost scrollable element around the editor was only scrollable along the other axis. + +Work around a memory leak in Chrome's EditContext implementation. + +## 6.28.4 (2024-07-03) + +### Bug fixes + +Fix a bug where EditContext-based editing could corrupt the document in some situations. + +## 6.28.3 (2024-07-01) + +### Bug fixes + +Fix an issue causing the IME interface to appear in the wrong spot on Chrome Windows. + +## 6.28.2 (2024-06-21) + +### Bug fixes + +Only use `EditContext` on Chrome versions that support passing it an inverted selection range. + +Fix an issue that prevented non-inclusive block widgets from having their `updateDOM` method called when changed. + +Re-enable `EditContext` use on Chrome 126 and up. + +## 6.28.1 (2024-06-12) + +### Bug fixes + +Disable `EditContext` by default again, to work around a regression where Chrome's implementation doesn't support inverted selections. + +Make sure `EditorView.editable` is respected when `EditContext` is used. + +## 6.28.0 (2024-06-10) + +### Bug fixes + +Fix an issue where long lines broken up by block widgets were sometimes only partially rendered. + +### New features + +The editor will now, when available (which is only on Chrome for the foreseeable future) use the [`EditContext`](https://developer.mozilla.org/en-US/docs/Web/API/EditContext) API to capture text input. + +## 6.27.0 (2024-06-04) + +### New features + +The new `setTabFocusMode` method can be used to control whether the editor disables key bindings for Tab and Shift-Tab. + +## 6.26.4 (2024-06-04) + +### Bug fixes + +Fix an issue where commands with an optional second argument would get the keyboard event in that argument when called from a keymap. + +Fix an issue that could cause the cursor to be rendered on the wrong side of a zero-length block widget. + +Fix an issue where `drawSelection` got confused by block widgets in line-wrapped editors in some situations. + +Don't hide the native selection in widgets that have focus. + +Make sure that clicking an unfocusable editor still remove focus from any other focused elements. + +Fix a crash when loading the package in a non-browser environment. + +Stop mouse selection when the user types. + +## 6.26.3 (2024-04-12) + +### Bug fixes + +Fix an issue where dispatching an update to an editor before it measured itself for the first time could cause the scroll position to incorrectly move. + +Fix a crash when multiple tooltips with arrows are shown. + +## 6.26.2 (2024-04-09) + +### Bug fixes + +Improve behavior of `scrollPastEnd` in a scaled editor. + +When available, use `Selection.getComposedRanges` on Safari to find the selection inside a shadow DOM. + +Remove the workaround that avoided inappropriate styling on composed text after a decoration again, since it breaks the stock Android virtual keyboard. + +## 6.26.1 (2024-03-28) + +### Bug fixes + +Fix the editor getting stuck in composition when Safari fails to fire a compositionend event for a dead key composition. + +Fix an issue where, with IME systems that kept the cursor at the start of the composed text, the editor misidentified the target node and disrupted composition. + +Fix a bug where in a line-wrapped editor, with some content, the initial scroll position would be off from the top of the document. + +## 6.26.0 (2024-03-14) + +### Bug fixes + +Avoid the editor getting confused when iOS autocorrects on pressing Enter and does the correction and the break insertion in two different events. + +Fix the pasting of copied URIs in iOS. + +Fix a bug where a scaled editor could keep performing unnecessary updates due to tiny differences in geometry values returned by the browser. + +Fix a bug where, on iOS with a physical keyboard, the modifiers for some keys weren't being passed to the keymaps. + +Work around the fact that Mobile Safari makes DOM changes before firing a key event when typing ctrl-d on an external keyboard. + +Fix an issue where some commands didn't properly scroll the cursor into view on Mobile Safari. + +Re-measure the document when print settings are changed on Chrome. + +### New features + +The `EditorView.scrollHandler` facet can be used to override or extend the behavior of the editor when things are scrolled into view. + +## 6.25.1 (2024-03-06) + +### Bug fixes + +Fix accidental non-optional field in layer config objects. + +## 6.25.0 (2024-03-04) + +### Bug fixes + +Properly recognize Android GBoard enter presses that strip a space at the end of the line as enter. + +Fix a bug that caused the gutter to have the wrong height when the editor was scaled after construction. + +When starting a composition after a non-inclusive mark decoration, temporarily insert a widget that prevents the composed text from inheriting that mark's styles. + +Make sure the selection is repositioned when a transaction changes decorations without changing the document. + +### New features + +View plugins can now provide a `docViewUpdate` method that is called whenever the document view is updated. + +Layers now take a `updateOnDocUpdate` option that controls whether they are automatically updated when the document view changes. + +## 6.24.1 (2024-02-19) + +### Bug fixes + +Fix a crash that happens when hover tooltips are active during changes, introduced in 6.24.0. + +## 6.24.0 (2024-02-09) + +### Bug fixes + +Fix an issue that broke context-menu select-all on Chrome when the viewport didn't cover the whole document. + +Make sure tooltips are ordered by extension precedence in the DOM. + +### New features + +Hover tooltip sources may now return multiple tooltips. + +## 6.23.1 (2024-01-24) + +### Bug fixes + +Fix a bug that caused `Tooltip.above` to not take effect for tooltips that were already present when the tooltip plugin is initialized. + +Automatically reposition tooltips when their size changes. + +## 6.23.0 (2023-12-28) + +### Bug fixes + +Work around odd iOS Safari behavior when doing select all. + +Fix a composition interruption when an widget is inserted next to the cursor. + +Fix a crash in bidirectional cursor motion. + +Simplify visual motion through bidirectional text, fix several corner cases where it would work badly. + +Fix a bug that broke some bidi isolates not on the first line of the document. + +### New features + +`EditorView.bidiIsolatedRanges` now supports automatically determining the direction of the range if not provided by the decoration. + +`EditorView.visualLineSide` can be used to find the visual end or start of a line with bidirectional text. + +The new `EditorView.outerDecorations` facet can be used to provide decorations that should always be at the bottom of the precedence stack. + +## 6.22.3 (2023-12-13) + +### Bug fixes + +Fix a bug that could cause tooltips to be unnecessarily be positioned absolutely. + +Make sure that, when an editor creates tooltips immediately on initialization, the editor is attached to the document when their `mount` callback is called. + +## 6.22.2 (2023-12-08) + +### Bug fixes + +Fix an issue in the bidirectional motion that could cause the cursor to get stuck in a loop when a zero-width non-joiner char was placed on a direction boundary. + +Fix a bug that corrupts the editor's internal view tree data structure on some types of edits, putting the editor in a broken state. + +## 6.22.1 (2023-11-27) + +### Bug fixes + +Call widget `destroy` methods when the entire editor is destroyed or reset. + +Work around an issue on Safari on macOS Sonoma that made the native cursor visible even when `drawSelection` is enabled. + +Fix an issue where, on some browsers, the screenreader announced text ended up in the printed document. + +Fix a bug where a hover tooltip could stick around even though the pointer was no longer on the editor when it was moved out over the tooltip. + +Fix an issue where hover tooltips could close when moving the mouse onto them due to mouse position rounding issues. + +## 6.22.0 (2023-11-03) + +### Bug fixes + +Exceptions raised by update listeners are now routed to the configured exception sink, if any. + +Fix an issue where passing large scroll margins to `scrollIntoView` would cause the measure loop to fail to terminate. + +Widgets that are draggable (and allow drag events through in their `ignoreEvent` implementation) can now use the editor's built-in drag/drop behavior. + +### New features + +The new `scrollTo` option to `EditorView` allows an initial scroll position to be provided. + +The new `EditorView.scrollSnapshot` method returns an effect that can be used to reset to a previous scroll position. + +## 6.21.4 (2023-10-24) + +### Bug fixes + +Support the `offset`, `getCoords`, `overlap`, and `resize` properties on hover tooltips, as long as they aren't given conflicting values when there are multiple active hover tooltips. + +Fix a bug that caused tooltips in the default configuration to be positioned incorrectly on Chrome when the editor was transformed. + +## 6.21.3 (2023-10-06) + +### Bug fixes + +Fix an issue that caused `coordsForChar` to return the wrong rectangle for characters after a line wrap in Safari. + +Make the context menu work when clicking below the content in a fixed-height editor. + +Tooltips that have been put below/above their target position because there is no room on their default side now stay there on further updates. + +## 6.21.2 (2023-10-02) + +### Bug fixes + +Fix a regression that broke dragging text from inside the editor. + +## 6.21.1 (2023-10-02) + +### Bug fixes + +Fix a bug that could corrupt the DOM view for specific changes involving newlines and mark decorations. + +## 6.21.0 (2023-09-29) + +### Bug fixes + +Fix a bug that could cause zero-length widgets at the start of a line to be left in the view even after they were removed. + +### New features + +`RectangleMarker`'s dimension properties are now public. + +## 6.20.2 (2023-09-25) + +### Bug fixes + +Fix an issue in the way the DOM selection is being read that could break backspacing of widgets on Android. + +Fix a bug where the editor could incorrectly computate its transform scale when it was small. + +## 6.20.1 (2023-09-22) + +### Bug fixes + +Fix a crash in plugin event handlers after dynamic reconfiguration. + +Fix an issue where, on Chrome, tooltips would no longer use fixed positioning. + +## 6.20.0 (2023-09-20) + +### Bug fixes + +Fix an issue that caused `repositionTooltips` to crash when it was called on an editor without tooltips. + +Fix an issue that caused the tooltip system to leave empty nodes in the DOM when an editor using the `parent` option to `tooltips` is destroyed. + +Fix a bug that regression mouse interaction with the area of a fixed-size editor that isn't covered by the content. + +Fix some issues with the way `moveVertically` behaved for positions on line wrap points. + +Fix a bug that could cause the document DOM to be incorrectly updated on some types of viewport changes. + +### New features + +The new `getDrawSelectionConfig` function returns the `drawSelection` configuration for a given state. + +## 6.19.0 (2023-09-14) + +### Bug fixes + +Make sure the drop cursor is properly cleaned up even when another extension handles the drop event. + +Fix a crash related to non-inclusive replacing block decorations. + +### New features + +The new `EditorView.domEventObservers` (and the corresponding option to view plugins) allows you to register functions that are always called for an event, regardless of whether other handlers handled it. + +## 6.18.1 (2023-09-11) + +### Bug fixes + +Fix an issue where the editor duplicated text when the browser moved content into the focused text node on composition. + +Make sure `widgetMarker` is called for gutters on lines covered by a block replace decoration. + +Fix an issue where the cursor could be shown in a position that doesn't allow a cursor when the selection is in a block widget. + +## 6.18.0 (2023-09-05) + +### New features + +The new `EditorView.scaleX` and `scaleY` properties return the CSS-transformed scale of the editor (or 1 when not scaled). + +The editor now supports being scaled with CSS. + +## 6.17.1 (2023-08-31) + +### Bug fixes + +Don't close the hover tooltip when the pointer moves over empty space caused by line breaks within the hovered range. + +Fix a bug where on Chrome Android, if a virtual keyboard was slow to apply a change, the editor could end up dropping it. + +Work around an issue where line-wise copy/cut didn't work in Firefox because the browser wasn't firing those events when nothing was selected. + +Fix a crash triggered by the way some Android IME systems update the DOM. + +Fix a bug that caused replacing a word by an emoji on Chrome Android to be treated as a backspace press. + +## 6.17.0 (2023-08-28) + +### Bug fixes + +Fix a bug that broke hover tooltips when hovering over a widget. + +### New features + +The new `EditorView.cspNonce` facet can be used to provide a Content Security Policy nonce for the library's generated CSS. + +The new `EditorView.bidiIsolatedRanges` can be used to inform the editor about ranges styled as Unicode bidirection isolates, so that it can compute the character order correctly. + +`EditorView.dispatch` now also accepts an array of transactions to be applied together in a single view update. + +The new `dispatchTransactions` option to `new EditorView` now replaces the old (deprecated but still supported) `dispatch` option in a way that allows multiple transactions to be applied in one update. + +Input handlers are now passed an additional argument that they can use to retrieve the default transaction that would be applied for the insertion. + +## 6.16.0 (2023-07-31) + +### Bug fixes + +Fix an issue that made the gutter not stick in place when the editor was in a right-to-left context. + +### New features + +The new `EditorView.coordsForChar` method returns the client rectangle for a given character in the editor. + +## 6.15.3 (2023-07-18) + +### Bug fixes + +Fix another crash regression for compositions before line breaks. + +## 6.15.2 (2023-07-18) + +### Bug fixes + +Fix the check that made sure compositions are dropped when the selection is moved. + +## 6.15.1 (2023-07-18) + +### Bug fixes + +Fix a regression that could cause the composition content to be drawn incorrectly. + +## 6.15.0 (2023-07-17) + +### Bug fixes + +Fix dragging a selection from inside the current selection on macOS. + +Fix an issue that could cause the scroll position to jump wildly + +Don't try to scroll fixed-positioned elements into view by scrolling their parent elements. + +Fix a bug that caused the cursor to be hidden when showing a placeholder that consisted of the empty string. + +Resolve some issues where composition could incorrectly affect nearby replaced content. + +### New features + +Key bindings can now set a `stopPropagation` field to cause the view to stop the key event propagation when it considers the event handled. + +## 6.14.1 (2023-07-06) + +### Bug fixes + +Fix an issue where scrolling up through line-wrapped text would sometimes cause the scroll position to pop down. + +Fix an issue where clicking wouldn't focus the editor on Firefox when it was in an iframe and already the active element of the frame. + +Fix a bug that could cause compositions to be disrupted because their surrounding DOM was repurposed for some other piece of content. + +Fix a bug where adding content to the editor could inappropriately move the scroll position. + +Extend detection of Enter presses on Android to `beforeInput` events with an `"insertLineBreak"` type. + +## 6.14.0 (2023-06-23) + +### Bug fixes + +When dragging text inside the editor, look at the state of Ctrl (or Alt on macOS) at the time of the drop, not the start of drag, to determine whether to move or copy the text. + +Fix an issue where having a bunch of padding on lines could cause vertical cursor motion and `posAtCoords` to jump over lines. + +### New features + +Block widget decorations can now be given an `inlineOrder` option to make them appear in the same ordering as surrounding inline widgets. + +## 6.13.2 (2023-06-13) + +### Bug fixes + +Fix an issue in scroll position stabilization for changes above the visible, where Chrome already does this natively and we ended up compensating twice. + +## 6.13.1 (2023-06-12) + +### Bug fixes + +Fix a bug where the cursor would in some circumstances be drawn on the wrong side of an inline widget. + +Fix an issue where `scrollPastEnd` could cause the scroll position of editors that weren't in view to be changed unnecessarily. + +## 6.13.0 (2023-06-05) + +### Bug fixes + +Forbid widget decoration side values bigger than 10000, to prevent them from breaking range ordering invariants. + +Fix a bug where differences between widgets' estimated and actual heights could cause the editor to inappropriately move the scroll position. + +Avoid another situation in which composition that inserts line breaks could corrupt the editor DOM. + +### New features + +Inline widgets may now introduce line breaks, if they report this through the `WidgetType.lineBreaks` property. + +## 6.12.0 (2023-05-18) + +### Bug fixes + +Remove an accidentally included `console.log`. + +### New features + +`EditorViewConfig.dispatch` is now passed the view object as a second argument. + +## 6.11.3 (2023-05-17) + +### Bug fixes + +Make sure pointer selection respects `EditorView.atomicRanges`. + +Preserve DOM widgets when their decoration type changes but they otherwise stay in the same place. + +Fix a bug in `drawSelection` that could lead to invisible or incorrect selections for a blank line below a block widget. + +## 6.11.2 (2023-05-13) + +### Bug fixes + +Fix a bug where the `crosshairCursor` extension could, when non-native key events were fired, trigger disruptive and needless view updates. + +Fix an Android issue where backspacing at the front of a line with widget decorations could replace those decorations with their text content. + +Respect scroll margins when scrolling the target of drag-selection into view. + +Validate selection offsets reported by the browser, to work around Safari giving us invalid values in some cases. + +## 6.11.1 (2023-05-09) + +### Bug fixes + +Don't preserve the DOM around a composition that spans multiple lines. +## 6.11.0 (2023-05-03) + +### New features + +Gutters now support a `widgetMarker` option that can be used to add markers next to block widgets. + +## 6.10.1 (2023-05-01) + +### Bug fixes + +Limit cursor height in front of custom placeholder DOM elements. + +## 6.10.0 (2023-04-25) + +### Bug fixes + +Fix a crash in `drawSelection` when a measured position falls on a position that doesn't have corresponding screen coordinates. + +Work around unhelpful interaction observer behavior that could cause the editor to not notice it was visible. + +Give the cursor next to a line-wrapped placeholder a single-line height. + +Make sure drop events below the editable element in a fixed-height editor get handled properly. + +### New features + +Widget decorations can now define custom `coordsAtPos` methods to control the way the editor computes screen positions at or in the widget. + +## 6.9.6 (2023-04-21) + +### Bug fixes + +Fix an issue where, when escape was pressed followed by a key that the editor handled, followed by tab, the tab would still move focus. + +Fix an issue where, in some circumstances, the editor would ignore text changes at the end of a composition. + +Allow inline widgets to be updated to a different length via `updateDOM`. + +## 6.9.5 (2023-04-17) + +### Bug fixes + +Avoid disrupting the composition in specific cases where Safari invasively changes the DOM structure in the middle of a composition. + +Fix a bug that prevented `destroy` being called on hover tooltips. + +Fix a bug where the editor could take focus when content changes required it to restore the DOM selection. + +Fix height layout corruption caused by a division by zero. + +Make sure styles targeting the editor's focus status are specific enough to not cause them to apply to editors nested inside another focused editor. This will require themes to adjust their selection background styles to match the new specificity. + +## 6.9.4 (2023-04-11) + +### Bug fixes + +Make the editor scroll while dragging a selection near its sides, even if the cursor isn't outside the scrollable element. + +Fix a bug that interrupted composition after widgets in some circumstances on Firefox. + +Make sure the last change in a composition has its user event set to `input.type.compose`, even if the `compositionend` event fires before the changes are applied. + +Make it possible to remove additional selection ranges by clicking on them with ctrl/cmd held, even if they aren't cursors. + +Keep widget buffers between widgets and compositions, since removing them confuses IME on macOS Firefox. + +Fix a bug where, for DOM changes that put the selection in the middle of the changed range, the editor incorrectly set its selection state. + +Fix a bug where `coordsAtPos` could return a coordinates before the line break when querying a line-wrapped position with a positive `side`. + +## 6.9.3 (2023-03-21) + +### Bug fixes + +Work around a Firefox issue that caused `coordsAtPos` to return rectangles with the full line height on empty lines. + +Opening a context menu by clicking below the content element but inside the editor now properly shows the browser's menu for editable elements. + +Fix an issue that broke composition (especially of Chinese IME) after widget decorations. + +Fix an issue that would cause the cursor to jump around during compositions inside nested mark decorations. + +## 6.9.2 (2023-03-08) + +### Bug fixes + +Work around a Firefox CSS bug that caused cursors to stop blinking in a scrolled editor. + +Fix an issue in `drawSelection` where the selection extended into the editor's padding. + +Fix pasting of links copied from iOS share sheet. + +## 6.9.1 (2023-02-17) + +### Bug fixes + +Improve the way `posAtCoords` picks the side of a widget to return by comparing the coordinates the center of the widget. + +Fix an issue where transactions created for the `focusChangeEffect` facet were sometimes not dispatched. + +## 6.9.0 (2023-02-15) + +### Bug fixes + +Fix an issue where inaccurate estimated vertical positions could cause the viewport to not converge in line-wrapped editors. + +Don't suppress double-space to period conversion when autocorrect is enabled. + +Make sure the measuring code notices when the scaling of the editor is changed, and does a full measure in that case. + +### New features + +The new `EditorView.focusChangeEffect` facet can be used to dispatch a state effect when the editor is focused or blurred. + +## 6.8.1 (2023-02-08) + +### Bug fixes + +Fix an issue where tooltips that have their height reduced have their height flicker when scrolling or otherwise interacting with the editor. + +## 6.8.0 (2023-02-07) + +### Bug fixes + +Fix a regression that caused clicking on the scrollbar to move the selection. + +Fix an issue where focus or blur event handlers that dispatched editor transactions could corrupt the mouse selection state. + +Fix a CSS regression that prevented the drop cursor from being positioned properly. + +### New features + +`WidgetType.updateDOM` is now passed the editor view object. + +## 6.7.3 (2023-01-12) + +### Bug fixes + +Fix a bug in `posAtCoords` that could cause incorrect results for positions to the left of a wrapped line. + +## 6.7.2 (2023-01-04) + +### Bug fixes + +Fix a regression where the cursor didn't restart its blink cycle when moving it with the pointer. + +Even without a `key` property, measure request objects that are already scheduled will not be scheduled again by `requestMeasure`. + +Fix an issue where keymaps incorrectly interpreted key events that used Ctrl+Alt modifiers to simulate AltGr on Windows. + +Fix a bug where line decorations with a different `class` property would be treated as equal. + +Fix a bug that caused `drawSelection` to not notice when it was reconfigured. + +Fix a crash in the gutter extension caused by sharing of mutable arrays. + +Fix a regression that caused touch selection on mobile platforms to not work in an uneditable editor. + +Fix a bug where DOM events on the boundary between lines could get assigned to the wrong line. + +## 6.7.1 (2022-12-12) + +### Bug fixes + +Make the editor properly scroll when moving the pointer out of it during drag selection. + +Fix a regression where clicking below the content element in an editor with its own height didn't focus the editor. + +## 6.7.0 (2022-12-07) + +### Bug fixes + +Make the editor notice widget height changes to automatically adjust its height information. + +Fix an issue where widget buffers could be incorrectly omitted after empty lines. + +Fix an issue in content redrawing that could cause `coordsAtPos` to return incorrect results. + +### New features + +The static `RectangleMarker.forRange` method exposes the logic used by the editor to draw rectangles covering a selection range. + +Layers can now provide a `destroy` function to be called when the layer is removed. + +The new `highlightWhitespace` extension makes spaces and tabs in the editor visible. + +The `highlightTrailingWhitespace` extension can be used to make trailing whitespace stand out. + +## 6.6.0 (2022-11-24) + +### New features + +The `layer` function can now be used to define extensions that draw DOM elements over or below the document text. + +Tooltips that are bigger than the available vertical space for them will now have their height set so that they don't stick out of the window. The new `resize` property on `TooltipView` can be used to opt out of this behavior. + +## 6.5.1 (2022-11-15) + +### Bug fixes + +Fix a bug that caused marked unnecessary splitting of mark decoration DOM elements in some cases. + +## 6.5.0 (2022-11-14) + +### Bug fixes + +Fix an issue where key bindings were activated for the wrong key in some situations with non-US keyboards. + +### New features + +A tooltip's `positioned` callback is now passed the available space for tooltips. + +## 6.4.2 (2022-11-10) + +### Bug fixes + +Typing into a read-only editor no longer moves the cursor. + +Fix an issue where hover tooltips were closed when the mouse was moved over them if they had a custom parent element. + +Fix an issue where the editor could end up displaying incorrect height measurements (typically after initializing). + +## 6.4.1 (2022-11-07) + +### Bug fixes + +Fix an issue where coordinates next to replaced widgets were returned incorrectly, causing the cursor to be drawn in the wrong place. + +Update the `crosshairCursor` state on every mousemove event. + +Avoid an issue in the way that the editor enforces cursor associativity that could cause the cursor to get stuck on single-character wrapped lines. + +## 6.4.0 (2022-10-18) + +### Bug fixes + +Avoid an issue where `scrollPastEnd` makes a single-line editor have a vertical scrollbar. + +Work around a Chrome bug where it inserts a newline when you press space at the start of a wrapped line. + +Align `rectangularSelection`'s behavior with other popular editors by making it create cursors at the end of lines that are too short to touch the rectangle. + +Fix an issue where coordinates on mark decoration boundaries were sometimes taken from the wrong side of the position. + +Prevent scrolling artifacts caused by attempts to scroll stuff into view when the editor isn't being displayed. + +### New features + +`TooltipView` objects can now provide a `destroy` method to be called when the tooltip is removed. + +## 6.3.1 (2022-10-10) + +### Bug fixes + +Fix a crash when trying to scroll something into view in an editor that wasn't in the visible DOM. + +Fix an issue where `coordsAtPos` returned the coordinates on the wrong side of a widget decoration wrapped in a mark decoration. + +Fix an issue where content on long wrapped lines could fail to properly scroll into view. + +Fix an issue where DOM change reading on Chrome Android could get confused when a transaction came in right after a beforeinput event for backspace, enter, or delete. + +## 6.3.0 (2022-09-28) + +### Bug fixes + +Reduce the amount of wrap-point jittering when scrolling through a very long wrapped line. + +Fix an issue where scrolling to content that wasn't currently drawn due to being on a very long line would often fail to scroll to the right position. + +Suppress double-space-adds-period behavior on Chrome Mac when it behaves weirdly next to widget. + +### New features + +Key binding objects with an `any` property will now add handlers that are called for any key, within the ordering of the keybindings. + +## 6.2.5 (2022-09-24) + +### Bug fixes + +Don't override double/triple tap behavior on touch screen devices, so that the mobile selection menu pops up properly. + +Fix an issue where updating the selection could crash on Safari when the editor was hidden. + +## 6.2.4 (2022-09-16) + +### Bug fixes + +Highlight the active line even when there is a selection. Prevent the active line background from obscuring the selection backdrop. + +Fix an issue where elements with negative margins would confuse the editor's scrolling-into-view logic. + +Fix scrolling to a specific position in an editor that has not been in view yet. + +## 6.2.3 (2022-09-08) + +### Bug fixes + +Fix a bug where cursor motion, when starting from a non-empty selection range, could get stuck on atomic ranges in some circumstances. + +Avoid triggering Chrome Android's text-duplication issue when a period is typed in the middle of a word. + +## 6.2.2 (2022-08-31) + +### Bug fixes + +Don't reset the selection for selection change events that were suppressed by a node view. + +## 6.2.1 (2022-08-25) + +### Bug fixes + +Don't use the global `document` variable to track focus, since that doesn't work in another window/frame. + +Fix an issue where key handlers that didn't return true were sometimes called twice for the same keypress. + +Avoid editing glitches when using deletion keys like ctrl-d on iOS. + +Properly treat characters from the 'Arabic Presentation Forms-A' Unicode block as right-to-left. + +Work around a Firefox bug that inserts text at the wrong point for specific cross-line selections. + +## 6.2.0 (2022-08-05) + +### Bug fixes + +Fix a bug where `posAtCoords` would return the wrong results for positions to the right of wrapped lines. + +### New features + +The new `EditorView.setRoot` method can be used when an editor view is moved to a new document or shadow root. + +## 6.1.4 (2022-08-04) + +### Bug fixes + +Make selection-restoration on focus more reliable. + +## 6.1.3 (2022-08-03) + +### Bug fixes + +Fix a bug where a document that contains only non-printing characters would lead to bogus text measurements (and, from those, to crashing). + +Make sure differences between estimated and actual block heights don't cause visible scroll glitches. + +## 6.1.2 (2022-07-27) + +### Bug fixes + +Fix an issue where double tapping enter to confirm IME input and insert a newline on iOS would sometimes insert two newlines. + +Fix an issue on iOS where a composition could get aborted if the editor scrolled on backspace. + +## 6.1.1 (2022-07-25) + +### Bug fixes + +Make `highlightSpecialChars` replace directional isolate characters by default. + +The editor will now try to suppress browsers' native behavior of resetting the selection in the editable content when the editable element is focused (programmatically, with tab, etc). + +Fix a CSS issue that made it possible, when the gutters were wide enough, for them to overlap with the content. + +## 6.1.0 (2022-07-19) + +### New features + +`MatchDecorator` now supports a `decorate` option that can be used to customize the way decorations are added for each match. + +## 6.0.3 (2022-07-08) + +### Bug fixes + +Fix a problem where `posAtCoords` could incorrectly return the start of the next line when querying positions between lines. + +Fix an issue where registering a high-precedence keymap made keymap handling take precedence over other keydown event handlers. + +Ctrl/Cmd-clicking can now remove ranges from a multi-range selection. + +## 6.0.2 (2022-06-23) + +### Bug fixes + +Fix a CSS issue that broke horizontal scroll width stabilization. + +Fix a bug where `defaultLineHeight` could get an incorrect value in very narrow editors. + +## 6.0.1 (2022-06-17) + +### Bug fixes + +Avoid DOM selection corruption when the editor doesn't have focus but has selection and updates its content. + +Fall back to dispatching by key code when a key event produces a non-ASCII character (so that Cyrillic and Arabic keyboards can still use bindings specified with Latin characters). + +## 6.0.0 (2022-06-08) + +### New features + +The new static `EditorView.findFromDOM` method can be used to retrieve an editor instance from its DOM structure. + +Instead of passing a constructed state to the `EditorView` constructor, it is now also possible to inline the configuration options to the state in the view config object. + +## 0.20.7 (2022-05-30) + +### Bug fixes + +Fix an issue on Chrome Android where the DOM could fail to display the actual document after backspace. + +Avoid an issue on Chrome Android where DOM changes were sometimes inappropriately replace by a backspace key effect due to spurious beforeinput events. + +Fix a problem where the content element's width didn't cover the width of the actual content. + +Work around a bug in Chrome 102 which caused wheel scrolling of the editor to be interrupted every few lines. + +## 0.20.6 (2022-05-20) + +### Bug fixes + +Make sure the editor re-measures itself when its attributes are updated. + +## 0.20.5 (2022-05-18) + +### Bug fixes + +Fix an issue where gutter elements without any markers in them would not get the `cm-gutterElement` class assigned. + +Fix an issue where DOM event handlers registered by plugins were retained indefinitely, even after the editor was reconfigured. + +## 0.20.4 (2022-05-03) + +### Bug fixes + +Prevent Mac-style behavior of inserting a period when the user inserts two spaces. + +Fix an issue where the editor would sometimes not restore the DOM selection when refocused with a selection identical to the one it held when it lost focus. + +## 0.20.3 (2022-04-27) + +### Bug fixes + +Fix a bug where the input handling could crash on repeated (or held) backspace presses on Chrome Android. + +## 0.20.2 (2022-04-22) + +### New features + +The new `hideOn` option to `hoverTooltip` allows more fine-grained control over when the tooltip should hide. + +## 0.20.1 (2022-04-20) + +### Bug fixes + +Remove debug statements that accidentally made it into 0.20.0. + +Fix a regression in `moveVertically`. + +## 0.20.0 (2022-04-20) + +### Breaking changes + +The deprecated interfaces `blockAtHeight`, `visualLineAtHeight`, `viewportLines`, `visualLineAt`, `scrollPosIntoView`, `scrollTo`, and `centerOn` were removed from the library. + +All decorations are now provided through `EditorView.decorations`, and are part of a single precedence ordering. Decoration sources that need access to the view are provided as functions. + +Atomic ranges are now specified through a facet (`EditorView.atomicRanges`). + +Scroll margins are now specified through a facet (`EditorView.scrollMargins`). + +Plugin fields no longer exist in the library (and are replaced by facets holding function values). + +This package no longer re-exports the Range type from @codemirror/state. + +### Bug fixes + +Fix a bug where zero-length block widgets could cause `viewportLineBlocks` to contain overlapping ranges. + +### New features + +The new `perLineTextDirection` facet configures whether the editor reads text direction per line, or uses a single direction for the entire editor. `EditorView.textDirectionAt` returns the direction around a given position. + +`rectangularSelection` and `crosshairCursor` from @codemirror/rectangular-selection were merged into this package. + +This package now exports the tooltip functionality that used to live in @codemirror/tooltip. + +The exports from the old @codemirror/panel package are now available from this package. + +The exports from the old @codemirror/gutter package are now available from this package. + +## 0.19.48 (2022-03-30) + +### Bug fixes + +Fix an issue where DOM syncing could crash when a DOM node was moved from a parent to a child node (via widgets reusing existing nodes). + +To avoid interfering with things like a vim mode too much, the editor will now only activate the tab-to-move-focus escape hatch after an escape press that wasn't handled by an event handler. + +Make sure the view measures itself before the page is printed. + +Tweak types of view plugin defining functions to avoid TypeScript errors when the plugin value doesn't have any of the interface's properties. + +## 0.19.47 (2022-03-08) + +### Bug fixes + +Fix an issue where block widgets at the start of the viewport could break height computations. + +## 0.19.46 (2022-03-03) + +### Bug fixes + +Fix a bug where block widgets on the edges of viewports could cause the positioning of content to misalign with the gutter and height computations. + +Improve cursor height next to widgets. + +Fix a bug where mapping positions to screen coordinates could return incorred coordinates during composition. + +## 0.19.45 (2022-02-23) + +### Bug fixes + +Fix an issue where the library failed to call `WidgetType.destroy` on the old widget when replacing a widget with a different widget of the same type. + +Fix an issue where the editor would compute DOM positions inside composition contexts incorrectly in some cases, causing the selection to be put in the wrong place and needlessly interrupting compositions. + +Fix leaking of resize event handlers. + +## 0.19.44 (2022-02-17) + +### Bug fixes + +Fix a crash that occasionally occurred when drag-selecting in a way that scrolled the editor. + +### New features + +The new `EditorView.compositionStarted` property indicates whether a composition is starting. + +## 0.19.43 (2022-02-16) + +### Bug fixes + +Fix several issues where editing or composition went wrong due to our zero-width space kludge characters ending up in unexpected places. + +Make sure the editor re-measures its dimensions whenever its theme changes. + +Fix an issue where some keys on Android phones could leave the editor DOM unsynced with the actual document. + +## 0.19.42 (2022-02-05) + +### Bug fixes + +Fix a regression in cursor position determination after making an edit next to a widget. + +## 0.19.41 (2022-02-04) + +### Bug fixes + +Fix an issue where the editor's view of its content height could go out of sync with the DOM when a line-wrapping editor had its width changed, causing wrapping to change. + +Fix a bug that caused the editor to draw way too much content when scrolling to a position in an editor (much) taller than the window. + +Report an error when a replace decoration from a plugin crosses a line break, rather than silently ignoring it. + +Fix an issue where reading DOM changes was broken when `lineSeparator` contained more than one character. + +Make ordering of replace and mark decorations with the same extent and inclusivness more predictable by giving replace decorations precedence. + +Fix a bug where, on Chrome, replacement across line boundaries and next to widgets could cause bogus zero-width characters to appear in the content. + +## 0.19.40 (2022-01-19) + +### Bug fixes + +Make composition input properly appear at secondary cursors (except when those are in the DOM node with the composition, in which case the browser won't allow us to intervene without aborting the composition). + +Fix a bug that cause the editor to get confused about which content was visible after scrolling something into view. + +Fix a bug where the dummy elements rendered around widgets could end up in a separate set of wrapping marks, and thus become visible. + +`EditorView.moveVertically` now preserves the `assoc` property of the input range. + +Get rid of gaps between selection elements drawn by `drawSelection`. + +Fix an issue where replacing text next to a widget might leak bogus zero-width spaces into the document. + +Avoid browser selection mishandling when a focused view has `setState` called by eagerly refocusing it. + +## 0.19.39 (2022-01-06) + +### Bug fixes + +Make sure the editor signals a `geometryChanged` update when its width changes. + +### New features + +`EditorView.darkTheme` can now be queried to figure out whether the editor is using a dark theme. + +## 0.19.38 (2022-01-05) + +### Bug fixes + +Fix a bug that caused line decorations with a `class` property to suppress all other line decorations for that line. + +Fix a bug that caused scroll effects to be corrupted when further updates came in before they were applied. + +Fix an issue where, depending on which way a floating point rounding error fell, `posAtCoords` (and thus vertical cursor motion) for positions outside of the vertical range of the document might or might not return the start/end of the document. + +## 0.19.37 (2021-12-22) + +### Bug fixes + +Fix regression where plugin replacing decorations that span to the end of the line are ignored. + +## 0.19.36 (2021-12-22) + +### Bug fixes + +Fix a crash in `posAtCoords` when the position lies in a block widget that is rendered but scrolled out of view. + +Adding block decorations from a plugin now raises an error. Replacing decorations that cross lines are ignored, when provided by a plugin. + +Fix inverted interpretation of the `precise` argument to `posAtCoords`. + +## 0.19.35 (2021-12-20) + +### Bug fixes + +The editor will now handle double-taps as if they are double-clicks, rather than letting the browser's native behavior happen (because the latter often does the wrong thing). + +Fix an issue where backspacing out a selection on Chrome Android would sometimes only delete the last character due to event order issues. + +`posAtCoords`, without second argument, will no longer return null for positions below or above the document. + +## 0.19.34 (2021-12-17) + +### Bug fixes + +Fix a bug where content line elements would in some cases lose their `cm-line` class. + +## 0.19.33 (2021-12-16) + +### Breaking changes + +`EditorView.scrollTo` and `EditorView.centerOn` are deprecated in favor of `EditorView.scrollIntoView`, and will be removed in the next breaking release. + +### Bug fixes + +Fix an issue that could cause the editor to unnecessarily interfere with composition (especially visible on macOS Chrome). + +A composition started with multiple lines selected will no longer be interruptd by the editor. + +### New features + +The new `EditorView.scrollIntoView` function allows you to do more fine-grained scrolling. + +## 0.19.32 (2021-12-15) + +### Bug fixes + +Fix a bug where CodeMirror's own event handers would run even after a user-supplied handler called `preventDefault` on an event. + +Properly draw selections when negative text-indent is used for soft wrapping. + +Fix an issue where `viewportLineBlocks` could hold inaccurate height information when the vertical scaling changed. + +Fixes drop cursor positioning when the document is scrolled. Force a content measure when the editor comes into view + +Fix a bug that could cause the editor to not measure its layout the first time it came into view. + +## 0.19.31 (2021-12-13) + +### New features + +The package now exports a `dropCursor` extension that draws a cursor at the current drop position when dragging content over the editor. + +## 0.19.30 (2021-12-13) + +### Bug fixes + +Refine Android key event handling to work properly in a GBoard corner case where pressing Enter fires a bunch of spurious deleteContentBackward events. + +Fix a crash in `drawSelection` for some kinds of selections. + +Prevent a possibility where some content updates causes duplicate text to remain in DOM. + +### New features + +Support a `maxLength` option to `MatchDecorator` that allows user code to control how far it scans into hidden parts of viewport lines. + +## 0.19.29 (2021-12-09) + +### Bug fixes + +Fix a bug that could cause out-of-view editors to get a nonsensical viewport and fail to scroll into view when asked to. + +Fix a bug where would return 0 when clicking below the content if the last line was replaced with a block widget decoration. + +Fix an issue where clicking at the position of the previous cursor in a blurred editor would cause the selection to reset to the start of the document. + +Fix an issue where composition could be interrupted if the browser created a new node inside a mark decoration node. + +## 0.19.28 (2021-12-08) + +### Bug fixes + +Fix an issue where pressing Enter on Chrome Android during composition did not fire key handlers for Enter. + +Avoid a Chrome bug where the virtual keyboard closes when pressing backspace after a widget. + +Fix an issue where the editor could show a horizontal scroll bar even after all lines that caused it had been deleted or changed. + +## 0.19.27 (2021-12-06) + +### Bug fixes + +Fix a bug that could cause `EditorView.plugin` to inappropriately return `null` during plugin initialization. + +Fix a bug where a block widget without `estimatedHeight` at the end of the document could fail to be drawn + +## 0.19.26 (2021-12-03) + +### New features + +Widgets can now define a `destroy` method that is called when they are removed from the view. + +## 0.19.25 (2021-12-02) + +### Bug fixes + +Widgets around replaced ranges are now visible when their side does not point towards the replaced range. + +A replaced line with a line decoration no longer creates an extra empty line block in the editor. + +The `scrollPastEnd` extension will now over-reserve space at the bottom of the editor on startup, to prevent restored scroll positions from being clipped. + +### New features + +`EditorView.editorAttributes` and `contentAttributes` may now hold functions that produce the attributes. + +## 0.19.24 (2021-12-01) + +### Bug fixes + +Fix a bug where `lineBlockAt`, for queries inside the viewport, would always return the first line in the viewport. + +## 0.19.23 (2021-11-30) + +### Bug fixes + +Fix an issue where after some kinds of changes, `EditorView.viewportLineBlocks` held an out-of-date set of blocks. + +### New features + +Export `EditorView.documentPadding`, with information about the vertical padding of the document. + +## 0.19.22 (2021-11-30) + +### Bug fixes + +Fix an issue where editors with large vertical padding (for example via `scrollPastEnd`) could sometimes lose their scroll position on Chrome. + +Avoid some unnecessary DOM measuring work by more carefully checking whether it is needed. + +### New features + +The new `elementAtHeight`, `lineBlockAtHeight`, and `lineBlockAt` methods provide a simpler and more efficient replacement for the (now deprecated) `blockAtHeight`, `visualLineAtHeight`, and `visualLineAt` methods. + +The editor view now exports a `documentTop` getter that gives you the vertical position of the top of the document. All height info is queried and reported relative to this top. + +The editor view's new `viewportLineBlocks` property provides an array of in-viewport line blocks, and replaces the (now deprecated) `viewportLines` method. + +## 0.19.21 (2021-11-26) + +### Bug fixes + +Fix a problem where the DOM update would unnecessarily trigger browser relayouts. + +## 0.19.20 (2021-11-19) + +### Bug fixes + +Run a measure cycle when the editor's size spontaneously changes. + +## 0.19.19 (2021-11-17) + +### Bug fixes + +Fix a bug that caused the precedence of `editorAttributes` and `contentAttributes` to be inverted, making lower-precedence extensions override higher-precedence ones. + +## 0.19.18 (2021-11-16) + +### Bug fixes + +Fix an issue where the editor wasn't aware it was line-wrapping with its own `lineWrapping` extension enabled. + +## 0.19.17 (2021-11-16) + +### Bug fixes + +Avoid an issue where stretches of whitespace on line wrap points could cause the cursor to be placed outside of the content. + +## 0.19.16 (2021-11-11) + +### Breaking changes + +Block replacement decorations now default to inclusive, because non-inclusive block decorations are rarely what you need. + +### Bug fixes + +Fix an issue that caused block widgets to always have a large side value, making it impossible to show them between to replacement decorations. + +Fix a crash that could happen after some types of viewport changes, due to a bug in the block widget view data structure. + +## 0.19.15 (2021-11-09) + +### Bug fixes + +Fix a bug where the editor would think it was invisible when the document body was given screen height and scroll behavior. + +Fix selection reading inside a shadow root on iOS. + +## 0.19.14 (2021-11-07) + +### Bug fixes + +Fix an issue where typing into a read-only editor would move the selection. + +Fix slowness when backspace is held down on iOS. + +## 0.19.13 (2021-11-06) + +### Bug fixes + +Fix a bug where backspace, enter, and delete would get applied twice on iOS. + +## 0.19.12 (2021-11-04) + +### Bug fixes + +Make sure the workaround for the lost virtual keyboard on Chrome Android also works on slower phones. Slight style change in beforeinput handler + +Avoid failure cases in viewport-based rendering of very long lines. + +## 0.19.11 (2021-11-03) + +### Breaking changes + +`EditorView.scrollPosIntoView` has been deprecated. Use the `EditorView.scrollTo` effect instead. + +### New features + +The new `EditorView.centerOn` effect can be used to scroll a given range to the center of the view. + +## 0.19.10 (2021-11-02) + +### Bug fixes + +Don't crash when `IntersectionObserver` fires its callback without any records. Try to handle some backspace issues on Chrome Android + +Using backspace near uneditable widgets on Chrome Android should now be more reliable. + +Work around a number of browser bugs by always rendering zero-width spaces around in-content widgets, so that browsers will treat the positions near them as valid cursor positions and not try to run composition across widget boundaries. + +Work around bogus composition changes created by Chrome Android after handled backspace presses. + +Work around an issue where tapping on an uneditable node in the editor would sometimes fail to show the virtual keyboard on Chrome Android. + +Prevent translation services from translating the editor content. Show direction override characters as special chars by default + +`specialChars` will now, by default, replace direction override chars, to mitigate https://trojansource.codes/ attacks. + +### New features + +The editor view will, if `parent` is given but `root` is not, derive the root from the parent element. + +Line decorations now accept a `class` property to directly add DOM classes to the line. + +## 0.19.9 (2021-10-01) + +### Bug fixes + +Fix an issue where some kinds of reflows in the surrounding document could move unrendered parts of the editor into view without the editor noticing and updating its viewport. + +Fix an occasional crash in the selection drawing extension. + +## 0.19.8 (2021-09-26) + +### Bug fixes + +Fix a bug that could, on DOM changes near block widgets, insert superfluous line breaks. + +Make interacting with a destroyed editor view do nothing, rather than crash, to avoid tripping people up with pending timeouts and such. + +Make sure `ViewUpdate.viewportChanged` is true whenever `visibleRanges` changes, so that plugins acting only on visible ranges can use it to check when to update. + +Fix line-wise cut on empty lines. + +## 0.19.7 (2021-09-13) + +### Bug fixes + +The view is now aware of the new `EditorState.readOnly` property, and suppresses events that modify the document when it is true. + +## 0.19.6 (2021-09-10) + +### Bug fixes + +Remove a `console.log` that slipped into the previous release. + +## 0.19.5 (2021-09-09) + +### New features + +The new `EditorView.scrollTo` effect can be used to scroll a given range into view. + +## 0.19.4 (2021-09-01) + +### Bug fixes + +Fix an issue where lines containing just a widget decoration wrapped in a mark decoration could be displayed with 0 height. + +## 0.19.3 (2021-08-25) + +### Bug fixes + +Fix a view corruption that could happen in situations involving overlapping mark decorations. + +## 0.19.2 (2021-08-23) + +### New features + +The package now exports a `scrollPastEnd` function, which returns an extension that adds space below the document to allow the last line to be scrolled to the top of the editor. + +## 0.19.1 (2021-08-11) + +### Breaking changes + +The view now emits new-style user event annotations for the transactions it generates. + +### Bug fixes + +Fix a bug where `coordsAtPos` would allow the passed `side` argument to override widget sides, producing incorrect cursor positions. + +Fix a bug that could cause content lines to be misaligned in certain situations involving widgets at the end of lines. + +Fix an issue where, if the browser decided to modify DOM attributes in the content in response to some editing action, the view failed to reset those again. + +## 0.18.19 (2021-07-12) + +### Bug fixes + +Fix a regression where `EditorView.editable.of(false)` didn't disable editing on Webkit-based browsers. + +## 0.18.18 (2021-07-06) + +### Bug fixes + +Fix a bug that caused `EditorView.moveVertically` to only move by one line, even when given a custom distance, in some cases. + +Hide Safari's native bold/italic/underline controls for the content. + +Fix a CSS problem that prevented Safari from breaking words longer than the line in line-wrapping mode. + +Avoid a problem where composition would be inappropriately abored on Safari. + +Fix drag-selection that scrolls the content by dragging past the visible viewport. + +### New features + +`posAtCoords` now has an imprecise mode where it'll return an approximate position even for parts of the document that aren't currently rendered. + +## 0.18.17 (2021-06-14) + +### Bug fixes + +Make `drawSelection` behave properly when lines are split by block widgets. + +Make sure drawn selections that span a single line break don't leave a gap between the lines. + +## 0.18.16 (2021-06-03) + +### Bug fixes + +Fix a crash that could occur when the document changed during mouse selection. + +Fix a bug where composition inside styled content would sometimes be inappropriately aborted by editor DOM updates. + +### New features + +`MouseSelectionStyle.update` may now return true to indicate it should be queried for a new selection after the update. + +## 0.18.15 (2021-06-01) + +### Bug fixes + +Fix a bug that would, in very specific circumstances, cause `posAtCoords` to go into an infinite loop in Safari. + +Fix a bug where some types of IME input on Mobile Safari would drop text. + +## 0.18.14 (2021-05-28) + +### Bug fixes + +Fix an issue where the DOM selection was sometimes not properly updated when next to a widget. + +Invert the order in which overlapping decorations are drawn so that higher-precedence decorations are nested inside lower-precedence ones (and thus override their styling). + +Fix a but in `posAtCoords` where it would in some situations return -1 instead of `null`. + +### New features + +A new plugin field, `PluginField.atomicRanges`, can be used to cause cursor motion to skip past some ranges of the document. + +## 0.18.13 (2021-05-20) + +### Bug fixes + +Fix a bug that would cause the content DOM update to crash in specific circumstances. + +Work around an issue where, after some types of changes, Mobile Safari would ignore Enter presses. + +Make iOS enter and backspace handling more robust, so that platform bugs are less likely to break those keys in the editor. + +Fix a regression where Esc + Tab no longer allowed the user to exit the editor. + +### New features + +You can now drop text files into the editor. + +## 0.18.12 (2021-05-10) + +### Bug fixes + +Work around a Mobile Safari bug where, after backspacing out the last character on a line, Enter didn't work anymore. + +Work around a problem in Mobile Safari where you couldn't tap to put the cursor at the end of a line that ended in a widget. + +## 0.18.11 (2021-04-30) + +### Bug fixes + +Add an attribute to prevent the Grammarly browser extension from messing with the editor content. + +Fix more issues around selection handling a Shadow DOM in Safari. + +## 0.18.10 (2021-04-27) + +### Bug fixes + +Fix a bug where some types of updates wouldn't properly cause marks around the changes to be joined in the DOM. + +Fix an issue where the content and gutters in a fixed-height editor could be smaller than the editor height. + +Fix a crash on Safari when initializing an editor in an unfocused window. + +Fix a bug where the editor would incorrectly conclude it was out of view in some types of absolutely positioned parent elements. + +## 0.18.9 (2021-04-23) + +### Bug fixes + +Fix a crash that occurred when determining DOM coordinates in some specific situations. + +Fix a crash when a DOM change that ended at a zero-width view element (widget) removed that element from the DOM. + +Disable autocorrect and autocapitalize by default, since in most code-editor contexts they get in the way. You can use `EditorView.contentAttributes` to override this. + +Fix a bug that interfered with native touch selection handling on Android. + +Fix an unnecessary DOM update after composition that would disrupt touch selection on Android. + +Add a workaround for Safari's broken selection reporting when the editor is in a shadow DOM tree. + +Fix select-all from the context menu on Safari. + +## 0.18.8 (2021-04-19) + +### Bug fixes + +Handle selection replacements where the inserted text matches the start/end of the replaced text better. + +Fix an issue where the editor would miss scroll events when it was placed in a DOM component slot. + +## 0.18.7 (2021-04-13) + +### Bug fixes + +Fix a crash when drag-selecting out of the editor with editable turned off. + +Backspace and delete now largely work in an editor without a keymap. + +Pressing backspace on iOS should now properly update the virtual keyboard's capitalize and autocorrect state. + +Prevent random line-wrapping in (non-wrapping) editors on Mobile Safari. +## 0.18.6 (2021-04-08) + +### Bug fixes + +Fix an issue in the compiled output that would break the code when minified with terser. + +## 0.18.5 (2021-04-07) + +### Bug fixes + +Improve handling of bidi text with brackets (conforming to Unicode 13's bidi algorithm). + +Fix the position where `drawSelection` displays the cursor on bidi boundaries. + +## 0.18.4 (2021-04-07) + +### Bug fixes + +Fix an issue where the default focus ring gets obscured by the gutters and active line. + +Fix an issue where the editor believed Chrome Android didn't support the CSS `tab-size` style. + +Don't style active lines when there are non-empty selection ranges, so that the active line background doesn't obscure the selection. + +Make iOS autocapitalize update properly when you press Enter. + +## 0.18.3 (2021-03-19) + +### Breaking changes + +The outer DOM element now has class `cm-editor` instead of `cm-wrap` (`cm-wrap` will be present as well until 0.19). + +### Bug fixes + +Improve behavior of `posAtCoords` when the position is near text but not in any character's actual box. + +## 0.18.2 (2021-03-19) + +### Bug fixes + +Triple-clicking now selects the line break after the clicked line (if any). + +Fix an issue where the `drawSelection` plugin would fail to draw the top line of the selection when it started in an empty line. + +Fix an issue where, at the end of a specific type of composition on iOS, the editor read the DOM before the browser was done updating it. + +## 0.18.1 (2021-03-05) + +### Bug fixes + +Fix an issue where, on iOS, some types of IME would cause the composed content to be deleted when confirming a composition. + +## 0.18.0 (2021-03-03) + +### Breaking changes + +The `themeClass` function and ``-style selectors in themes are no longer supported (prefixing with `cm-` should be done manually now). + +Themes must now use `&` (instead of an extra `$`) to target the editor wrapper element. + +The editor no longer adds `cm-light` or `cm-dark` classes. Targeting light or dark configurations in base themes should now be done by using a `&light` or `&dark` top-level selector. + +## 0.17.13 (2021-03-03) + +### Bug fixes + +Work around a Firefox bug where it won't draw the cursor when it is between uneditable elements. + +Fix a bug that broke built-in mouse event handling. + +## 0.17.12 (2021-03-02) + +### Bug fixes + +Avoid interfering with touch events, to allow native selection behavior. + +Fix a bug that broke sub-selectors with multiple `&` placeholders in themes. + +## 0.17.11 (2021-02-25) + +### Bug fixes + +Fix vertical cursor motion on Safari with a larger line-height. + +Fix incorrect selection drawing (with `drawSelection`) when the selection spans to just after a soft wrap point. + +Fix an issue where compositions on Safari were inappropriately aborted in some circumstances. + +The view will now redraw when the `EditorView.phrases` facet changes, to make sure translated text is properly updated. + +## 0.17.10 (2021-02-22) + +### Bug fixes + +Long words without spaces, when line-wrapping is enabled, are now properly broken. + +Fix the horizontal position of selections drawn by `drawSelection` in right-to-left editors with a scrollbar. + +## 0.17.9 (2021-02-18) + +### Bug fixes + +Fix an issue where pasting linewise at the start of a line left the cursor before the inserted content. + +## 0.17.8 (2021-02-16) + +### Bug fixes + +Fix a problem where the DOM selection and the editor state could get out of sync in non-editable mode. + +Fix a crash when the editor was hidden on Safari, due to `getClientRects` returning an empty list. + +Prevent Firefox from making the scrollable element keyboard-focusable. + +## 0.17.7 (2021-01-25) + +### New features + +Add an `EditorView.announce` state effect that can be used to conveniently provide screen reader announcements. + +## 0.17.6 (2021-01-22) + +### Bug fixes + +Avoid creating very high scroll containers for large documents so that we don't overflow the DOM's fixed-precision numbers. + +## 0.17.5 (2021-01-15) + +### Bug fixes + +Fix a bug that would create space-filling placeholders with incorrect height when document is very large. + +## 0.17.4 (2021-01-14) + +### Bug fixes + +The `drawSelection` extension will now reuse cursor DOM nodes when the number of cursors stays the same, allowing some degree of cursor transition animation. + +Makes highlighted special characters styleable (``) and fix their default look in dark themes to have appropriate contrast. + +### New features + +Adds a new `MatchDecorator` helper class which can be used to easily maintain decorations on content that matches a regular expression. + +## 0.17.3 (2021-01-06) + +### New features + +The package now also exports a CommonJS module. + +## 0.17.2 (2021-01-04) + +### Bug fixes + +Work around Chrome problem where the native shift-enter behavior inserts two line breaks. + +Make bracket closing and bracket pair removing more reliable on Android. + +Fix bad cursor position and superfluous change transactions after pressing enter when in a composition on Android. + +Fix issue where the wrong character was deleted when backspacing out a character before an identical copy of that character on Android. + +## 0.17.1 (2020-12-30) + +### Bug fixes + +Fix a bug that prevented `ViewUpdate.focusChanged` from ever being true. + +## 0.17.0 (2020-12-29) + +### Breaking changes + +First numbered release. + diff --git a/vendor/codemirror-view/LICENSE b/vendor/codemirror-view/LICENSE new file mode 100644 index 0000000000..9a91f48619 --- /dev/null +++ b/vendor/codemirror-view/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (C) 2018-2021 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/vendor/codemirror-view/LICENSE-APACHE-2.0 b/vendor/codemirror-view/LICENSE-APACHE-2.0 new file mode 100644 index 0000000000..ec12b3da10 --- /dev/null +++ b/vendor/codemirror-view/LICENSE-APACHE-2.0 @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor has + been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/vendor/codemirror-view/README.md b/vendor/codemirror-view/README.md new file mode 100644 index 0000000000..5f633c13f4 --- /dev/null +++ b/vendor/codemirror-view/README.md @@ -0,0 +1,37 @@ +# @codemirror/view [![NPM version](https://img.shields.io/npm/v/@codemirror/view.svg)](https://www.npmjs.org/package/@codemirror/view) + +[ [**WEBSITE**](https://codemirror.net/) | [**DOCS**](https://codemirror.net/docs/ref/#view) | [**ISSUES**](https://code.haverbeke.berlin/codemirror/dev/issues) | [**FORUM**](https://discuss.codemirror.net/) | [**CHANGELOG**](https://code.haverbeke.berlin/codemirror/view/src/branch/main/CHANGELOG.md) ] + +This package implements the DOM view component for the +[CodeMirror](https://codemirror.net/) code editor. + +The [project page](https://codemirror.net/) has more information, a +number of [examples](https://codemirror.net/examples/) and the +[documentation](https://codemirror.net/docs/). + +This code is released under an +[MIT license](https://code.haverbeke.berlin/codemirror/view/tree/main/LICENSE). + +We aim to be an inclusive, welcoming community. To make that explicit, +we have a [code of +conduct](http://contributor-covenant.org/version/1/1/0/) that applies +to communication around the project. + +## Usage + +```javascript +import {EditorView} from "@codemirror/view" +import {basicSetup} from "codemirror" + +const view = new EditorView({ + parent: document.querySelector("#some-node"), + doc: "Content text", + extensions: [basicSetup /* ... */] +}) +``` + +Add additional extensions, such as a [language +mode](https://codemirror.net/#languages), to configure the editor. +Call +[`view.dispatch`](https://codemirror.net/docs/ref/#view.EditorView.dispatch) +to update the editor's state. diff --git a/vendor/codemirror-view/UPSTREAM.md b/vendor/codemirror-view/UPSTREAM.md new file mode 100644 index 0000000000..1b1dce51f9 --- /dev/null +++ b/vendor/codemirror-view/UPSTREAM.md @@ -0,0 +1,6 @@ +# Upstream revision + +- Package: `@codemirror/view` 6.43.9 +- Repository: https://code.haverbeke.berlin/codemirror/view.git +- Commit: `d4e1656e1a0060f562695df93cb1775c0cdee24f` +- npm release `gitHead`: `d4e1656e1a0060f562695df93cb1775c0cdee24f` diff --git a/vendor/codemirror-view/dist/index.cjs b/vendor/codemirror-view/dist/index.cjs new file mode 100644 index 0000000000..eb036233aa --- /dev/null +++ b/vendor/codemirror-view/dist/index.cjs @@ -0,0 +1,13050 @@ +'use strict'; + +var state = require('@codemirror/state'); +var styleMod = require('style-mod'); +var w3cKeyname = require('w3c-keyname'); +var elt = require('crelt'); + +let nav = typeof navigator != "undefined" ? navigator : { userAgent: "", vendor: "", platform: "" }; +let doc = typeof document != "undefined" ? document : { documentElement: { style: {} } }; +const ie_edge = /Edge\/(\d+)/.exec(nav.userAgent); +const ie_upto10 = /MSIE \d/.test(nav.userAgent); +const ie_11up = /Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(nav.userAgent); +const ie = !!(ie_upto10 || ie_11up || ie_edge); +const gecko = !ie && /gecko\/(\d+)/i.test(nav.userAgent); +const chrome = !ie && /Chrome\/(\d+)/.exec(nav.userAgent); +const webkit = "webkitFontSmoothing" in doc.documentElement.style; +const safari = !ie && /Apple Computer/.test(nav.vendor); +const ios = safari && (/Mobile\/\w+/.test(nav.userAgent) || nav.maxTouchPoints > 2); +var browser = { + mac: ios || /Mac/.test(nav.platform), + windows: /Win/.test(nav.platform), + linux: /Linux|X11/.test(nav.platform), + ie, + ie_version: ie_upto10 ? doc.documentMode || 6 : ie_11up ? +ie_11up[1] : ie_edge ? +ie_edge[1] : 0, + gecko, + gecko_version: gecko ? +(/Firefox\/(\d+)/.exec(nav.userAgent) || [0, 0])[1] : 0, + chrome: !!chrome, + chrome_version: chrome ? +chrome[1] : 0, + ios, + android: /Android\b/.test(nav.userAgent), + webkit, + webkit_version: webkit ? +(/\bAppleWebKit\/(\d+)/.exec(nav.userAgent) || [0, 0])[1] : 0, + safari, + safari_version: safari ? +(/\bVersion\/(\d+(\.\d+)?)/.exec(nav.userAgent) || [0, 0])[1] : 0, + tabSize: doc.documentElement.style.tabSize != null ? "tab-size" : "-moz-tab-size" +}; + +function combineAttrs(source, target) { + for (let name in source) { + if (name == "class" && target.class) + target.class += " " + source.class; + else if (name == "style" && target.style) + target.style += ";" + source.style; + else + target[name] = source[name]; + } + return target; +} +const noAttrs = Object.create(null); +function attrsEq(a, b, ignore) { + if (a == b) + return true; + if (!a) + a = noAttrs; + if (!b) + b = noAttrs; + let keysA = Object.keys(a), keysB = Object.keys(b); + if (keysA.length - (0) != + keysB.length - (0)) + return false; + for (let key of keysA) { + if (key != ignore && (keysB.indexOf(key) == -1 || a[key] !== b[key])) + return false; + } + return true; +} +function setAttrs(dom, attrs) { + for (let i = dom.attributes.length - 1; i >= 0; i--) { + let name = dom.attributes[i].name; + if (attrs[name] == null) + dom.removeAttribute(name); + } + for (let name in attrs) { + let value = attrs[name]; + if (name == "style") + dom.style.cssText = value; + else if (dom.getAttribute(name) != value) + dom.setAttribute(name, value); + } +} +function updateAttrs(dom, prev, attrs) { + let changed = false; + if (prev) + for (let name in prev) + if (!(attrs && name in attrs)) { + changed = true; + if (name == "style") + dom.style.cssText = ""; + else + dom.removeAttribute(name); + } + if (attrs) + for (let name in attrs) + if (!(prev && prev[name] == attrs[name])) { + changed = true; + if (name == "style") + dom.style.cssText = attrs[name]; + else + dom.setAttribute(name, attrs[name]); + } + return changed; +} +function getAttrs(dom) { + let attrs = Object.create(null); + for (let i = 0; i < dom.attributes.length; i++) { + let attr = dom.attributes[i]; + attrs[attr.name] = attr.value; + } + return attrs; +} + +/** +Widgets added to the content are described by subclasses of this +class. Using a description object like that makes it possible to +delay creating of the DOM structure for a widget until it is +needed, and to avoid redrawing widgets even if the decorations +that define them are recreated. +*/ +class WidgetType { + /** + Compare this instance to another instance of the same type. + (TypeScript can't express this, but only instances of the same + specific class will be passed to this method.) This is used to + avoid redrawing widgets when they are replaced by a new + decoration of the same type. The default implementation just + returns `false`, which will cause new instances of the widget to + always be redrawn. + */ + eq(widget) { return false; } + /** + Update a DOM element created by a widget of the same type (but + different, non-`eq` content) to reflect this widget. May return + true to indicate that it could update, false to indicate it + couldn't (in which case the widget will be redrawn). The default + implementation just returns false. + */ + updateDOM(dom, view, from) { return false; } + /** + @internal + */ + compare(other) { + return this == other || this.constructor == other.constructor && this.eq(other); + } + /** + The estimated height this widget will have, to be used when + estimating the height of content that hasn't been drawn. May + return -1 to indicate you don't know. The default implementation + returns -1. + */ + get estimatedHeight() { return -1; } + /** + For inline widgets that are displayed inline (as opposed to + `inline-block`) and introduce line breaks (through `
` tags + or textual newlines), this must indicate the amount of line + breaks they introduce. Defaults to 0. + */ + get lineBreaks() { return 0; } + /** + Can be used to configure which kinds of events inside the widget + should be ignored by the editor. The default is to ignore all + events. + */ + ignoreEvent(event) { return true; } + /** + Override the way screen coordinates for positions at/in the + widget are found. `pos` will be the offset into the widget, and + `side` the side of the position that is being queried—less than + zero for before, greater than zero for after, and zero for + directly at that position. + */ + coordsAt(dom, pos, side) { return null; } + /** + @internal + */ + get isHidden() { return false; } + /** + @internal + */ + get editable() { return false; } + /** + This is called when the an instance of the widget is removed + from the editor view. + */ + destroy(dom) { } +} +/** +The different types of blocks that can occur in an editor view. +*/ +exports.BlockType = void 0; +(function (BlockType) { + /** + A line of text. + */ + BlockType[BlockType["Text"] = 0] = "Text"; + /** + A block widget associated with the position after it. + */ + BlockType[BlockType["WidgetBefore"] = 1] = "WidgetBefore"; + /** + A block widget associated with the position before it. + */ + BlockType[BlockType["WidgetAfter"] = 2] = "WidgetAfter"; + /** + A block widget [replacing](https://codemirror.net/6/docs/ref/#view.Decoration^replace) a range of content. + */ + BlockType[BlockType["WidgetRange"] = 3] = "WidgetRange"; +})(exports.BlockType || (exports.BlockType = {})); +/** +A decoration provides information on how to draw or style a piece +of content. You'll usually use it wrapped in a +[`Range`](https://codemirror.net/6/docs/ref/#state.Range), which adds a start and end position. +@nonabstract +*/ +class Decoration extends state.RangeValue { + constructor( + /** + @internal + */ + startSide, + /** + @internal + */ + endSide, + /** + @internal + */ + widget, + /** + The config object used to create this decoration. You can + include additional properties in there to store metadata about + your decoration. + */ + spec) { + super(); + this.startSide = startSide; + this.endSide = endSide; + this.widget = widget; + this.spec = spec; + } + /** + @internal + */ + get heightRelevant() { return false; } + /** + Create a mark decoration, which influences the styling of the + content in its range. Nested mark decorations will cause nested + DOM elements to be created. Nesting order is determined by + precedence of the [facet](https://codemirror.net/6/docs/ref/#view.EditorView^decorations), with + the higher-precedence decorations creating the inner DOM nodes. + Such elements are split on line boundaries and on the boundaries + of lower-precedence decorations. + */ + static mark(spec) { + return new MarkDecoration(spec); + } + /** + Create a widget decoration, which displays a DOM element at the + given position. + */ + static widget(spec) { + let side = Math.max(-1e4, Math.min(10000, spec.side || 0)), block = !!spec.block; + side += (block && !spec.inlineOrder) + ? (side > 0 ? 300000000 /* Side.BlockAfter */ : -4e8 /* Side.BlockBefore */) + : (side > 0 ? 100000000 /* Side.InlineAfter */ : -1e8 /* Side.InlineBefore */); + return new PointDecoration(spec, side, side, block, spec.widget || null, false); + } + /** + Create a replace decoration which replaces the given range with + a widget, or simply hides it. + */ + static replace(spec) { + let block = !!spec.block, startSide, endSide; + if (spec.isBlockGap) { + startSide = -5e8 /* Side.GapStart */; + endSide = 400000000 /* Side.GapEnd */; + } + else { + let { start, end } = getInclusive(spec, block); + startSide = (start ? (block ? -3e8 /* Side.BlockIncStart */ : -1 /* Side.InlineIncStart */) : 500000000 /* Side.NonIncStart */) - 1; + endSide = (end ? (block ? 200000000 /* Side.BlockIncEnd */ : 1 /* Side.InlineIncEnd */) : -6e8 /* Side.NonIncEnd */) + 1; + } + return new PointDecoration(spec, startSide, endSide, block, spec.widget || null, true); + } + /** + Create a line decoration, which can add DOM attributes to the + line starting at the given position. + */ + static line(spec) { + return new LineDecoration(spec); + } + /** + Build a [`DecorationSet`](https://codemirror.net/6/docs/ref/#view.DecorationSet) from the given + decorated range or ranges. If the ranges aren't already sorted, + pass `true` for `sort` to make the library sort them for you. + */ + static set(of, sort = false) { + return state.RangeSet.of(of, sort); + } + /** + @internal + */ + hasHeight() { return this.widget ? this.widget.estimatedHeight > -1 : false; } +} +/** +The empty set of decorations. +*/ +Decoration.none = state.RangeSet.empty; +class MarkDecoration extends Decoration { + constructor(spec) { + let { start, end } = getInclusive(spec); + super(start ? -1 /* Side.InlineIncStart */ : 500000000 /* Side.NonIncStart */, end ? 1 /* Side.InlineIncEnd */ : -6e8 /* Side.NonIncEnd */, null, spec); + this.tagName = spec.tagName || "span"; + this.attrs = spec.class && spec.attributes ? combineAttrs(spec.attributes, { class: spec.class }) + : spec.class ? { class: spec.class } : spec.attributes || noAttrs; + } + eq(other) { + return this == other || other instanceof MarkDecoration && this.tagName == other.tagName && attrsEq(this.attrs, other.attrs); + } + range(from, to = from) { + if (from >= to) + throw new RangeError("Mark decorations may not be empty"); + return super.range(from, to); + } +} +MarkDecoration.prototype.point = false; +class LineDecoration extends Decoration { + constructor(spec) { + super(-2e8 /* Side.Line */, -2e8 /* Side.Line */, null, spec); + } + eq(other) { + return other instanceof LineDecoration && + this.spec.class == other.spec.class && + attrsEq(this.spec.attributes, other.spec.attributes); + } + range(from, to = from) { + if (to != from) + throw new RangeError("Line decoration ranges must be zero-length"); + return super.range(from, to); + } +} +LineDecoration.prototype.mapMode = state.MapMode.TrackBefore; +LineDecoration.prototype.point = true; +class PointDecoration extends Decoration { + constructor(spec, startSide, endSide, block, widget, isReplace) { + super(startSide, endSide, widget, spec); + this.block = block; + this.isReplace = isReplace; + this.mapMode = !block ? state.MapMode.TrackDel : startSide <= 0 ? state.MapMode.TrackBefore : state.MapMode.TrackAfter; + } + // Only relevant when this.block == true + get type() { + return this.startSide != this.endSide ? exports.BlockType.WidgetRange + : this.startSide <= 0 ? exports.BlockType.WidgetBefore : exports.BlockType.WidgetAfter; + } + get heightRelevant() { + return this.block || !!this.widget && (this.widget.estimatedHeight >= 5 || this.widget.lineBreaks > 0); + } + eq(other) { + return other instanceof PointDecoration && + widgetsEq(this.widget, other.widget) && + this.block == other.block && + this.startSide == other.startSide && this.endSide == other.endSide; + } + range(from, to = from) { + if (this.isReplace && (from > to || (from == to && this.startSide > 0 && this.endSide <= 0))) + throw new RangeError("Invalid range for replacement decoration"); + if (!this.isReplace && to != from) + throw new RangeError("Widget decorations can only have zero-length ranges"); + return super.range(from, to); + } +} +PointDecoration.prototype.point = true; +function getInclusive(spec, block = false) { + let { inclusiveStart: start, inclusiveEnd: end } = spec; + if (start == null) + start = spec.inclusive; + if (end == null) + end = spec.inclusive; + return { start: start !== null && start !== void 0 ? start : block, end: end !== null && end !== void 0 ? end : block }; +} +function widgetsEq(a, b) { + return a == b || !!(a && b && a.compare(b)); +} +function addRange(from, to, ranges, margin = 0) { + let last = ranges.length - 1; + if (last >= 0 && ranges[last] + margin >= from) + ranges[last] = Math.max(ranges[last], to); + else + ranges.push(from, to); +} +/** +A block wrapper defines a DOM node that wraps lines or other block +wrappers at the top of the document. It affects any line or block +widget that starts inside its range, including blocks starting +directly at `from` but not including `to`. +*/ +class BlockWrapper extends state.RangeValue { + constructor( + /** + @internal + */ + tagName, + /** + @internal + */ + attributes, + /** + @internal + */ + rank) { + super(); + this.tagName = tagName; + this.attributes = attributes; + this.rank = rank; + } + eq(other) { + return other == this || + other instanceof BlockWrapper && this.tagName == other.tagName && attrsEq(this.attributes, other.attributes); + } + /** + Create a block wrapper object with the given tag name and + attributes. + */ + static create(spec) { + return new BlockWrapper(spec.tagName, spec.attributes || noAttrs, spec.rank == null ? 50 : Math.max(0, Math.min(spec.rank, 100))); + } + /** + Create a range set from the given block wrapper ranges. + */ + static set(of, sort = false) { + return state.RangeSet.of(of, sort); + } +} +BlockWrapper.prototype.startSide = BlockWrapper.prototype.endSide = -1; + +function getSelection(root) { + let target; + // Browsers differ on whether shadow roots have a getSelection + // method. If it exists, use that, otherwise, call it on the + // document. + if (root.nodeType == 11) { // Shadow root + target = root.getSelection ? root : root.ownerDocument; + } + else { + target = root; + } + return target.getSelection(); +} +function contains(dom, node) { + return node ? dom == node || dom.contains(node.nodeType != 1 ? node.parentNode : node) : false; +} +function hasSelection(dom, selection) { + if (!selection.anchorNode) + return false; + try { + // Firefox will raise 'permission denied' errors when accessing + // properties of `sel.anchorNode` when it's in a generated CSS + // element. + return contains(dom, selection.anchorNode); + } + catch (_) { + return false; + } +} +function clientRectsFor(dom) { + if (dom.nodeType == 3) + return textRange(dom, 0, dom.nodeValue.length).getClientRects(); + else if (dom.nodeType == 1) + return dom.getClientRects(); + else + return []; +} +// Scans forward and backward through DOM positions equivalent to the +// given one to see if the two are in the same place (i.e. after a +// text node vs at the end of that text node) +function isEquivalentPosition(node, off, targetNode, targetOff) { + return targetNode ? (scanFor(node, off, targetNode, targetOff, -1) || + scanFor(node, off, targetNode, targetOff, 1)) : false; +} +function domIndex(node) { + for (var index = 0;; index++) { + node = node.previousSibling; + if (!node) + return index; + } +} +function isBlockElement(node) { + return node.nodeType == 1 && /^(DIV|P|LI|UL|OL|BLOCKQUOTE|DD|DT|H\d|SECTION|PRE)$/.test(node.nodeName); +} +function scanFor(node, off, targetNode, targetOff, dir) { + for (;;) { + if (node == targetNode && off == targetOff) + return true; + if (off == (dir < 0 ? 0 : maxOffset(node))) { + if (node.nodeName == "DIV") + return false; + let parent = node.parentNode; + if (!parent || parent.nodeType != 1) + return false; + off = domIndex(node) + (dir < 0 ? 0 : 1); + node = parent; + } + else if (node.nodeType == 1) { + node = node.childNodes[off + (dir < 0 ? -1 : 0)]; + if (node.nodeType == 1 && node.contentEditable == "false") + return false; + off = dir < 0 ? maxOffset(node) : 0; + } + else { + return false; + } + } +} +function maxOffset(node) { + return node.nodeType == 3 ? node.nodeValue.length : node.childNodes.length; +} +function flattenRect(rect, toLeft) { + let { left, right } = rect; + if (left == right) + return rect; + let x = toLeft ? left : right; + return { left: x, right: x, top: rect.top, bottom: rect.bottom }; +} +function windowRect(win) { + let vp = win.visualViewport; + if (vp) + return { + left: 0, right: vp.width, + top: 0, bottom: vp.height + }; + return { left: 0, right: win.innerWidth, + top: 0, bottom: win.innerHeight }; +} +function getScale(elt, rect) { + let scaleX = rect.width / elt.offsetWidth; + let scaleY = rect.height / elt.offsetHeight; + if (scaleX > 0.995 && scaleX < 1.005 || !isFinite(scaleX) || Math.abs(rect.width - elt.offsetWidth) < 1) + scaleX = 1; + if (scaleY > 0.995 && scaleY < 1.005 || !isFinite(scaleY) || Math.abs(rect.height - elt.offsetHeight) < 1) + scaleY = 1; + return { scaleX, scaleY }; +} +function scrollRectIntoView(dom, rect, side, x, y, xMargin, yMargin, ltr) { + let doc = dom.ownerDocument, win = doc.defaultView || window; + for (let cur = dom, stop = false; cur && !stop;) { + if (cur.nodeType == 1) { // Element + let bounding, top = cur == doc.body; + let scaleX = 1, scaleY = 1; + if (top) { + bounding = windowRect(win); + } + else { + if (/^(fixed|sticky)$/.test(getComputedStyle(cur).position)) + stop = true; + if (cur.scrollHeight <= cur.clientHeight && cur.scrollWidth <= cur.clientWidth) { + cur = cur.assignedSlot || cur.parentNode; + continue; + } + let rect = cur.getBoundingClientRect(); + ({ scaleX, scaleY } = getScale(cur, rect)); + // Make sure scrollbar width isn't included in the rectangle + bounding = { left: rect.left, right: rect.left + cur.clientWidth * scaleX, + top: rect.top, bottom: rect.top + cur.clientHeight * scaleY }; + } + let moveX = 0, moveY = 0; + if (y == "nearest") { + if (rect.top < bounding.top + yMargin) { + moveY = rect.top - (bounding.top + yMargin); + if (side > 0 && rect.bottom > bounding.bottom + moveY) + moveY = rect.bottom - bounding.bottom + yMargin; + } + else if (rect.bottom > bounding.bottom - yMargin) { + moveY = rect.bottom - bounding.bottom + yMargin; + if (side < 0 && (rect.top - moveY) < bounding.top) + moveY = rect.top - (bounding.top + yMargin); + } + } + else { + let rectHeight = rect.bottom - rect.top, boundingHeight = bounding.bottom - bounding.top; + let targetTop = y == "center" && rectHeight <= boundingHeight ? rect.top + rectHeight / 2 - boundingHeight / 2 : + y == "start" || y == "center" && side < 0 ? rect.top - yMargin : + rect.bottom - boundingHeight + yMargin; + moveY = targetTop - bounding.top; + } + if (x == "nearest") { + if (rect.left < bounding.left + xMargin) { + moveX = rect.left - (bounding.left + xMargin); + if (side > 0 && rect.right > bounding.right + moveX) + moveX = rect.right - bounding.right + xMargin; + } + else if (rect.right > bounding.right - xMargin) { + moveX = rect.right - bounding.right + xMargin; + if (side < 0 && rect.left < bounding.left + moveX) + moveX = rect.left - (bounding.left + xMargin); + } + } + else { + let targetLeft = x == "center" ? rect.left + (rect.right - rect.left) / 2 - (bounding.right - bounding.left) / 2 : + (x == "start") == ltr ? rect.left - xMargin : + rect.right - (bounding.right - bounding.left) + xMargin; + moveX = targetLeft - bounding.left; + } + if (moveX || moveY) { + if (top) { + win.scrollBy(moveX, moveY); + } + else { + let movedX = 0, movedY = 0; + if (moveY) { + let start = cur.scrollTop; + cur.scrollTop += moveY / scaleY; + movedY = (cur.scrollTop - start) * scaleY; + } + if (moveX) { + let start = cur.scrollLeft; + cur.scrollLeft += moveX / scaleX; + movedX = (cur.scrollLeft - start) * scaleX; + } + rect = { left: rect.left - movedX, top: rect.top - movedY, + right: rect.right - movedX, bottom: rect.bottom - movedY }; + if (movedX && Math.abs(movedX - moveX) < 1) + x = "nearest"; + if (movedY && Math.abs(movedY - moveY) < 1) + y = "nearest"; + } + } + if (top) + break; + if (rect.top < bounding.top || rect.bottom > bounding.bottom || + rect.left < bounding.left || rect.right > bounding.right) + rect = { left: Math.max(rect.left, bounding.left), right: Math.min(rect.right, bounding.right), + top: Math.max(rect.top, bounding.top), bottom: Math.min(rect.bottom, bounding.bottom) }; + cur = cur.assignedSlot || cur.parentNode; + } + else if (cur.nodeType == 11) { // A shadow root + cur = cur.host; + } + else { + break; + } + } +} +function scrollableParents(dom, getX = true) { + let doc = dom.ownerDocument, x = null, y = null; + for (let cur = dom.parentNode; cur;) { + if (cur == doc.body || ((!getX || x) && y)) { + break; + } + else if (cur.nodeType == 1) { + if (!y && cur.scrollHeight > cur.clientHeight) + y = cur; + if (getX && !x && cur.scrollWidth > cur.clientWidth) + x = cur; + cur = cur.assignedSlot || cur.parentNode; + } + else if (cur.nodeType == 11) { + cur = cur.host; + } + else { + break; + } + } + return { x, y }; +} +class DOMSelectionState { + constructor() { + this.anchorNode = null; + this.anchorOffset = 0; + this.focusNode = null; + this.focusOffset = 0; + } + eq(domSel) { + return this.anchorNode == domSel.anchorNode && this.anchorOffset == domSel.anchorOffset && + this.focusNode == domSel.focusNode && this.focusOffset == domSel.focusOffset; + } + setRange(range) { + let { anchorNode, focusNode } = range; + // Clip offsets to node size to avoid crashes when Safari reports bogus offsets (#1152) + this.set(anchorNode, Math.min(range.anchorOffset, anchorNode ? maxOffset(anchorNode) : 0), focusNode, Math.min(range.focusOffset, focusNode ? maxOffset(focusNode) : 0)); + } + set(anchorNode, anchorOffset, focusNode, focusOffset) { + this.anchorNode = anchorNode; + this.anchorOffset = anchorOffset; + this.focusNode = focusNode; + this.focusOffset = focusOffset; + } +} +function getScrollStack(target) { + let stack = []; + for (let cur = target; cur; cur = cur.nodeType == 11 ? cur.host : cur.parentNode) { + if (cur.nodeType == 1) + stack.push({ node: cur, left: cur.scrollLeft, top: cur.scrollTop }); + } + return stack; +} +function restoreScrollStack(stack, vert = true) { + for (let { node, left, top } of stack) { + if (vert && node.scrollTop != top) + node.scrollTop = top; + if (node.scrollLeft != left) + node.scrollLeft = left; + } +} +let preventScrollSupported = null; +// Safari 26 breaks preventScroll support +if (browser.safari && browser.safari_version >= 26) + preventScrollSupported = false; +// Feature-detects support for .focus({preventScroll: true}), and uses +// a fallback kludge when not supported. +function focusPreventScroll(dom) { + if (dom.setActive) + return dom.setActive(); // in IE + if (preventScrollSupported) + return dom.focus(preventScrollSupported); + let stack = getScrollStack(dom); + dom.focus(preventScrollSupported == null ? { + get preventScroll() { + preventScrollSupported = { preventScroll: true }; + return true; + } + } : undefined); + if (!preventScrollSupported) { + preventScrollSupported = false; + restoreScrollStack(stack); + } +} +let scratchRange; +function textRange(node, from, to = from) { + let range = scratchRange || (scratchRange = document.createRange()); + range.setEnd(node, to); + range.setStart(node, from); + return range; +} +function dispatchKey(elt, name, code, mods) { + let options = { key: name, code: name, keyCode: code, which: code, cancelable: true }; + if (mods) + ({ altKey: options.altKey, ctrlKey: options.ctrlKey, shiftKey: options.shiftKey, metaKey: options.metaKey } = mods); + let down = new KeyboardEvent("keydown", options); + down.synthetic = true; + elt.dispatchEvent(down); + let up = new KeyboardEvent("keyup", options); + up.synthetic = true; + elt.dispatchEvent(up); + return down.defaultPrevented || up.defaultPrevented; +} +function getRoot(node) { + while (node) { + if (node && (node.nodeType == 9 || node.nodeType == 11 && node.host)) + return node; + node = node.assignedSlot || node.parentNode; + } + return null; +} +function atElementStart(doc, selection) { + let node = selection.focusNode, offset = selection.focusOffset; + if (!node || selection.anchorNode != node || selection.anchorOffset != offset) + return false; + // Safari can report bogus offsets (#1152) + offset = Math.min(offset, maxOffset(node)); + for (;;) { + if (offset) { + if (node.nodeType != 1) + return false; + let prev = node.childNodes[offset - 1]; + if (prev.contentEditable == "false") + offset--; + else { + node = prev; + offset = maxOffset(node); + } + } + else if (node == doc) { + return true; + } + else { + offset = domIndex(node); + node = node.parentNode; + } + } +} +function isScrolledToBottom(elt) { + if (elt instanceof Window) + return elt.pageYOffset > Math.max(0, elt.document.documentElement.scrollHeight - elt.innerHeight - 4); + return elt.scrollTop > Math.max(1, elt.scrollHeight - elt.clientHeight - 4); +} +function textNodeBefore(startNode, startOffset) { + for (let node = startNode, offset = startOffset;;) { + if (node.nodeType == 3 && offset > 0) { + return { node: node, offset: offset }; + } + else if (node.nodeType == 1 && offset > 0) { + if (node.contentEditable == "false") + return null; + node = node.childNodes[offset - 1]; + offset = maxOffset(node); + } + else if (node.parentNode && !isBlockElement(node)) { + offset = domIndex(node); + node = node.parentNode; + } + else { + return null; + } + } +} +function textNodeAfter(startNode, startOffset) { + for (let node = startNode, offset = startOffset;;) { + if (node.nodeType == 3 && offset < node.nodeValue.length) { + return { node: node, offset: offset }; + } + else if (node.nodeType == 1 && offset < node.childNodes.length) { + if (node.contentEditable == "false") + return null; + node = node.childNodes[offset]; + offset = 0; + } + else if (node.parentNode && !isBlockElement(node)) { + offset = domIndex(node) + 1; + node = node.parentNode; + } + else { + return null; + } + } +} +class DOMPos { + constructor(node, offset, precise = true) { + this.node = node; + this.offset = offset; + this.precise = precise; + } + static before(dom, precise) { return new DOMPos(dom.parentNode, domIndex(dom), precise); } + static after(dom, precise) { return new DOMPos(dom.parentNode, domIndex(dom) + 1, precise); } +} + +/** +Used to indicate [text direction](https://codemirror.net/6/docs/ref/#view.EditorView.textDirection). +*/ +exports.Direction = void 0; +(function (Direction) { + // (These are chosen to match the base levels, in bidi algorithm + // terms, of spans in that direction.) + /** + Left-to-right. + */ + Direction[Direction["LTR"] = 0] = "LTR"; + /** + Right-to-left. + */ + Direction[Direction["RTL"] = 1] = "RTL"; +})(exports.Direction || (exports.Direction = {})); +const LTR = exports.Direction.LTR, RTL = exports.Direction.RTL; +// Decode a string with each type encoded as log2(type) +function dec(str) { + let result = []; + for (let i = 0; i < str.length; i++) + result.push(1 << +str[i]); + return result; +} +// Character types for codepoints 0 to 0xf8 +const LowTypes = dec("88888888888888888888888888888888888666888888787833333333337888888000000000000000000000000008888880000000000000000000000000088888888888888888888888888888888888887866668888088888663380888308888800000000000000000000000800000000000000000000000000000008"); +// Character types for codepoints 0x600 to 0x6f9 +const ArabicTypes = dec("4444448826627288999999999992222222222222222222222222222222222222222222222229999999999999999999994444444444644222822222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222999999949999999229989999223333333333"); +const Brackets = Object.create(null), BracketStack = []; +// There's a lot more in +// https://www.unicode.org/Public/UCD/latest/ucd/BidiBrackets.txt, +// which are left out to keep code size down. +for (let p of ["()", "[]", "{}"]) { + let l = p.charCodeAt(0), r = p.charCodeAt(1); + Brackets[l] = r; + Brackets[r] = -l; +} +function charType(ch) { + return ch <= 0xf7 ? LowTypes[ch] : + 0x590 <= ch && ch <= 0x5f4 ? 2 /* T.R */ : + 0x600 <= ch && ch <= 0x6f9 ? ArabicTypes[ch - 0x600] : + 0x6ee <= ch && ch <= 0x8ac ? 4 /* T.AL */ : + 0x2000 <= ch && ch <= 0x200c ? 256 /* T.NI */ : + 0xfb50 <= ch && ch <= 0xfdff ? 4 /* T.AL */ : 1 /* T.L */; +} +const BidiRE = /[\u0590-\u05f4\u0600-\u06ff\u0700-\u08ac\ufb50-\ufdff]/; +/** +Represents a contiguous range of text that has a single direction +(as in left-to-right or right-to-left). +*/ +class BidiSpan { + /** + The direction of this span. + */ + get dir() { return this.level % 2 ? RTL : LTR; } + /** + @internal + */ + constructor( + /** + The start of the span (relative to the start of the line). + */ + from, + /** + The end of the span. + */ + to, + /** + The ["bidi + level"](https://unicode.org/reports/tr9/#Basic_Display_Algorithm) + of the span (in this context, 0 means + left-to-right, 1 means right-to-left, 2 means left-to-right + number inside right-to-left text). + */ + level) { + this.from = from; + this.to = to; + this.level = level; + } + /** + @internal + */ + side(end, dir) { return (this.dir == dir) == end ? this.to : this.from; } + /** + @internal + */ + forward(forward, dir) { return forward == (this.dir == dir); } + /** + @internal + */ + static find(order, index, level, assoc) { + let maybe = -1; + for (let i = 0; i < order.length; i++) { + let span = order[i]; + if (span.from <= index && span.to >= index) { + if (span.level == level) + return i; + // When multiple spans match, if assoc != 0, take the one that + // covers that side, otherwise take the one with the minimum + // level. + if (maybe < 0 || (assoc != 0 ? (assoc < 0 ? span.from < index : span.to > index) : order[maybe].level > span.level)) + maybe = i; + } + } + if (maybe < 0) + throw new RangeError("Index out of range"); + return maybe; + } +} +function isolatesEq(a, b) { + if (a.length != b.length) + return false; + for (let i = 0; i < a.length; i++) { + let iA = a[i], iB = b[i]; + if (iA.from != iB.from || iA.to != iB.to || iA.direction != iB.direction || !isolatesEq(iA.inner, iB.inner)) + return false; + } + return true; +} +// Reused array of character types +const types = []; +// Fill in the character types (in `types`) from `from` to `to` and +// apply W normalization rules. +function computeCharTypes(line, rFrom, rTo, isolates, outerType) { + for (let iI = 0; iI <= isolates.length; iI++) { + let from = iI ? isolates[iI - 1].to : rFrom, to = iI < isolates.length ? isolates[iI].from : rTo; + let prevType = iI ? 256 /* T.NI */ : outerType; + // W1. Examine each non-spacing mark (NSM) in the level run, and + // change the type of the NSM to the type of the previous + // character. If the NSM is at the start of the level run, it will + // get the type of sor. + // W2. Search backwards from each instance of a European number + // until the first strong type (R, L, AL, or sor) is found. If an + // AL is found, change the type of the European number to Arabic + // number. + // W3. Change all ALs to R. + // (Left after this: L, R, EN, AN, ET, CS, NI) + for (let i = from, prev = prevType, prevStrong = prevType; i < to; i++) { + let type = charType(line.charCodeAt(i)); + if (type == 512 /* T.NSM */) + type = prev; + else if (type == 8 /* T.EN */ && prevStrong == 4 /* T.AL */) + type = 16 /* T.AN */; + types[i] = type == 4 /* T.AL */ ? 2 /* T.R */ : type; + if (type & 7 /* T.Strong */) + prevStrong = type; + prev = type; + } + // W5. A sequence of European terminators adjacent to European + // numbers changes to all European numbers. + // W6. Otherwise, separators and terminators change to Other + // Neutral. + // W7. Search backwards from each instance of a European number + // until the first strong type (R, L, or sor) is found. If an L is + // found, then change the type of the European number to L. + // (Left after this: L, R, EN+AN, NI) + for (let i = from, prev = prevType, prevStrong = prevType; i < to; i++) { + let type = types[i]; + if (type == 128 /* T.CS */) { + if (i < to - 1 && prev == types[i + 1] && (prev & 24 /* T.Num */)) + type = types[i] = prev; + else + types[i] = 256 /* T.NI */; + } + else if (type == 64 /* T.ET */) { + let end = i + 1; + while (end < to && types[end] == 64 /* T.ET */) + end++; + let replace = (i && prev == 8 /* T.EN */) || (end < rTo && types[end] == 8 /* T.EN */) ? (prevStrong == 1 /* T.L */ ? 1 /* T.L */ : 8 /* T.EN */) : 256 /* T.NI */; + for (let j = i; j < end; j++) + types[j] = replace; + i = end - 1; + } + else if (type == 8 /* T.EN */ && prevStrong == 1 /* T.L */) { + types[i] = 1 /* T.L */; + } + prev = type; + if (type & 7 /* T.Strong */) + prevStrong = type; + } + } +} +// Process brackets throughout a run sequence. +function processBracketPairs(line, rFrom, rTo, isolates, outerType) { + let oppositeType = outerType == 1 /* T.L */ ? 2 /* T.R */ : 1 /* T.L */; + for (let iI = 0, sI = 0, context = 0; iI <= isolates.length; iI++) { + let from = iI ? isolates[iI - 1].to : rFrom, to = iI < isolates.length ? isolates[iI].from : rTo; + // N0. Process bracket pairs in an isolating run sequence + // sequentially in the logical order of the text positions of the + // opening paired brackets using the logic given below. Within this + // scope, bidirectional types EN and AN are treated as R. + for (let i = from, ch, br, type; i < to; i++) { + // Keeps [startIndex, type, strongSeen] triples for each open + // bracket on BracketStack. + if (br = Brackets[ch = line.charCodeAt(i)]) { + if (br < 0) { // Closing bracket + for (let sJ = sI - 3; sJ >= 0; sJ -= 3) { + if (BracketStack[sJ + 1] == -br) { + let flags = BracketStack[sJ + 2]; + let type = (flags & 2 /* Bracketed.EmbedInside */) ? outerType : + !(flags & 4 /* Bracketed.OppositeInside */) ? 0 : + (flags & 1 /* Bracketed.OppositeBefore */) ? oppositeType : outerType; + if (type) + types[i] = types[BracketStack[sJ]] = type; + sI = sJ; + break; + } + } + } + else if (BracketStack.length == 189 /* Bracketed.MaxDepth */) { + break; + } + else { + BracketStack[sI++] = i; + BracketStack[sI++] = ch; + BracketStack[sI++] = context; + } + } + else if ((type = types[i]) == 2 /* T.R */ || type == 1 /* T.L */) { + let embed = type == outerType; + context = embed ? 0 : 1 /* Bracketed.OppositeBefore */; + for (let sJ = sI - 3; sJ >= 0; sJ -= 3) { + let cur = BracketStack[sJ + 2]; + if (cur & 2 /* Bracketed.EmbedInside */) + break; + if (embed) { + BracketStack[sJ + 2] |= 2 /* Bracketed.EmbedInside */; + } + else { + if (cur & 4 /* Bracketed.OppositeInside */) + break; + BracketStack[sJ + 2] |= 4 /* Bracketed.OppositeInside */; + } + } + } + } + } +} +function processNeutrals(rFrom, rTo, isolates, outerType) { + for (let iI = 0, prev = outerType; iI <= isolates.length; iI++) { + let from = iI ? isolates[iI - 1].to : rFrom, to = iI < isolates.length ? isolates[iI].from : rTo; + // N1. A sequence of neutrals takes the direction of the + // surrounding strong text if the text on both sides has the same + // direction. European and Arabic numbers act as if they were R in + // terms of their influence on neutrals. Start-of-level-run (sor) + // and end-of-level-run (eor) are used at level run boundaries. + // N2. Any remaining neutrals take the embedding direction. + // (Left after this: L, R, EN+AN) + for (let i = from; i < to;) { + let type = types[i]; + if (type == 256 /* T.NI */) { + let end = i + 1; + for (;;) { + if (end == to) { + if (iI == isolates.length) + break; + end = isolates[iI++].to; + to = iI < isolates.length ? isolates[iI].from : rTo; + } + else if (types[end] == 256 /* T.NI */) { + end++; + } + else { + break; + } + } + let beforeL = prev == 1 /* T.L */; + let afterL = (end < rTo ? types[end] : outerType) == 1 /* T.L */; + let replace = beforeL == afterL ? (beforeL ? 1 /* T.L */ : 2 /* T.R */) : outerType; + for (let j = end, jI = iI, fromJ = jI ? isolates[jI - 1].to : rFrom; j > i;) { + if (j == fromJ) { + j = isolates[--jI].from; + fromJ = jI ? isolates[jI - 1].to : rFrom; + } + types[--j] = replace; + } + i = end; + } + else { + prev = type; + i++; + } + } + } +} +// Find the contiguous ranges of character types in a given range, and +// emit spans for them. Flip the order of the spans as appropriate +// based on the level, and call through to compute the spans for +// isolates at the proper point. +function emitSpans(line, from, to, level, baseLevel, isolates, order) { + let ourType = level % 2 ? 2 /* T.R */ : 1 /* T.L */; + if ((level % 2) == (baseLevel % 2)) { // Same dir as base direction, don't flip + for (let iCh = from, iI = 0; iCh < to;) { + // Scan a section of characters in direction ourType, unless + // there's another type of char right after iCh, in which case + // we scan a section of other characters (which, if ourType == + // T.L, may contain both T.R and T.AN chars). + let sameDir = true, isNum = false; + if (iI == isolates.length || iCh < isolates[iI].from) { + let next = types[iCh]; + if (next != ourType) { + sameDir = false; + isNum = next == 16 /* T.AN */; + } + } + // Holds an array of isolates to pass to a recursive call if we + // must recurse (to distinguish T.AN inside an RTL section in + // LTR text), null if we can emit directly + let recurse = !sameDir && ourType == 1 /* T.L */ ? [] : null; + let localLevel = sameDir ? level : level + 1; + let iScan = iCh; + run: for (;;) { + if (iI < isolates.length && iScan == isolates[iI].from) { + if (isNum) + break run; + let iso = isolates[iI]; + // Scan ahead to verify that there is another char in this dir after the isolate(s) + if (!sameDir) + for (let upto = iso.to, jI = iI + 1;;) { + if (upto == to) + break run; + if (jI < isolates.length && isolates[jI].from == upto) + upto = isolates[jI++].to; + else if (types[upto] == ourType) + break run; + else + break; + } + iI++; + if (recurse) { + recurse.push(iso); + } + else { + if (iso.from > iCh) + order.push(new BidiSpan(iCh, iso.from, localLevel)); + let dirSwap = (iso.direction == LTR) != !(localLevel % 2); + computeSectionOrder(line, dirSwap ? level + 1 : level, baseLevel, iso.inner, iso.from, iso.to, order); + iCh = iso.to; + } + iScan = iso.to; + } + else if (iScan == to || (sameDir ? types[iScan] != ourType : types[iScan] == ourType)) { + break; + } + else { + iScan++; + } + } + if (recurse) + emitSpans(line, iCh, iScan, level + 1, baseLevel, recurse, order); + else if (iCh < iScan) + order.push(new BidiSpan(iCh, iScan, localLevel)); + iCh = iScan; + } + } + else { + // Iterate in reverse to flip the span order. Same code again, but + // going from the back of the section to the front + for (let iCh = to, iI = isolates.length; iCh > from;) { + let sameDir = true, isNum = false; + if (!iI || iCh > isolates[iI - 1].to) { + let next = types[iCh - 1]; + if (next != ourType) { + sameDir = false; + isNum = next == 16 /* T.AN */; + } + } + let recurse = !sameDir && ourType == 1 /* T.L */ ? [] : null; + let localLevel = sameDir ? level : level + 1; + let iScan = iCh; + run: for (;;) { + if (iI && iScan == isolates[iI - 1].to) { + if (isNum) + break run; + let iso = isolates[--iI]; + // Scan ahead to verify that there is another char in this dir after the isolate(s) + if (!sameDir) + for (let upto = iso.from, jI = iI;;) { + if (upto == from) + break run; + if (jI && isolates[jI - 1].to == upto) + upto = isolates[--jI].from; + else if (types[upto - 1] == ourType) + break run; + else + break; + } + if (recurse) { + recurse.push(iso); + } + else { + if (iso.to < iCh) + order.push(new BidiSpan(iso.to, iCh, localLevel)); + let dirSwap = (iso.direction == LTR) != !(localLevel % 2); + computeSectionOrder(line, dirSwap ? level + 1 : level, baseLevel, iso.inner, iso.from, iso.to, order); + iCh = iso.from; + } + iScan = iso.from; + } + else if (iScan == from || (sameDir ? types[iScan - 1] != ourType : types[iScan - 1] == ourType)) { + break; + } + else { + iScan--; + } + } + if (recurse) + emitSpans(line, iScan, iCh, level + 1, baseLevel, recurse, order); + else if (iScan < iCh) + order.push(new BidiSpan(iScan, iCh, localLevel)); + iCh = iScan; + } + } +} +function computeSectionOrder(line, level, baseLevel, isolates, from, to, order) { + let outerType = (level % 2 ? 2 /* T.R */ : 1 /* T.L */); + computeCharTypes(line, from, to, isolates, outerType); + processBracketPairs(line, from, to, isolates, outerType); + processNeutrals(from, to, isolates, outerType); + emitSpans(line, from, to, level, baseLevel, isolates, order); +} +function computeOrder(line, direction, isolates) { + if (!line) + return [new BidiSpan(0, 0, direction == RTL ? 1 : 0)]; + if (direction == LTR && !isolates.length && !BidiRE.test(line)) + return trivialOrder(line.length); + if (isolates.length) + while (line.length > types.length) + types[types.length] = 256 /* T.NI */; // Make sure types array has no gaps + let order = [], level = direction == LTR ? 0 : 1; + computeSectionOrder(line, level, level, isolates, 0, line.length, order); + return order; +} +function trivialOrder(length) { + return [new BidiSpan(0, length, 0)]; +} +let movedOver = ""; +// This implementation moves strictly visually, without concern for a +// traversal visiting every logical position in the string. It will +// still do so for simple input, but situations like multiple isolates +// with the same level next to each other, or text going against the +// main dir at the end of the line, will make some positions +// unreachable with this motion. Each visible cursor position will +// correspond to the lower-level bidi span that touches it. +// +// The alternative would be to solve an order globally for a given +// line, making sure that it includes every position, but that would +// require associating non-canonical (higher bidi span level) +// positions with a given visual position, which is likely to confuse +// people. (And would generally be a lot more complicated.) +function moveVisually(line, order, dir, start, forward) { + var _a; + let startIndex = start.head - line.from; + let spanI = BidiSpan.find(order, startIndex, (_a = start.bidiLevel) !== null && _a !== void 0 ? _a : -1, start.assoc); + let span = order[spanI], spanEnd = span.side(forward, dir); + // End of span + if (startIndex == spanEnd) { + let nextI = spanI += forward ? 1 : -1; + if (nextI < 0 || nextI >= order.length) + return null; + span = order[spanI = nextI]; + startIndex = span.side(!forward, dir); + spanEnd = span.side(forward, dir); + } + let nextIndex = state.findClusterBreak(line.text, startIndex, span.forward(forward, dir)); + if (nextIndex < span.from || nextIndex > span.to) + nextIndex = spanEnd; + movedOver = line.text.slice(Math.min(startIndex, nextIndex), Math.max(startIndex, nextIndex)); + let nextSpan = spanI == (forward ? order.length - 1 : 0) ? null : order[spanI + (forward ? 1 : -1)]; + if (nextSpan && nextIndex == spanEnd && nextSpan.level + (forward ? 0 : 1) < span.level) + return state.EditorSelection.cursor(nextSpan.side(!forward, dir) + line.from, nextSpan.forward(forward, dir) ? 1 : -1, nextSpan.level); + return state.EditorSelection.cursor(nextIndex + line.from, span.forward(forward, dir) ? -1 : 1, span.level); +} +function autoDirection(text, from, to) { + for (let i = from; i < to; i++) { + let type = charType(text.charCodeAt(i)); + if (type == 1 /* T.L */) + return LTR; + if (type == 2 /* T.R */ || type == 4 /* T.AL */) + return RTL; + } + return LTR; +} + +const clickAddsSelectionRange = state.Facet.define(); +const dragMovesSelection$1 = state.Facet.define(); +const mouseSelectionStyle = state.Facet.define(); +const exceptionSink = state.Facet.define(); +const updateListener = state.Facet.define(); +const inputHandler = state.Facet.define(); +const focusChangeEffect = state.Facet.define(); +const clipboardInputFilter = state.Facet.define(); +const clipboardOutputFilter = state.Facet.define(); +const perLineTextDirection = state.Facet.define({ + combine: values => values.some(x => x) +}); +const nativeSelectionHidden = state.Facet.define({ + combine: values => values.some(x => x) +}); +const scrollHandler = state.Facet.define(); +/** +A facet that enables directional viewport buffering. Without this facet, +viewport behavior is identical to upstream CodeMirror. +*/ +const viewportBuffer = state.Facet.define({ + combine(values) { + if (!values.length) + return null; + let value = values[0]; + return { + maxAhead: Math.max(0, Math.min(4, Number.isFinite(value.maxAhead) ? value.maxAhead : 4)), + settleDelay: Math.max(0, Number.isFinite(value.settleDelay) ? value.settleDelay : 120) + }; + } +}); +/** +A facet enabling render-gated Android touch scrolling. Without this facet, +touch scrolling is identical to upstream CodeMirror. +*/ +const controlledTouchScroll = state.Facet.define({ + combine(values) { + if (!values.length) + return null; + let value = values[0]; + return { + waitForRendering: value.waitForRendering !== false, + maxAhead: Math.max(1, Math.min(4, Number.isFinite(value.maxAhead) ? value.maxAhead : 4)), + settleDelay: Math.max(0, Number.isFinite(value.settleDelay) ? value.settleDelay : 120) + }; + } +}); +class ScrollTarget { + constructor(range, y, x, yMargin, xMargin, + // This data structure is abused to also store precise scroll + // snapshots, instead of a `scrollIntoView` request. When this + // flag is `true`, `range` points at a position in the reference + // line, `yMargin` holds the difference between the top of that + // line and the top of the editor, and `xMargin` holds the + // editor's `scrollLeft`. + isSnapshot = false) { + this.range = range; + this.y = y; + this.x = x; + this.yMargin = yMargin; + this.xMargin = xMargin; + this.isSnapshot = isSnapshot; + } + map(changes) { + return changes.empty ? this : + new ScrollTarget(this.range.map(changes), this.y, this.x, this.yMargin, this.xMargin, this.isSnapshot); + } + clip(state$1) { + return this.range.to <= state$1.doc.length ? this : + new ScrollTarget(state.EditorSelection.cursor(state$1.doc.length), this.y, this.x, this.yMargin, this.xMargin, this.isSnapshot); + } +} +const scrollIntoView = state.StateEffect.define({ map: (t, ch) => t.map(ch) }); +const setEditContextFormatting = state.StateEffect.define(); +/** +Log or report an unhandled exception in client code. Should +probably only be used by extension code that allows client code to +provide functions, and calls those functions in a context where an +exception can't be propagated to calling code in a reasonable way +(for example when in an event handler). + +Either calls a handler registered with +[`EditorView.exceptionSink`](https://codemirror.net/6/docs/ref/#view.EditorView^exceptionSink), +`window.onerror`, if defined, or `console.error` (in which case +it'll pass `context`, when given, as first argument). +*/ +function logException(state, exception, context) { + let handler = state.facet(exceptionSink); + if (handler.length) + handler[0](exception); + else if (window.onerror && window.onerror(String(exception), context, undefined, undefined, exception)) ; + else if (context) + console.error(context + ":", exception); + else + console.error(exception); +} +const editable = state.Facet.define({ combine: values => values.length ? values[0] : true }); +let nextPluginID = 0; +const viewPlugin = state.Facet.define({ + combine(plugins) { + return plugins.filter((p, i) => { + for (let j = 0; j < i; j++) + if (plugins[j].plugin == p.plugin) + return false; + return true; + }); + } +}); +/** +View plugins associate stateful values with a view. They can +influence the way the content is drawn, and are notified of things +that happen in the view. They optionally take an argument, in +which case you need to call [`of`](https://codemirror.net/6/docs/ref/#view.ViewPlugin.of) to create +an extension for the plugin. When the argument type is undefined, +you can use the plugin instance as an extension directly. +*/ +class ViewPlugin { + constructor( + /** + @internal + */ + id, + /** + @internal + */ + create, + /** + @internal + */ + domEventHandlers, + /** + @internal + */ + domEventObservers, buildExtensions) { + this.id = id; + this.create = create; + this.domEventHandlers = domEventHandlers; + this.domEventObservers = domEventObservers; + this.baseExtensions = buildExtensions(this); + this.extension = this.baseExtensions.concat(viewPlugin.of({ plugin: this, arg: undefined })); + } + /** + Create an extension for this plugin with the given argument. + */ + of(arg) { + return this.baseExtensions.concat(viewPlugin.of({ plugin: this, arg })); + } + /** + Define a plugin from a constructor function that creates the + plugin's value, given an editor view. + */ + static define(create, spec) { + const { eventHandlers, eventObservers, provide, decorations: deco } = spec || {}; + return new ViewPlugin(nextPluginID++, create, eventHandlers, eventObservers, plugin => { + let ext = []; + if (deco) + ext.push(decorations.of(view => { + let pluginInst = view.plugin(plugin); + return pluginInst ? deco(pluginInst) : Decoration.none; + })); + if (provide) + ext.push(provide(plugin)); + return ext; + }); + } + /** + Create a plugin for a class whose constructor takes a single + editor view as argument. + */ + static fromClass(cls, spec) { + return ViewPlugin.define((view, arg) => new cls(view, arg), spec); + } +} +class PluginInstance { + constructor(spec) { + this.spec = spec; + // When starting an update, all plugins have this field set to the + // update object, indicating they need to be updated. When finished + // updating, it is set to `null`. Retrieving a plugin that needs to + // be updated with `view.plugin` forces an eager update. + this.mustUpdate = null; + // This is null when the plugin is initially created, but + // initialized on the first update. + this.value = null; + } + get plugin() { return this.spec && this.spec.plugin; } + update(view) { + if (!this.value) { + if (this.spec) { + try { + this.value = this.spec.plugin.create(view, this.spec.arg); + } + catch (e) { + logException(view.state, e, "CodeMirror plugin crashed"); + this.deactivate(); + } + } + } + else if (this.mustUpdate) { + let update = this.mustUpdate; + this.mustUpdate = null; + if (this.value.update) { + try { + this.value.update(update); + } + catch (e) { + logException(update.state, e, "CodeMirror plugin crashed"); + if (this.value.destroy) + try { + this.value.destroy(); + } + catch (_) { } + this.deactivate(); + } + } + } + return this; + } + destroy(view) { + var _a; + if ((_a = this.value) === null || _a === void 0 ? void 0 : _a.destroy) { + try { + this.value.destroy(); + } + catch (e) { + logException(view.state, e, "CodeMirror plugin crashed"); + } + } + } + deactivate() { + this.spec = this.value = null; + } +} +const editorAttributes = state.Facet.define(); +const contentAttributes = state.Facet.define(); +// Provide decorations +const decorations = state.Facet.define(); +const blockWrappers = state.Facet.define(); +const outerDecorations = state.Facet.define(); +const atomicRanges = state.Facet.define(); +const bidiIsolatedRanges = state.Facet.define(); +function getIsolatedRanges(view, line) { + let isolates = view.state.facet(bidiIsolatedRanges); + if (!isolates.length) + return isolates; + let sets = isolates.map(i => i instanceof Function ? i(view) : i); + let result = []; + state.RangeSet.spans(sets, line.from, line.to, { + point() { }, + span(fromDoc, toDoc, active, open) { + let from = fromDoc - line.from, to = toDoc - line.from; + let level = result; + for (let i = active.length - 1; i >= 0; i--, open--) { + let direction = active[i].spec.bidiIsolate, update; + if (direction == null) + direction = autoDirection(line.text, from, to); + if (open > 0 && level.length && + (update = level[level.length - 1]).to == from && update.direction == direction) { + update.to = to; + level = update.inner; + } + else { + let add = { from, to, direction, inner: [] }; + level.push(add); + level = add.inner; + } + } + } + }); + return result; +} +const scrollMargins = state.Facet.define(); +function getScrollMargins(view) { + let left = 0, right = 0, top = 0, bottom = 0; + for (let source of view.state.facet(scrollMargins)) { + let m = source(view); + if (m) { + if (m.left != null) + left = Math.max(left, m.left); + if (m.right != null) + right = Math.max(right, m.right); + if (m.top != null) + top = Math.max(top, m.top); + if (m.bottom != null) + bottom = Math.max(bottom, m.bottom); + } + } + return { left, right, top, bottom }; +} +const styleModule = state.Facet.define(); +class ChangedRange { + constructor(fromA, toA, fromB, toB) { + this.fromA = fromA; + this.toA = toA; + this.fromB = fromB; + this.toB = toB; + } + join(other) { + return new ChangedRange(Math.min(this.fromA, other.fromA), Math.max(this.toA, other.toA), Math.min(this.fromB, other.fromB), Math.max(this.toB, other.toB)); + } + addToSet(set) { + let i = set.length, me = this; + for (; i > 0; i--) { + let range = set[i - 1]; + if (range.fromA > me.toA) + continue; + if (range.toA < me.fromA) + break; + me = me.join(range); + set.splice(i - 1, 1); + } + set.splice(i, 0, me); + return set; + } + // Extend a set to cover all the content in `ranges`, which is a + // flat array with each pair of numbers representing fromB/toB + // positions. These pairs are generated in unchanged ranges, so the + // offset between doc A and doc B is the same for their start and + // end points. + static extendWithRanges(diff, ranges) { + if (ranges.length == 0) + return diff; + let result = []; + for (let dI = 0, rI = 0, off = 0;;) { + let nextD = dI < diff.length ? diff[dI].fromB : 1e9; + let nextR = rI < ranges.length ? ranges[rI] : 1e9; + let fromB = Math.min(nextD, nextR); + if (fromB == 1e9) + break; + let fromA = fromB + off, toB = fromB, toA = fromA; + for (;;) { + if (rI < ranges.length && ranges[rI] <= toB) { + let end = ranges[rI + 1]; + rI += 2; + toB = Math.max(toB, end); + for (let i = dI; i < diff.length && diff[i].fromB <= toB; i++) + off = diff[i].toA - diff[i].toB; + toA = Math.max(toA, end + off); + } + else if (dI < diff.length && diff[dI].fromB <= toB) { + let next = diff[dI++]; + toB = Math.max(toB, next.toB); + toA = Math.max(toA, next.toA); + off = next.toA - next.toB; + } + else { + break; + } + } + result.push(new ChangedRange(fromA, toA, fromB, toB)); + } + return result; + } +} +/** +View [plugins](https://codemirror.net/6/docs/ref/#view.ViewPlugin) are given instances of this +class, which describe what happened, whenever the view is updated. +*/ +class ViewUpdate { + constructor( + /** + The editor view that the update is associated with. + */ + view, + /** + The new editor state. + */ + state$1, + /** + The transactions involved in the update. May be empty. + */ + transactions) { + this.view = view; + this.state = state$1; + this.transactions = transactions; + /** + @internal + */ + this.flags = 0; + this.startState = view.state; + this.changes = state.ChangeSet.empty(this.startState.doc.length); + for (let tr of transactions) + this.changes = this.changes.compose(tr.changes); + let changedRanges = []; + this.changes.iterChangedRanges((fromA, toA, fromB, toB) => changedRanges.push(new ChangedRange(fromA, toA, fromB, toB))); + this.changedRanges = changedRanges; + } + /** + @internal + */ + static create(view, state, transactions) { + return new ViewUpdate(view, state, transactions); + } + /** + Tells you whether the [viewport](https://codemirror.net/6/docs/ref/#view.EditorView.viewport) or + [visible ranges](https://codemirror.net/6/docs/ref/#view.EditorView.visibleRanges) changed in this + update. + */ + get viewportChanged() { + return (this.flags & 4 /* UpdateFlag.Viewport */) > 0; + } + /** + Returns true when + [`viewportChanged`](https://codemirror.net/6/docs/ref/#view.ViewUpdate.viewportChanged) is true + and the viewport change is not just the result of mapping it in + response to document changes. + */ + get viewportMoved() { + return (this.flags & 8 /* UpdateFlag.ViewportMoved */) > 0; + } + /** + Indicates whether the height of a block element in the editor + changed in this update. + */ + get heightChanged() { + return (this.flags & 2 /* UpdateFlag.Height */) > 0; + } + /** + Returns true when the document was modified or the size of the + editor, or elements within the editor, changed. + */ + get geometryChanged() { + return this.docChanged || (this.flags & (16 /* UpdateFlag.Geometry */ | 2 /* UpdateFlag.Height */)) > 0; + } + /** + True when this update indicates a focus change. + */ + get focusChanged() { + return (this.flags & 1 /* UpdateFlag.Focus */) > 0; + } + /** + Whether the document changed in this update. + */ + get docChanged() { + return !this.changes.empty; + } + /** + Whether the selection was explicitly set in this update. + */ + get selectionSet() { + return this.transactions.some(tr => tr.selection); + } + /** + @internal + */ + get empty() { return this.flags == 0 && this.transactions.length == 0; } +} + +const noChildren = []; +class Tile { + constructor(dom, length, flags = 0) { + this.dom = dom; + this.length = length; + this.flags = flags; + this.parent = null; + dom.cmTile = this; + } + get breakAfter() { return (this.flags & 1 /* TileFlag.BreakAfter */); } + get children() { return noChildren; } + isWidget() { return false; } + get isHidden() { return false; } + isComposite() { return false; } + isLine() { return false; } + isText() { return false; } + isBlock() { return false; } + get domAttrs() { return null; } + sync(track) { + this.flags |= 2 /* TileFlag.Synced */; + if (this.flags & 4 /* TileFlag.AttrsDirty */) { + this.flags &= -5 /* TileFlag.AttrsDirty */; + let attrs = this.domAttrs; + if (attrs) + setAttrs(this.dom, attrs); + } + } + toString() { + return this.constructor.name + (this.children.length ? `(${this.children})` : "") + (this.breakAfter ? "#" : ""); + } + destroy() { this.parent = null; } + setDOM(dom) { + this.dom = dom; + dom.cmTile = this; + } + get posAtStart() { + return this.parent ? this.parent.posBefore(this) : 0; + } + get posAtEnd() { + return this.posAtStart + this.length; + } + posBefore(tile, start = this.posAtStart) { + let pos = start; + for (let child of this.children) { + if (child == tile) + return pos; + pos += child.length + child.breakAfter; + } + throw new RangeError("Invalid child in posBefore"); + } + posAfter(tile) { + return this.posBefore(tile) + tile.length; + } + covers(side) { return true; } + coordsIn(pos, side, rtl) { return null; } + domPosFor(off, side) { + let index = domIndex(this.dom); + let after = this.length ? off > 0 : side > 0; + return new DOMPos(this.parent.dom, index + (after ? 1 : 0), off == 0 || off == this.length); + } + markDirty(attrs) { + this.flags &= -3 /* TileFlag.Synced */; + if (attrs) + this.flags |= 4 /* TileFlag.AttrsDirty */; + if (this.parent && (this.parent.flags & 2 /* TileFlag.Synced */)) + this.parent.markDirty(false); + } + get overrideDOMText() { return null; } + get root() { + for (let t = this; t; t = t.parent) + if (t instanceof DocTile) + return t; + return null; + } + static get(dom) { + return dom.cmTile; + } +} +class CompositeTile extends Tile { + constructor(dom) { + super(dom, 0); + this._children = []; + } + isComposite() { return true; } + get children() { return this._children; } + get lastChild() { return this.children.length ? this.children[this.children.length - 1] : null; } + append(child) { + this.children.push(child); + child.parent = this; + } + sync(track) { + if (this.flags & 2 /* TileFlag.Synced */) + return; + super.sync(track); + let parent = this.dom, prev = null, next; + let tracking = (track === null || track === void 0 ? void 0 : track.node) == parent ? track : null; + let length = 0; + for (let child of this.children) { + child.sync(track); + length += child.length + child.breakAfter; + next = prev ? prev.nextSibling : parent.firstChild; + if (tracking && next != child.dom) + tracking.written = true; + if (child.dom.parentNode == parent) { + while (next && next != child.dom) + next = rm$1(next); + } + else { + parent.insertBefore(child.dom, next); + } + prev = child.dom; + } + next = prev ? prev.nextSibling : parent.firstChild; + if (tracking && next) + tracking.written = true; + while (next) + next = rm$1(next); + this.length = length; + } +} +// Remove a DOM node and return its next sibling. +function rm$1(dom) { + let next = dom.nextSibling; + dom.parentNode.removeChild(dom); + return next; +} +// The top-level tile. Its dom property equals view.contentDOM. +class DocTile extends CompositeTile { + constructor(view, dom) { + super(dom); + this.view = view; + } + owns(tile) { + for (; tile; tile = tile.parent) + if (tile == this) + return true; + return false; + } + isBlock() { return true; } + nearest(dom) { + for (;;) { + if (!dom) + return null; + let tile = Tile.get(dom); + if (tile && this.owns(tile)) + return tile; + dom = dom.parentNode; + } + } + blockTiles(f) { + for (let stack = [], cur = this, i = 0, pos = 0;;) { + if (i == cur.children.length) { + if (!stack.length) + return; + cur = cur.parent; + if (cur.breakAfter) + pos++; + i = stack.pop(); + } + else { + let next = cur.children[i++]; + if (next instanceof BlockWrapperTile) { + stack.push(i); + cur = next; + i = 0; + } + else { + let end = pos + next.length; + let result = f(next, pos); + if (result !== undefined) + return result; + pos = end + next.breakAfter; + } + } + } + } + // Find the block at the given position. If side < -1, make sure to + // stay before block widgets at that position, if side > 1, after + // such widgets (used for selection drawing, which needs to be able + // to get coordinates for positions that aren't valid cursor positions). + resolveBlock(pos, side) { + let before, beforeOff = -1, after, afterOff = -1; + this.blockTiles((tile, off) => { + let end = off + tile.length; + if (pos >= off && pos <= end) { + if (tile.isWidget() && side >= -1 && side <= 1) { + if (tile.flags & 32 /* TileFlag.After */) + return true; + if (tile.flags & 16 /* TileFlag.Before */) + before = undefined; + } + if ((off < pos || pos == end && (side < -1 ? tile.length : tile.covers(1))) && + (!before || !tile.isWidget() && before.isWidget())) { + before = tile; + beforeOff = pos - off; + } + if ((end > pos || pos == off && (side > 1 ? tile.length : tile.covers(-1))) && + (!after || !tile.isWidget() && after.isWidget())) { + after = tile; + afterOff = pos - off; + } + } + }); + if (!before && !after) + throw new Error("No tile at position " + pos); + return before && side < 0 || !after ? { tile: before, offset: beforeOff } : { tile: after, offset: afterOff }; + } +} +class BlockWrapperTile extends CompositeTile { + constructor(dom, wrapper) { + super(dom); + this.wrapper = wrapper; + } + isBlock() { return true; } + covers(side) { + if (!this.children.length) + return false; + return side < 0 ? this.children[0].covers(-1) : this.lastChild.covers(1); + } + get domAttrs() { return this.wrapper.attributes; } + static of(wrapper, dom) { + let tile = new BlockWrapperTile(dom || document.createElement(wrapper.tagName), wrapper); + if (!dom) + tile.flags |= 4 /* TileFlag.AttrsDirty */; + return tile; + } +} +class LineTile extends CompositeTile { + constructor(dom, attrs) { + super(dom); + this.attrs = attrs; + } + isLine() { return true; } + static start(attrs, dom, keepAttrs) { + let line = new LineTile(dom || document.createElement("div"), attrs); + if (!dom || !keepAttrs) + line.flags |= 4 /* TileFlag.AttrsDirty */; + return line; + } + get domAttrs() { return this.attrs; } + // Find the tile associated with a given position in this line. + // Side -2/2 is handled specially, in that it allows the position + // returned to be before (-2) or after (2) widgets that would always + // be after/before a cursor position. + resolveInline(pos, side, forCoords) { + let before = null, beforeOff = -1, after = null, afterOff = -1; + function scan(tile, pos) { + for (let i = 0, off = 0; i < tile.children.length && off <= pos; i++) { + let child = tile.children[i], end = off + child.length; + if (end >= pos) { + if (child.isComposite()) { + scan(child, pos - off); + } + else if ((!after || after.isHidden && (side > 0 && !(after.flags & 32 /* TileFlag.After */) || forCoords && onSameLine(after, child))) && + (end > pos || (child.flags & 32 /* TileFlag.After */) && side <= 1)) { + after = child; + afterOff = pos - off; + } + else if (off < pos || (child.flags & 16 /* TileFlag.Before */) && !child.isHidden && side >= -1) { + before = child; + beforeOff = pos - off; + } + } + off = end; + } + } + scan(this, pos); + let target = ((side < 0 ? before : after) || before || after); + return target ? { tile: target, offset: target == before ? beforeOff : afterOff } : null; + } + coordsIn(pos, side, rtl) { + let found = this.resolveInline(pos, side, true); + if (!found) + return fallbackRect(this); + return found.tile.coordsIn(Math.max(0, found.offset), side, rtl); + } + domIn(pos, side) { + let found = this.resolveInline(pos, side); + if (found) { + let { tile, offset } = found; + if (this.dom.contains(tile.dom)) { + if (tile.isText()) + return new DOMPos(tile.dom, Math.min(tile.dom.nodeValue.length, offset)); + return tile.domPosFor(offset, tile.flags & 16 /* TileFlag.Before */ ? 1 : tile.flags & 32 /* TileFlag.After */ ? -1 : side); + } + let parent = found.tile.parent, saw = false; + for (let ch of parent.children) { + if (saw) + return new DOMPos(ch.dom, 0); + if (ch == found.tile) { + saw = true; + } + } + } + return new DOMPos(this.dom, 0); + } +} +function fallbackRect(tile) { + let last = tile.dom.lastChild; + if (!last) + return tile.dom.getBoundingClientRect(); + let rects = clientRectsFor(last); + return rects[rects.length - 1] || null; +} +function onSameLine(a, b) { + let posA = a.coordsIn(0, 1), posB = b.coordsIn(0, 1); + return posA && posB && posB.top < posA.bottom; +} +class MarkTile extends CompositeTile { + constructor(dom, mark) { + super(dom); + this.mark = mark; + } + get domAttrs() { return this.mark.attrs; } + static of(mark, dom) { + let tile = new MarkTile(dom || document.createElement(mark.tagName), mark); + if (!dom) + tile.flags |= 4 /* TileFlag.AttrsDirty */; + return tile; + } +} +class TextTile extends Tile { + constructor(dom, text) { + super(dom, text.length); + this.text = text; + } + sync(track) { + if (this.flags & 2 /* TileFlag.Synced */) + return; + super.sync(track); + if (this.dom.nodeValue != this.text) { + if (track && track.node == this.dom) + track.written = true; + this.dom.nodeValue = this.text; + } + } + isText() { return true; } + toString() { return JSON.stringify(this.text); } + coordsIn(pos, side, rtl) { + let length = this.dom.nodeValue.length; + if (pos > length) + pos = length; + let from = pos, to = pos, flatten = 0; + if (pos == 0 && side < 0 || pos == length && side >= 0) { + if (!(browser.chrome || browser.gecko)) { // These browsers reliably return valid rectangles for empty ranges + if (pos) { + from--; + flatten = 1; + } + else if (to < length) { + to++; + flatten = -1; + } + } + } + else { + if (side < 0) + from--; + else if (to < length) + to++; + } + let rects = textRange(this.dom, from, to).getClientRects(); + if (!rects.length) + return null; + let rect = rects[(flatten ? flatten < 0 : side >= 0) ? 0 : rects.length - 1]; + if (browser.safari && !flatten && rect.width == 0) + rect = Array.prototype.find.call(rects, r => r.width) || rect; + return rtl == null ? rect : flattenRect(rect, (flatten ? flatten > 0 : side < 0) == rtl); + } + static of(text, dom) { + let tile = new TextTile(dom || document.createTextNode(text), text); + if (!dom) + tile.flags |= 2 /* TileFlag.Synced */; + return tile; + } +} +class WidgetTile extends Tile { + constructor(dom, length, widget, flags) { + super(dom, length, flags); + this.widget = widget; + } + isWidget() { return true; } + get isHidden() { return this.widget.isHidden; } + covers(side) { + if (this.flags & 48 /* TileFlag.PointWidget */) + return false; + return (this.flags & (side < 0 ? 64 /* TileFlag.IncStart */ : 128 /* TileFlag.IncEnd */)) > 0; + } + coordsIn(pos, side) { return this.coordsInWidget(pos, side, false); } + coordsInWidget(pos, side, block) { + let custom = this.widget.coordsAt(this.dom, pos, side); + if (custom) + return custom; + if (block) { + return flattenRect(this.dom.getBoundingClientRect(), this.length ? pos == 0 : side <= 0); + } + else { + let rects = this.dom.getClientRects(), rect = null; + if (!rects.length) + return null; + let fromBack = (this.flags & 16 /* TileFlag.Before */) ? true : (this.flags & 32 /* TileFlag.After */) ? false : pos > 0; + for (let i = fromBack ? rects.length - 1 : 0;; i += (fromBack ? -1 : 1)) { + rect = rects[i]; + if (pos > 0 ? i == 0 : i == rects.length - 1 || rect.top < rect.bottom) + break; + } + return flattenRect(rect, !fromBack); + } + } + get overrideDOMText() { + if (!this.length) + return state.Text.empty; + let { root } = this; + if (!root) + return state.Text.empty; + let start = this.posAtStart; + return root.view.state.doc.slice(start, start + this.length); + } + destroy() { + super.destroy(); + this.widget.destroy(this.dom); + } + static of(widget, view, length, flags, dom) { + if (!dom) { + dom = widget.toDOM(view); + if (!widget.editable) + dom.contentEditable = "false"; + } + return new WidgetTile(dom, length, widget, flags); + } +} +// These are drawn around uneditable widgets to avoid a number of +// browser bugs that show up when the cursor is directly next to +// uneditable inline content. +class WidgetBufferTile extends Tile { + constructor(flags) { + let img = document.createElement("img"); + img.className = "cm-widgetBuffer"; + img.setAttribute("aria-hidden", "true"); + super(img, 0, flags); + } + get isHidden() { return true; } + get overrideDOMText() { return state.Text.empty; } + coordsIn(pos, side, rtl) { + let rect = this.dom.getBoundingClientRect(); + return rtl == null ? rect : flattenRect(rect, (side > 0) == rtl); + } +} +// Represents a position in the tile tree. +class TilePointer { + constructor(top) { + this.index = 0; + this.beforeBreak = false; + this.parents = []; + this.tile = top; + } + // Advance by the given distance. If side is -1, stop leaving or + // entering tiles, or skipping zero-length tiles, once the distance + // has been traversed. When side is 1, leave, enter, or skip + // everything at the end position. + advance(dist, side, walker) { + let { tile, index, beforeBreak, parents } = this; + while (dist || side > 0) { + if (!tile.isComposite()) { + let len = tile.length; + if (index < len && dist) { + let take = Math.min(dist, len - index); + if (walker) + walker.skip(tile, index, index + take); + dist -= take; + index += take; + } + if (index == len) { + beforeBreak = !!tile.breakAfter; + ({ tile, index } = parents.pop()); + index++; + } + else if (!dist) { + break; + } + } + else if (beforeBreak) { + if (!dist) + break; + if (walker) + walker.break(); + dist--; + beforeBreak = false; + } + else if (index == tile.children.length) { + if (!dist && !parents.length) + break; + if (walker) + walker.leave(tile); + beforeBreak = !!tile.breakAfter; + ({ tile, index } = parents.pop()); + index++; + } + else { + let next = tile.children[index], brk = next.breakAfter; + if ((side > 0 ? next.length <= dist : next.length < dist) && + (!walker || walker.skip(next, 0, next.length) !== false || !next.isComposite)) { + beforeBreak = !!brk; + index++; + dist -= next.length; + } + else { + parents.push({ tile, index }); + tile = next; + index = 0; + if (walker && next.isComposite()) + walker.enter(next); + } + } + } + this.tile = tile; + this.index = index; + this.beforeBreak = beforeBreak; + return this; + } + get root() { return (this.parents.length ? this.parents[0].tile : this.tile); } +} + +// Used to track open block wrappers +class OpenWrapper { + constructor(from, to, wrapper, rank) { + this.from = from; + this.to = to; + this.wrapper = wrapper; + this.rank = rank; + } +} +// This class builds up a new document tile using input from either +// iteration over the old tree or iteration over the document + +// decorations. The add* methods emit elements into the tile +// structure. To avoid awkward synchronization issues, marks and block +// wrappers are treated as belonging to to their content, rather than +// opened/closed independently. +// +// All composite tiles that are touched by changes are rebuilt, +// reusing as much of the old tree (either whole nodes or just DOM +// elements) as possible. The new tree is built without the Synced +// flag, and then synced (during which DOM parent/child relations are +// fixed up, text nodes filled in, and attributes added) in a second +// phase. +class TileBuilder { + constructor(cache, root, blockWrappers) { + this.cache = cache; + this.root = root; + this.blockWrappers = blockWrappers; + this.curLine = null; + this.lastBlock = null; + this.afterWidget = null; + this.pos = 0; + this.wrappers = []; + this.wrapperPos = 0; + } + addText(text, marks, openStart, tile) { + var _a; + this.flushBuffer(); + let parent = this.ensureMarks(marks, openStart); + let prev = parent.lastChild; + if (prev && prev.isText() && !(prev.flags & 8 /* TileFlag.Composition */) && prev.length + text.length < 512 /* C.Chunk */) { + this.cache.reused.set(prev, 2 /* Reused.DOM */); + let tile = parent.children[parent.children.length - 1] = new TextTile(prev.dom, prev.text + text); + tile.parent = parent; + } + else { + parent.append(tile || TextTile.of(text, (_a = this.cache.find(TextTile)) === null || _a === void 0 ? void 0 : _a.dom)); + } + this.pos += text.length; + this.afterWidget = null; + } + addComposition(composition, context) { + let line = this.curLine; + if (line.dom != context.line.dom) { + line.setDOM(this.cache.reused.has(context.line) ? freeNode(context.line.dom) : context.line.dom); + this.cache.reused.set(context.line, 2 /* Reused.DOM */); + } + let head = line; + for (let i = context.marks.length - 1; i >= 0; i--) { + let mark = context.marks[i]; + let last = head.lastChild; + if (last instanceof MarkTile && last.mark.eq(mark.mark)) { + if (last.dom != mark.dom) + last.setDOM(freeNode(mark.dom)); + head = last; + } + else { + if (this.cache.reused.get(mark)) { + let tile = Tile.get(mark.dom); + if (tile) + tile.setDOM(freeNode(mark.dom)); + } + let nw = MarkTile.of(mark.mark, mark.dom); + head.append(nw); + head = nw; + } + this.cache.reused.set(mark, 2 /* Reused.DOM */); + } + let oldTile = Tile.get(composition.text); + if (oldTile) + this.cache.reused.set(oldTile, 2 /* Reused.DOM */); + let text = new TextTile(composition.text, composition.text.nodeValue); + text.flags |= 8 /* TileFlag.Composition */; + this.pos = composition.range.toB; + head.append(text); + } + addInlineWidget(widget, marks, openStart) { + // Adjacent same-side-facing non-replacing widgets don't need buffers between them + let noSpace = this.afterWidget && (widget.flags & 48 /* TileFlag.PointWidget */) && + (this.afterWidget.flags & 48 /* TileFlag.PointWidget */) == (widget.flags & 48 /* TileFlag.PointWidget */); + if (!noSpace) + this.flushBuffer(); + let parent = this.ensureMarks(marks, openStart); + if (!noSpace && !(widget.flags & 16 /* TileFlag.Before */)) + parent.append(this.getBuffer(1)); + parent.append(widget); + this.pos += widget.length; + this.afterWidget = widget; + } + addMark(tile, marks, openStart) { + this.flushBuffer(); + let parent = this.ensureMarks(marks, openStart); + parent.append(tile); + this.pos += tile.length; + this.afterWidget = null; + } + addBlockWidget(widget) { + this.getBlockPos().append(widget); + this.pos += widget.length; + this.lastBlock = widget; + this.endLine(); + } + continueWidget(length) { + let widget = this.afterWidget || this.lastBlock; + widget.length += length; + this.pos += length; + } + addLineStart(attrs, dom) { + var _a; + if (!attrs) + attrs = lineBaseAttrs; + let tile = LineTile.start(attrs, dom || ((_a = this.cache.find(LineTile)) === null || _a === void 0 ? void 0 : _a.dom), !!dom); + this.getBlockPos().append(this.lastBlock = this.curLine = tile); + } + addLine(tile) { + this.getBlockPos().append(tile); + this.pos += tile.length; + this.lastBlock = tile; + this.endLine(); + } + addBreak() { + this.lastBlock.flags |= 1 /* TileFlag.BreakAfter */; + this.endLine(); + this.pos++; + } + addLineStartIfNotCovered(attrs) { + if (!this.blockPosCovered()) + this.addLineStart(attrs); + } + ensureLine(attrs) { + if (!this.curLine) + this.addLineStart(attrs); + } + ensureMarks(marks, openStart) { + var _a; + let parent = this.curLine; + for (let i = marks.length - 1; i >= 0; i--) { + let mark = marks[i], last; + if (openStart > 0 && (last = parent.lastChild) && last instanceof MarkTile && last.mark.eq(mark)) { + parent = last; + openStart--; + } + else { + let tile = MarkTile.of(mark, (_a = this.cache.find(MarkTile, m => m.mark.eq(mark))) === null || _a === void 0 ? void 0 : _a.dom); + parent.append(tile); + parent = tile; + openStart = 0; + } + } + return parent; + } + endLine() { + if (this.curLine) { + this.flushBuffer(); + let last = this.curLine.lastChild; + if (!last || !hasContent(this.curLine, false) || + last.dom.nodeName != "BR" && last.isWidget() && !(browser.ios && hasContent(this.curLine, true))) + this.curLine.append(this.cache.findWidget(BreakWidget, 0, 32 /* TileFlag.After */) || + new WidgetTile(BreakWidget.toDOM(), 0, BreakWidget, 32 /* TileFlag.After */)); + this.curLine = this.afterWidget = null; + } + } + updateBlockWrappers() { + if (this.wrapperPos > this.pos + 10000 /* C.WrapperReset */) { + this.blockWrappers.goto(this.pos); + this.wrappers.length = 0; + } + for (let i = this.wrappers.length - 1; i >= 0; i--) + if (this.wrappers[i].to < this.pos) + this.wrappers.splice(i, 1); + for (let cur = this.blockWrappers; cur.value && cur.from <= this.pos; cur.next()) + if (cur.to >= this.pos) { + let rank = (cur.rank * 102) + cur.value.rank; + let wrap = new OpenWrapper(cur.from, cur.to, cur.value, rank), i = this.wrappers.length; + while (i > 0 && (this.wrappers[i - 1].rank - wrap.rank || this.wrappers[i - 1].to - wrap.to) < 0) + i--; + this.wrappers.splice(i, 0, wrap); + } + this.wrapperPos = this.pos; + } + getBlockPos() { + var _a; + this.updateBlockWrappers(); + let parent = this.root; + for (let wrap of this.wrappers) { + let last = parent.lastChild; + if (wrap.from < this.pos && last instanceof BlockWrapperTile && last.wrapper.eq(wrap.wrapper)) { + parent = last; + } + else { + let tile = BlockWrapperTile.of(wrap.wrapper, (_a = this.cache.find(BlockWrapperTile, t => t.wrapper.eq(wrap.wrapper))) === null || _a === void 0 ? void 0 : _a.dom); + parent.append(tile); + parent = tile; + } + } + return parent; + } + blockPosCovered() { + let last = this.lastBlock; + return last != null && !last.breakAfter && (!last.isWidget() || (last.flags & (32 /* TileFlag.After */ | 128 /* TileFlag.IncEnd */)) > 0); + } + getBuffer(side) { + let flags = 2 /* TileFlag.Synced */ | (side < 0 ? 16 /* TileFlag.Before */ : 32 /* TileFlag.After */); + let found = this.cache.find(WidgetBufferTile, undefined, 1 /* Reused.Full */); + if (found) + found.flags = flags; + return found || new WidgetBufferTile(flags); + } + flushBuffer() { + if (this.afterWidget && !(this.afterWidget.flags & 32 /* TileFlag.After */)) { + this.afterWidget.parent.append(this.getBuffer(-1)); + this.afterWidget = null; + } + } +} +// Helps getting efficient access to the document text. +class TextStream { + constructor(doc) { + this.skipCount = 0; + this.text = ""; + this.textOff = 0; + this.cursor = doc.iter(); + } + skip(len) { + // Advance the iterator past the replaced content + if (this.textOff + len <= this.text.length) { + this.textOff += len; + } + else { + this.skipCount += len - (this.text.length - this.textOff); + this.text = ""; + this.textOff = 0; + } + } + next(maxLen) { + if (this.textOff == this.text.length) { + let { value, lineBreak, done } = this.cursor.next(this.skipCount); + this.skipCount = 0; + if (done) + throw new Error("Ran out of text content when drawing inline views"); + this.text = value; + let len = this.textOff = Math.min(maxLen, value.length); + return lineBreak ? null : value.slice(0, len); + } + let end = Math.min(this.text.length, this.textOff + maxLen); + let chars = this.text.slice(this.textOff, end); + this.textOff = end; + return chars; + } +} +// Assign the tile classes bucket numbers for caching. +const buckets = [WidgetTile, LineTile, TextTile, MarkTile, WidgetBufferTile, BlockWrapperTile, DocTile]; +for (let i = 0; i < buckets.length; i++) + buckets[i].bucket = i; +// Leaf tiles and line tiles may be reused in their entirety. All +// others will get new tiles allocated, using the old DOM when +// possible. +class TileCache { + constructor(view) { + this.view = view; + // Buckets are circular buffers, using `index` as the current + // position. + this.buckets = buckets.map(() => []); + this.index = buckets.map(() => 0); + this.reused = new Map; + } + // Put a tile in the cache. + add(tile) { + let i = tile.constructor.bucket, bucket = this.buckets[i]; + if (bucket.length < 6 /* C.Bucket */) + bucket.push(tile); + else + bucket[this.index[i] = (this.index[i] + 1) % 6 /* C.Bucket */] = tile; + } + find(cls, test, type = 2 /* Reused.DOM */) { + let i = cls.bucket; + let bucket = this.buckets[i], off = this.index[i]; + for (let j = 0; j < bucket.length; j++) { + // Look at the most oldest items first (first-in, first-out) + let index = (j + off) % bucket.length, tile = bucket[index]; + if ((!test || test(tile)) && !this.reused.has(tile)) { + bucket.splice(index, 1); + if (index < off) + this.index[i]--; + this.reused.set(tile, type); + return tile; + } + } + return null; + } + findWidget(widget, length, flags) { + let widgets = this.buckets[0]; + if (widgets.length) + for (let i = 0, pass = 0;; i++) { + if (i == widgets.length) { + if (pass) + return null; + pass = 1; + i = 0; + } + let tile = widgets[i]; + if (!this.reused.has(tile) && + (pass == 0 ? tile.widget.compare(widget) + : tile.widget.constructor == widget.constructor && widget.updateDOM(tile.dom, this.view, tile.widget))) { + widgets.splice(i, 1); + if (i < this.index[0]) + this.index[0]--; + if (tile.widget == widget && tile.length == length && (tile.flags & (496 /* TileFlag.Widget */ | 1 /* TileFlag.BreakAfter */)) == flags) { + this.reused.set(tile, 1 /* Reused.Full */); + return tile; + } + else { + this.reused.set(tile, 2 /* Reused.DOM */); + return new WidgetTile(tile.dom, length, widget, (tile.flags & -498) | flags); + } + } + } + } + reuse(tile) { + this.reused.set(tile, 1 /* Reused.Full */); + return tile; + } + maybeReuse(tile, type = 2 /* Reused.DOM */) { + if (this.reused.has(tile)) + return undefined; + this.reused.set(tile, type); + return tile.dom; + } + clear() { + for (let i = 0; i < this.buckets.length; i++) + this.buckets[i].length = this.index[i] = 0; + } +} +// This class organizes a pass over the document, guided by the array +// of replaced ranges. For ranges that haven't changed, it iterates +// the old tree and copies its content into the new document. For +// changed ranges, it runs a decoration iterator to guide generation +// of content. +class TileUpdate { + constructor(view, old, blockWrappers, decorations, disallowBlockEffectsFor) { + this.view = view; + this.decorations = decorations; + this.disallowBlockEffectsFor = disallowBlockEffectsFor; + this.openWidget = false; + this.openMarks = 0; + this.cache = new TileCache(view); + this.text = new TextStream(view.state.doc); + this.builder = new TileBuilder(this.cache, new DocTile(view, view.contentDOM), state.RangeSet.iter(blockWrappers)); + this.cache.reused.set(old, 2 /* Reused.DOM */); + this.old = new TilePointer(old); + this.reuseWalker = { + skip: (tile, from, to) => { + this.cache.add(tile); + if (tile.isComposite()) + return false; + }, + enter: tile => this.cache.add(tile), + leave: () => { }, + break: () => { } + }; + } + run(changes, composition) { + let compositionContext = composition && this.getCompositionContext(composition.text); + for (let posA = 0, posB = 0, i = 0;;) { + let next = i < changes.length ? changes[i++] : null; + let skipA = next ? next.fromA : this.old.root.length; + if (skipA > posA) { + let len = skipA - posA; + this.preserve(len, !i, !next); + posA = skipA; + posB += len; + } + if (!next) + break; + // Compositions need to be handled specially, forcing the + // focused text node and its parent nodes to remain stable at + // that point in the document. + if (composition && next.fromA <= composition.range.fromA && next.toA >= composition.range.toA) { + this.forward(next.fromA, composition.range.fromA, composition.range.fromA < composition.range.toA ? 1 : -1); + this.emit(posB, composition.range.fromB); + this.builder.flushBuffer(); + this.cache.clear(); // Must not reuse DOM across composition + this.builder.addComposition(composition, compositionContext); + this.text.skip(composition.range.toB - composition.range.fromB); + this.forward(composition.range.fromA, next.toA); + this.emit(composition.range.toB, next.toB); + } + else { + this.forward(next.fromA, next.toA); + this.emit(posB, next.toB); + } + posB = next.toB; + posA = next.toA; + } + if (this.builder.curLine) + this.builder.endLine(); + return this.builder.root; + } + preserve(length, incStart, incEnd) { + let activeMarks = getMarks(this.old), openMarks = this.openMarks; + this.old.advance(length, incEnd ? 1 : -1, { + skip: (tile, from, to) => { + if (tile.isWidget()) { + if (this.openWidget) { + this.builder.continueWidget(to - from); + } + else { + let widget = to > 0 || from < tile.length + ? WidgetTile.of(tile.widget, this.view, to - from, tile.flags & 496 /* TileFlag.Widget */, this.cache.maybeReuse(tile)) + : this.cache.reuse(tile); + if (widget.flags & 256 /* TileFlag.Block */) { + widget.flags &= -2 /* TileFlag.BreakAfter */; + this.builder.addBlockWidget(widget); + } + else { + this.builder.ensureLine(null); + this.builder.addInlineWidget(widget, activeMarks, openMarks); + openMarks = activeMarks.length; + } + } + } + else if (tile.isText()) { + this.builder.ensureLine(null); + if (!from && to == tile.length && !this.cache.reused.has(tile)) { + this.builder.addText(tile.text, activeMarks, openMarks, this.cache.reuse(tile)); + } + else { + this.cache.add(tile); + this.builder.addText(tile.text.slice(from, to), activeMarks, openMarks); + } + openMarks = activeMarks.length; + } + else if (tile.isLine()) { + tile.flags &= -2 /* TileFlag.BreakAfter */; + this.cache.reused.set(tile, 1 /* Reused.Full */); + this.builder.addLine(tile); + } + else if (tile instanceof WidgetBufferTile) { + this.cache.add(tile); + } + else if (tile instanceof MarkTile) { + this.builder.ensureLine(null); + this.builder.addMark(tile, activeMarks, openMarks); + this.cache.reused.set(tile, 1 /* Reused.Full */); + openMarks = activeMarks.length; + } + else { + return false; + } + this.openWidget = false; + }, + enter: (tile) => { + if (tile.isLine()) { + this.builder.addLineStart(tile.attrs, this.cache.maybeReuse(tile)); + } + else { + this.cache.add(tile); + if (tile instanceof MarkTile) + activeMarks.unshift(tile.mark); + } + this.openWidget = false; + }, + leave: (tile) => { + if (tile.isLine()) { + if (activeMarks.length) + activeMarks.length = openMarks = 0; + } + else if (tile instanceof MarkTile) { + activeMarks.shift(); + openMarks = Math.min(openMarks, activeMarks.length); + } + }, + break: () => { + this.builder.addBreak(); + this.openWidget = false; + }, + }); + this.text.skip(length); + } + emit(from, to) { + let pendingLineAttrs = null; + let b = this.builder, markCount = -1; + let openEnd = state.RangeSet.spans(this.decorations, from, to, { + point: (from, to, deco, active, openStart, index) => { + if (deco instanceof PointDecoration) { + if (this.disallowBlockEffectsFor[index]) { + if (deco.block) + throw new RangeError("Block decorations may not be specified via plugins"); + if (to > this.view.state.doc.lineAt(from).to) + throw new RangeError("Decorations that replace line breaks may not be specified via plugins"); + } + markCount = active.length; + if (openStart > active.length) { + b.continueWidget(to - from); + } + else { + let widget = deco.widget || (deco.block ? NullWidget.block : NullWidget.inline); + let flags = widgetFlags(deco); + let tile = this.cache.findWidget(widget, to - from, flags) || WidgetTile.of(widget, this.view, to - from, flags); + if (deco.block) { + if (deco.startSide > 0) + b.addLineStartIfNotCovered(pendingLineAttrs); + b.addBlockWidget(tile); + } + else { + b.ensureLine(pendingLineAttrs); + b.addInlineWidget(tile, active, openStart); + } + } + pendingLineAttrs = null; + } + else { + pendingLineAttrs = addLineDeco(pendingLineAttrs, deco); + } + if (to > from) + this.text.skip(to - from); + }, + span: (from, to, active, openStart) => { + for (let pos = from; pos < to;) { + let chars = this.text.next(Math.min(512 /* C.Chunk */, to - pos)); + if (chars == null) { // Line break + b.addLineStartIfNotCovered(pendingLineAttrs); + b.addBreak(); + pos++; + } + else { + b.ensureLine(pendingLineAttrs); + b.addText(chars, active, pos == from ? openStart : active.length); + pos += chars.length; + } + pendingLineAttrs = null; + } + markCount = active.length; + } + }); + if (markCount > -1) + this.openWidget = openEnd > markCount; + if (!this.openWidget) + b.addLineStartIfNotCovered(pendingLineAttrs); + this.openMarks = openEnd; + } + forward(from, to, side = 1) { + if (to - from <= 10) { + this.old.advance(to - from, side, this.reuseWalker); + } + else { + this.old.advance(5, -1, this.reuseWalker); + this.old.advance(to - from - 10, -1); + this.old.advance(5, side, this.reuseWalker); + } + } + getCompositionContext(text) { + let marks = [], line = null; + for (let parent = text.parentNode;; parent = parent.parentNode) { + let tile = Tile.get(parent); + if (parent == this.view.contentDOM) + break; + if (tile instanceof MarkTile) + marks.push(tile); + else if (tile === null || tile === void 0 ? void 0 : tile.isLine()) + line = tile; + else if (tile instanceof BlockWrapperTile) ; // Ignore + else if (parent.nodeName == "DIV" && !line && parent != this.view.contentDOM) + line = new LineTile(parent, lineBaseAttrs); + else if (!line) + marks.push(MarkTile.of(new MarkDecoration({ tagName: parent.nodeName.toLowerCase(), attributes: getAttrs(parent) }), parent)); + } + return { line: line, marks }; + } +} +function hasContent(tile, requireText) { + let scan = (tile) => { + for (let ch of tile.children) + if ((requireText ? ch.isText() : ch.length) || scan(ch)) + return true; + return false; + }; + return scan(tile); +} +function widgetFlags(deco) { + let flags = deco.isReplace ? (deco.startSide < 0 ? 64 /* TileFlag.IncStart */ : 0) | (deco.endSide > 0 ? 128 /* TileFlag.IncEnd */ : 0) + : (deco.startSide > 0 ? 32 /* TileFlag.After */ : 16 /* TileFlag.Before */); + if (deco.block) + flags |= 256 /* TileFlag.Block */; + return flags; +} +const lineBaseAttrs = { class: "cm-line" }; +function addLineDeco(value, deco) { + let attrs = deco.spec.attributes, cls = deco.spec.class; + if (!attrs && !cls) + return value; + if (!value) + value = { class: "cm-line" }; + if (attrs) + combineAttrs(attrs, value); + if (cls) + value.class += " " + cls; + return value; +} +function getMarks(ptr) { + let found = []; + for (let i = ptr.parents.length; i > 1; i--) { + let tile = i == ptr.parents.length ? ptr.tile : ptr.parents[i].tile; + if (tile instanceof MarkTile) + found.push(tile.mark); + } + return found; +} +function freeNode(node) { + let tile = Tile.get(node); + if (tile) + tile.setDOM(node.cloneNode()); + return node; +} +class NullWidget extends WidgetType { + constructor(tag) { + super(); + this.tag = tag; + } + eq(other) { return other.tag == this.tag; } + toDOM() { return document.createElement(this.tag); } + updateDOM(elt) { return elt.nodeName.toLowerCase() == this.tag; } + get isHidden() { return true; } +} +NullWidget.inline = new NullWidget("span"); +NullWidget.block = new NullWidget("div"); +const BreakWidget = new class extends WidgetType { + toDOM() { return document.createElement("br"); } + get isHidden() { return true; } + get editable() { return true; } +}; + +class DocView { + constructor(view) { + this.view = view; + this.decorations = []; + this.blockWrappers = []; + this.dynamicDecorationMap = [false]; + this.domChanged = null; + this.hasComposition = null; + this.editContextFormatting = Decoration.none; + this.lastCompositionAfterCursor = false; + // Track a minimum width for the editor. When measuring sizes in + // measureVisibleLineHeights, this is updated to point at the width + // of a given element and its extent in the document. When a change + // happens in that range, these are reset. That way, once we've seen + // a line/element of a given length, we keep the editor wide enough + // to fit at least that element, until it is changed, at which point + // we forget it again. + this.minWidth = 0; + this.minWidthFrom = 0; + this.minWidthTo = 0; + // Track whether the DOM selection was set in a lossy way, so that + // we don't mess it up when reading it back it + this.impreciseAnchor = null; + this.impreciseHead = null; + this.forceSelection = false; + // Used by the resize observer to ignore resizes that we caused + // ourselves + this.lastUpdate = Date.now(); + this.updateDeco(); + this.tile = new DocTile(view, view.contentDOM); + this.updateInner([new ChangedRange(0, 0, 0, view.state.doc.length)], null); + } + // Update the document view to a given state. + update(update) { + var _a; + let changedRanges = update.changedRanges; + if (this.minWidth > 0 && changedRanges.length) { + if (!changedRanges.every(({ fromA, toA }) => toA < this.minWidthFrom || fromA > this.minWidthTo)) { + this.minWidth = this.minWidthFrom = this.minWidthTo = 0; + } + else { + this.minWidthFrom = update.changes.mapPos(this.minWidthFrom, 1); + this.minWidthTo = update.changes.mapPos(this.minWidthTo, 1); + } + } + this.updateEditContextFormatting(update); + let readCompositionAt = -1; + if (this.view.inputState.composing >= 0 && !this.view.observer.editContext) { + if ((_a = this.domChanged) === null || _a === void 0 ? void 0 : _a.newSel) + readCompositionAt = this.domChanged.newSel.head; + else if (!touchesComposition(update.changes, this.hasComposition) && !update.selectionSet) + readCompositionAt = update.state.selection.main.head; + } + let composition = readCompositionAt > -1 ? findCompositionRange(this.view, update.changes, readCompositionAt) : null; + this.domChanged = null; + if (this.hasComposition) { + let { from, to } = this.hasComposition; + changedRanges = new ChangedRange(from, to, update.changes.mapPos(from, -1), update.changes.mapPos(to, 1)) + .addToSet(changedRanges.slice()); + } + this.hasComposition = composition ? { from: composition.range.fromB, to: composition.range.toB } : null; + // When the DOM nodes around the selection are moved to another + // parent, Chrome sometimes reports a different selection through + // getSelection than the one that it actually shows to the user. + // This forces a selection update when lines are joined to work + // around that. Issue #54 + if ((browser.ie || browser.chrome) && !composition && update && + update.state.doc.lines != update.startState.doc.lines) + this.forceSelection = true; + let prevDeco = this.decorations, prevWrappers = this.blockWrappers; + this.updateDeco(); + let decoDiff = findChangedDeco(prevDeco, this.decorations, update.changes); + if (decoDiff.length) + changedRanges = ChangedRange.extendWithRanges(changedRanges, decoDiff); + let blockDiff = findChangedWrappers(prevWrappers, this.blockWrappers, update.changes); + if (blockDiff.length) + changedRanges = ChangedRange.extendWithRanges(changedRanges, blockDiff); + if (composition && !changedRanges.some(r => r.fromA <= composition.range.fromA && r.toA >= composition.range.toA)) + changedRanges = composition.range.addToSet(changedRanges.slice()); + if ((this.tile.flags & 2 /* TileFlag.Synced */) && changedRanges.length == 0) { + return false; + } + else { + this.updateInner(changedRanges, composition); + if (update.transactions.length) + this.lastUpdate = Date.now(); + return true; + } + } + // Used by update and the constructor do perform the actual DOM + // update + updateInner(changes, composition) { + this.view.viewState.mustMeasureContent = true; + let { observer } = this.view; + observer.ignore(() => { + if (composition || changes.length) { + let oldTile = this.tile; + let builder = new TileUpdate(this.view, oldTile, this.blockWrappers, this.decorations, this.dynamicDecorationMap); + if (composition && Tile.get(composition.text)) + builder.cache.reused.set(Tile.get(composition.text), 2 /* Reused.DOM */); + this.tile = builder.run(changes, composition); + destroyDropped(oldTile, builder.cache.reused); + } + // Lock the height during redrawing, since Chrome sometimes + // messes with the scroll position during DOM mutation (though + // no relayout is triggered and I cannot imagine how it can + // recompute the scroll position without a layout) + this.tile.dom.style.height = this.view.viewState.contentHeight / this.view.scaleY + "px"; + this.tile.dom.style.flexBasis = this.minWidth ? this.minWidth + "px" : ""; + // Chrome will sometimes, when DOM mutations occur directly + // around the selection, get confused and report a different + // selection from the one it displays (issue #218). This tries + // to detect that situation. + let track = browser.chrome || browser.ios ? { node: observer.selectionRange.focusNode, written: false } : undefined; + this.tile.sync(track); + if (track && (track.written || observer.selectionRange.focusNode != track.node || !this.tile.dom.contains(track.node))) + this.forceSelection = true; + this.tile.dom.style.height = ""; + }); + let gaps = []; + if (this.view.viewport.from || this.view.viewport.to < this.view.state.doc.length) + for (let child of this.tile.children) + if (child.isWidget() && child.widget instanceof BlockGapWidget) + gaps.push(child.dom); + observer.updateGaps(gaps); + } + updateEditContextFormatting(update) { + this.editContextFormatting = this.editContextFormatting.map(update.changes); + for (let tr of update.transactions) + for (let effect of tr.effects) + if (effect.is(setEditContextFormatting)) { + this.editContextFormatting = effect.value; + } + } + // Sync the DOM selection to this.state.selection + updateSelection(mustRead = false, fromPointer = false) { + if (mustRead || !this.view.observer.selectionRange.focusNode) + this.view.observer.readSelectionRange(); + let { dom } = this.tile; + let activeElt = this.view.root.activeElement, focused = activeElt == dom; + let selectionNotFocus = !focused && !(this.view.state.facet(editable) || dom.tabIndex > -1) && + hasSelection(dom, this.view.observer.selectionRange) && !(activeElt && dom.contains(activeElt)); + if (!(focused || fromPointer || selectionNotFocus)) + return; + let force = this.forceSelection; + this.forceSelection = false; + let main = this.view.state.selection.main, anchor, head; + if (main.empty) { + head = anchor = this.inlineDOMNearPos(main.anchor, main.assoc || 1); + } + else { + head = this.inlineDOMNearPos(main.head, main.head == main.from ? 1 : -1); + anchor = this.inlineDOMNearPos(main.anchor, main.anchor == main.from ? 1 : -1); + } + // Always reset on Firefox when next to an uneditable node to + // avoid invisible cursor bugs (#111) + if (browser.gecko && main.empty && !this.hasComposition && betweenUneditable(anchor)) { + let dummy = document.createTextNode(""); + this.view.observer.ignore(() => anchor.node.insertBefore(dummy, anchor.node.childNodes[anchor.offset] || null)); + anchor = head = new DOMPos(dummy, 0); + force = true; + } + let domSel = this.view.observer.selectionRange; + // If the selection is already here, or in an equivalent position, don't touch it + if (force || !domSel.focusNode || (!isEquivalentPosition(anchor.node, anchor.offset, domSel.anchorNode, domSel.anchorOffset) || + !isEquivalentPosition(head.node, head.offset, domSel.focusNode, domSel.focusOffset)) && !this.suppressWidgetCursorChange(domSel, main)) { + this.view.observer.ignore(() => { + // Chrome Android will hide the virtual keyboard when tapping + // inside an uneditable node, and not bring it back when we + // move the cursor to its proper position. This tries to + // restore the keyboard by cycling focus. + // With controlled scrolling, an offscreen caret can land in a block + // gap during redraw. Cycling focus there reopens a dismissed keyboard + // as scrolling settles. Keep focus entirely under user control in + // this mode, including after the gesture has ended. + if (!this.view.state.facet(controlledTouchScroll) && + browser.android && browser.chrome && dom.contains(domSel.focusNode) && + inUneditable(domSel.focusNode, dom)) { + dom.blur(); + dom.focus({ preventScroll: true }); + } + let rawSel = getSelection(this.view.root); + if (!rawSel) ; + else if (main.empty) { + // Work around https://bugzilla.mozilla.org/show_bug.cgi?id=1612076 + if (browser.gecko) { + let nextTo = nextToUneditable(anchor.node, anchor.offset); + if (nextTo && nextTo != (1 /* NextTo.Before */ | 2 /* NextTo.After */)) { + let text = (nextTo == 1 /* NextTo.Before */ ? textNodeBefore : textNodeAfter)(anchor.node, anchor.offset); + if (text) + anchor = new DOMPos(text.node, text.offset); + } + } + rawSel.collapse(anchor.node, anchor.offset); + if (main.bidiLevel != null && rawSel.caretBidiLevel !== undefined) + rawSel.caretBidiLevel = main.bidiLevel; + } + else if (rawSel.extend) { + // Selection.extend can be used to create an 'inverted' selection + // (one where the focus is before the anchor), but not all + // browsers support it yet. + rawSel.collapse(anchor.node, anchor.offset); + // Safari will ignore the call above when the editor is + // hidden, and then raise an error on the call to extend + // (#940). + try { + rawSel.extend(head.node, head.offset); + } + catch (_) { } + } + else { + // Primitive (IE) way + let range = document.createRange(); + if (main.anchor > main.head) + [anchor, head] = [head, anchor]; + range.setEnd(head.node, head.offset); + range.setStart(anchor.node, anchor.offset); + rawSel.removeAllRanges(); + rawSel.addRange(range); + } + if (selectionNotFocus && this.view.root.activeElement == dom) { + dom.blur(); + if (activeElt) + activeElt.focus(); + } + }); + this.view.observer.setSelectionRange(anchor, head); + } + this.impreciseAnchor = anchor.precise ? null : new DOMPos(domSel.anchorNode, domSel.anchorOffset); + this.impreciseHead = head.precise ? null : new DOMPos(domSel.focusNode, domSel.focusOffset); + } + // If a zero-length widget is inserted next to the cursor during + // composition, avoid moving it across it and disrupting the + // composition. + suppressWidgetCursorChange(sel, cursor) { + return this.hasComposition && cursor.empty && + isEquivalentPosition(sel.focusNode, sel.focusOffset, sel.anchorNode, sel.anchorOffset) && + this.posFromDOM(sel.focusNode, sel.focusOffset) == cursor.head; + } + enforceCursorAssoc() { + if (this.hasComposition) + return; + let { view } = this, cursor = view.state.selection.main; + let sel = getSelection(view.root); + let { anchorNode, anchorOffset } = view.observer.selectionRange; + if (!sel || !cursor.empty || !cursor.assoc || !sel.modify) + return; + let line = this.lineAt(cursor.head, cursor.assoc); + if (!line) + return; + let lineStart = line.posAtStart; + if (cursor.head == lineStart || cursor.head == lineStart + line.length) + return; + let before = this.coordsAt(cursor.head, -1), after = this.coordsAt(cursor.head, 1); + if (!before || !after || before.bottom > after.top) + return; + let dom = this.domAtPos(cursor.head + cursor.assoc, cursor.assoc); + sel.collapse(dom.node, dom.offset); + sel.modify("move", cursor.assoc < 0 ? "forward" : "backward", "lineboundary"); + // This can go wrong in corner cases like single-character lines, + // so check and reset if necessary. + view.observer.readSelectionRange(); + let newRange = view.observer.selectionRange; + if (view.docView.posFromDOM(newRange.anchorNode, newRange.anchorOffset) != cursor.from) + sel.collapse(anchorNode, anchorOffset); + } + posFromDOM(node, offset) { + let tile = this.tile.nearest(node); + if (!tile) + return this.tile.dom.compareDocumentPosition(node) & 2 /* PRECEDING */ ? 0 : this.view.state.doc.length; + let start = tile.posAtStart; + if (tile.isComposite()) { + let after; + if (node == tile.dom) { + after = tile.dom.childNodes[offset]; + } + else { + let bias = maxOffset(node) == 0 ? 0 : offset == 0 ? -1 : 1; + for (;;) { + let parent = node.parentNode; + if (parent == tile.dom) + break; + if (bias == 0 && parent.firstChild != parent.lastChild) { + if (node == parent.firstChild) + bias = -1; + else + bias = 1; + } + node = parent; + } + if (bias < 0) + after = node; + else + after = node.nextSibling; + } + if (after == tile.dom.firstChild) + return start; + while (after && !Tile.get(after)) + after = after.nextSibling; + if (!after) + return start + tile.length; + for (let i = 0, pos = start;; i++) { + let child = tile.children[i]; + if (child.dom == after) + return pos; + pos += child.length + child.breakAfter; + } + } + else if (tile.isText()) { + return node == tile.dom ? start + offset : start + (offset ? tile.length : 0); + } + else { + return start; + } + } + domAtPos(pos, side) { + let { tile, offset } = this.tile.resolveBlock(pos, side); + if (tile.isWidget()) + return tile.domPosFor(offset, side); + return tile.domIn(offset, side); + } + inlineDOMNearPos(pos, side) { + let before, beforeOff = -1, beforeBad = false; + let after, afterOff = -1, afterBad = false; + this.tile.blockTiles((tile, off) => { + if (tile.isWidget()) { + if ((tile.flags & 32 /* TileFlag.After */) && off >= pos) + return true; + if (tile.flags & 16 /* TileFlag.Before */) + beforeBad = true; + } + else { + let end = off + tile.length; + if (off <= pos) { + before = tile; + beforeOff = pos - off; + beforeBad = end < pos; + } + if (end >= pos && !after) { + after = tile; + afterOff = pos - off; + afterBad = off > pos; + } + if (off > pos && after) + return true; + } + }); + if (!before && !after) + return this.domAtPos(pos, side); + if (beforeBad && after) + before = null; + else if (afterBad && before) + after = null; + return before && side < 0 || !after ? before.domIn(beforeOff, side) : after.domIn(afterOff, side); + } + // Get the coord of the element at the given side of the given + // position. If rtl is given, flatten it using that text direction. + coordsAt(pos, side, rtl) { + let { tile, offset } = this.tile.resolveBlock(pos, side); + if (tile.isWidget()) { + if (tile.widget instanceof BlockGapWidget) + return null; + return tile.coordsInWidget(offset, side, true); + } + return tile.coordsIn(offset, side, rtl); + } + lineAt(pos, side) { + let { tile } = this.tile.resolveBlock(pos, side); + return tile.isLine() ? tile : null; + } + coordsForChar(pos) { + let { tile, offset } = this.tile.resolveBlock(pos, 1); + if (!tile.isLine()) + return null; + function scan(tile, offset) { + if (tile.isComposite()) { + for (let ch of tile.children) { + if (ch.length >= offset) { + let found = scan(ch, offset); + if (found) + return found; + } + offset -= ch.length; + if (offset < 0) + break; + } + } + else if (tile.isText() && offset < tile.length) { + let end = state.findClusterBreak(tile.text, offset); + if (end == offset) + return null; + let rects = textRange(tile.dom, offset, end).getClientRects(); + for (let i = 0; i < rects.length; i++) { + let rect = rects[i]; + if (i == rects.length - 1 || rect.top < rect.bottom && rect.left < rect.right) + return rect; + } + } + return null; + } + return scan(tile, offset); + } + measureVisibleLineHeights(viewport) { + let result = [], { from, to } = viewport; + let contentWidth = this.view.contentDOM.clientWidth; + let isWider = contentWidth > Math.max(this.view.scrollDOM.clientWidth, this.minWidth) + 1; + let widest = -1, ltr = this.view.textDirection == exports.Direction.LTR; + let spaceAbove = 0; + let scan = (tile, pos, measureBounds) => { + for (let i = 0; i < tile.children.length; i++) { + if (pos > to) + break; + let child = tile.children[i], end = pos + child.length; + let childRect = child.dom.getBoundingClientRect(), { height } = childRect; + if (measureBounds && !i) + spaceAbove += childRect.top - measureBounds.top; + if (child instanceof BlockWrapperTile) { + if (end > from) + scan(child, pos, childRect); + } + else if (pos >= from) { + if (spaceAbove > 0) + result.push(-spaceAbove); + result.push(height + spaceAbove); + spaceAbove = 0; + if (isWider) { + let last = child.dom.lastChild; + let rects = last ? clientRectsFor(last) : []; + if (rects.length) { + let rect = rects[rects.length - 1]; + let width = ltr ? rect.right - childRect.left : childRect.right - rect.left; + if (width > widest) { + widest = width; + this.minWidth = contentWidth; + this.minWidthFrom = pos; + this.minWidthTo = end; + } + } + } + } + if (measureBounds && i == tile.children.length - 1) + spaceAbove += measureBounds.bottom - childRect.bottom; + pos = end + child.breakAfter; + } + }; + scan(this.tile, 0, null); + return result; + } + textDirectionAt(pos) { + let { tile } = this.tile.resolveBlock(pos, 1); + return getComputedStyle(tile.dom).direction == "rtl" ? exports.Direction.RTL : exports.Direction.LTR; + } + measureTextSize() { + let lineMeasure = this.tile.blockTiles(tile => { + if (tile.isLine() && tile.children.length && tile.length <= 20) { + let totalWidth = 0, textHeight; + for (let child of tile.children) { + if (!child.isText() || /[^ -~]/.test(child.text)) + return undefined; + let rects = clientRectsFor(child.dom); + if (rects.length != 1) + return undefined; + totalWidth += rects[0].width; + textHeight = rects[0].height; + } + if (totalWidth) + return { + lineHeight: tile.dom.getBoundingClientRect().height, + charWidth: totalWidth / tile.length, + textHeight + }; + } + }); + if (lineMeasure) + return lineMeasure; + // If no workable line exists, force a layout of a measurable element + let dummy = document.createElement("div"), lineHeight, charWidth, textHeight; + dummy.className = "cm-line"; + dummy.style.width = "99999px"; + dummy.style.position = "absolute"; + dummy.textContent = "abc def ghi jkl mno pqr stu"; + this.view.observer.ignore(() => { + this.tile.dom.appendChild(dummy); + let rect = clientRectsFor(dummy.firstChild)[0]; + lineHeight = dummy.getBoundingClientRect().height; + charWidth = rect && rect.width ? rect.width / 27 : 7; + textHeight = rect && rect.height ? rect.height : lineHeight; + dummy.remove(); + }); + return { lineHeight, charWidth, textHeight }; + } + computeBlockGapDeco() { + let deco = [], vs = this.view.viewState; + for (let pos = 0, i = 0;; i++) { + let next = i == vs.viewports.length ? null : vs.viewports[i]; + let end = next ? next.from - 1 : this.view.state.doc.length; + if (end > pos) { + let height = (vs.lineBlockAt(end).bottom - vs.lineBlockAt(pos).top) / this.view.scaleY; + deco.push(Decoration.replace({ + widget: new BlockGapWidget(height), + block: true, + inclusive: true, + isBlockGap: true, + }).range(pos, end)); + } + if (!next) + break; + pos = next.to + 1; + } + return Decoration.set(deco); + } + updateDeco() { + let i = 1; + let allDeco = this.view.state.facet(decorations).map(d => { + let dynamic = this.dynamicDecorationMap[i++] = typeof d == "function"; + return dynamic ? d(this.view) : d; + }); + let dynamicOuter = false, outerDeco = this.view.state.facet(outerDecorations).map((d, i) => { + let dynamic = typeof d == "function"; + if (dynamic) + dynamicOuter = true; + return dynamic ? d(this.view) : d; + }); + if (outerDeco.length) { + this.dynamicDecorationMap[i++] = dynamicOuter; + allDeco.push(state.RangeSet.join(outerDeco)); + } + this.decorations = [ + this.editContextFormatting, + ...allDeco, + this.computeBlockGapDeco(), + this.view.viewState.lineGapDeco + ]; + while (i < this.decorations.length) + this.dynamicDecorationMap[i++] = false; + this.blockWrappers = this.view.state.facet(blockWrappers).map(v => typeof v == "function" ? v(this.view) : v); + } + scrollIntoView(target) { + if (target.isSnapshot) { + let ref = this.view.viewState.lineBlockAt(target.range.head); + this.view.scrollDOM.scrollTop = ref.top - target.yMargin; + this.view.scrollDOM.scrollLeft = target.xMargin; + return; + } + for (let handler of this.view.state.facet(scrollHandler)) { + try { + if (handler(this.view, target.range, target)) + return true; + } + catch (e) { + logException(this.view.state, e, "scroll handler"); + } + } + let { range } = target; + let rect = this.coordsAt(range.head, range.assoc || (range.head > range.anchor ? -1 : 1)), other; + if (!rect) + return; + if (!range.empty && (other = this.coordsAt(range.anchor, range.anchor > range.head ? -1 : 1))) + rect = { left: Math.min(rect.left, other.left), top: Math.min(rect.top, other.top), + right: Math.max(rect.right, other.right), bottom: Math.max(rect.bottom, other.bottom) }; + let margins = getScrollMargins(this.view); + let targetRect = { + left: rect.left - margins.left, top: rect.top - margins.top, + right: rect.right + margins.right, bottom: rect.bottom + margins.bottom + }; + let { offsetWidth, offsetHeight } = this.view.scrollDOM; + scrollRectIntoView(this.view.scrollDOM, targetRect, range.head < range.anchor ? -1 : 1, target.x, target.y, Math.max(Math.min(target.xMargin, offsetWidth), -offsetWidth), Math.max(Math.min(target.yMargin, offsetHeight), -offsetHeight), this.view.textDirection == exports.Direction.LTR); + // On mobile browsers, the visual viewport may be smaller than the + // actual reported viewport, causing scrollRectIntoView to fail to + // scroll properly. Unfortunately, this visual viewport cannot be + // updated directly, and scrollIntoView is the only way a script + // can affect it. So this tries to kludge around the problem by + // calling scrollIntoView on the scroll target's line. + if (window.visualViewport && window.innerHeight - window.visualViewport.height > 1 && + (rect.top > window.visualViewport.offsetTop + window.visualViewport.height || + rect.bottom < window.visualViewport.offsetTop)) { + let line = this.view.docView.lineAt(range.head, 1); + if (line) { + let stack = getScrollStack(line.dom); + line.dom.scrollIntoView({ block: "nearest" }); + restoreScrollStack(stack, false); + } + } + } + lineHasWidget(pos) { + let scan = (child) => child.isWidget() || child.children.some(scan); + return scan(this.tile.resolveBlock(pos, 1).tile); + } + destroy() { + destroyDropped(this.tile); + } +} +function destroyDropped(tile, reused) { + let r = reused === null || reused === void 0 ? void 0 : reused.get(tile); + if (r != 1 /* Reused.Full */) { + if (r == null) + tile.destroy(); + for (let ch of tile.children) + destroyDropped(ch, reused); + } +} +function betweenUneditable(pos) { + return pos.node.nodeType == 1 && pos.node.firstChild && + (pos.offset == 0 || pos.node.childNodes[pos.offset - 1].contentEditable == "false") && + (pos.offset == pos.node.childNodes.length || pos.node.childNodes[pos.offset].contentEditable == "false"); +} +function findCompositionNode(view, headPos) { + let sel = view.observer.selectionRange; + if (!sel.focusNode) + return null; + let textBefore = textNodeBefore(sel.focusNode, sel.focusOffset); + let textAfter = textNodeAfter(sel.focusNode, sel.focusOffset); + let textNode = textBefore || textAfter; + if (textAfter && textBefore && textAfter.node != textBefore.node) { + let tileAfter = Tile.get(textAfter.node); + if (!tileAfter || tileAfter.isText() && tileAfter.text != textAfter.node.nodeValue) { + textNode = textAfter; + } + else if (view.docView.lastCompositionAfterCursor) { + let tileBefore = Tile.get(textBefore.node); + if (!(!tileBefore || tileBefore.isText() && tileBefore.text != textBefore.node.nodeValue)) + textNode = textAfter; + } + } + view.docView.lastCompositionAfterCursor = textNode != textBefore; + if (!textNode) + return null; + let from = headPos - textNode.offset; + return { from, to: from + textNode.node.nodeValue.length, node: textNode.node }; +} +function findCompositionRange(view, changes, headPos) { + let found = findCompositionNode(view, headPos); + if (!found) + return null; + let { node: textNode, from, to } = found, text = textNode.nodeValue; + // Don't try to preserve multi-line compositions + if (/[\n\r]/.test(text)) + return null; + if (view.state.doc.sliceString(found.from, found.to) != text) + return null; + let inv = changes.invertedDesc; + return { range: new ChangedRange(inv.mapPos(from), inv.mapPos(to), from, to), text: textNode }; +} +function nextToUneditable(node, offset) { + if (node.nodeType != 1) + return 0; + return (offset && node.childNodes[offset - 1].contentEditable == "false" ? 1 /* NextTo.Before */ : 0) | + (offset < node.childNodes.length && node.childNodes[offset].contentEditable == "false" ? 2 /* NextTo.After */ : 0); +} +let DecorationComparator$1 = class DecorationComparator { + constructor() { + this.changes = []; + } + compareRange(from, to) { addRange(from, to, this.changes); } + comparePoint(from, to) { addRange(from, to, this.changes); } + boundChange(pos) { addRange(pos, pos, this.changes); } +}; +function findChangedDeco(a, b, diff) { + let comp = new DecorationComparator$1; + state.RangeSet.compare(a, b, diff, comp); + return comp.changes; +} +class WrapperComparator { + constructor() { + this.changes = []; + } + compareRange(from, to) { addRange(from, to, this.changes); } + comparePoint() { } + boundChange(pos) { addRange(pos, pos, this.changes); } +} +function findChangedWrappers(a, b, diff) { + let comp = new WrapperComparator; + state.RangeSet.compare(a, b, diff, comp); + return comp.changes; +} +function inUneditable(node, inside) { + for (let cur = node; cur && cur != inside; cur = cur.assignedSlot || cur.parentNode) { + if (cur.nodeType == 1 && cur.contentEditable == 'false') { + return true; + } + } + return false; +} +function touchesComposition(changes, composition) { + let touched = false; + if (composition) + changes.iterChangedRanges((from, to) => { + if (from < composition.to && to > composition.from) + touched = true; + }); + return touched; +} +class BlockGapWidget extends WidgetType { + constructor(height) { + super(); + this.height = height; + } + toDOM() { + let elt = document.createElement("div"); + elt.className = "cm-gap"; + this.updateDOM(elt); + return elt; + } + eq(other) { return other.height == this.height; } + updateDOM(elt) { + elt.style.height = this.height + "px"; + return true; + } + get editable() { return true; } + get estimatedHeight() { return this.height; } + ignoreEvent() { return false; } +} + +function groupAt(state$1, pos, bias = 1) { + let categorize = state$1.charCategorizer(pos); + let line = state$1.doc.lineAt(pos), linePos = pos - line.from; + if (line.length == 0) + return state.EditorSelection.cursor(pos); + if (linePos == 0) + bias = 1; + else if (linePos == line.length) + bias = -1; + let from = linePos, to = linePos; + if (bias < 0) + from = state.findClusterBreak(line.text, linePos, false); + else + to = state.findClusterBreak(line.text, linePos); + let cat = categorize(line.text.slice(from, to)); + while (from > 0) { + let prev = state.findClusterBreak(line.text, from, false); + if (categorize(line.text.slice(prev, from)) != cat) + break; + from = prev; + } + while (to < line.length) { + let next = state.findClusterBreak(line.text, to); + if (categorize(line.text.slice(to, next)) != cat) + break; + to = next; + } + return state.EditorSelection.undirectionalRange(from + line.from, to + line.from); +} +function posAtCoordsImprecise(view, contentRect, block, x, y) { + let into = Math.round((x - contentRect.left) * view.defaultCharacterWidth); + if (view.lineWrapping && block.height > view.defaultLineHeight * 1.5) { + let textHeight = view.viewState.heightOracle.textHeight; + let line = Math.floor((y - block.top - (view.defaultLineHeight - textHeight) * 0.5) / textHeight); + into += line * view.viewState.heightOracle.lineLength; + } + let content = view.state.sliceDoc(block.from, block.to); + return block.from + state.findColumn(content, into, view.state.tabSize); +} +function blockAt(view, pos, side) { + let line = view.lineBlockAt(pos); + if (Array.isArray(line.type)) { + let best; + for (let l of line.type) { + if (l.from > pos) + break; + if (l.to < pos) + continue; + if (l.from < pos && l.to > pos) + return l; + if (!best || (l.type == exports.BlockType.Text && (best.type != l.type || (side < 0 ? l.from < pos : l.to > pos)))) + best = l; + } + return best || line; + } + return line; +} +function moveToLineBoundary(view, start, forward, includeWrap) { + let line = blockAt(view, start.head, start.assoc || -1); + let coords = !includeWrap || line.type != exports.BlockType.Text || !(view.lineWrapping || line.widgetLineBreaks) ? null + : view.coordsAtPos(start.assoc < 0 && start.head > line.from ? start.head - 1 : start.head); + if (coords) { + let editorRect = view.dom.getBoundingClientRect(); + let direction = view.textDirectionAt(line.from); + let pos = view.posAtCoords({ x: forward == (direction == exports.Direction.LTR) ? editorRect.right - 1 : editorRect.left + 1, + y: (coords.top + coords.bottom) / 2 }); + if (pos != null) + return state.EditorSelection.cursor(pos, forward ? -1 : 1); + } + return state.EditorSelection.cursor(forward ? line.to : line.from, forward ? -1 : 1); +} +function moveByChar(view, start, forward, by) { + let line = view.state.doc.lineAt(start.head), spans = view.bidiSpans(line); + let direction = view.textDirectionAt(line.from); + for (let cur = start, check = null;;) { + let next = moveVisually(line, spans, direction, cur, forward), char = movedOver; + if (!next) { + if (line.number == (forward ? view.state.doc.lines : 1)) + return cur; + char = "\n"; + line = view.state.doc.line(line.number + (forward ? 1 : -1)); + spans = view.bidiSpans(line); + next = view.visualLineSide(line, !forward); + } + if (!check) { + if (!by) + return next; + check = by(char); + } + else if (!check(char)) { + return cur; + } + cur = next; + } +} +function byGroup(view, pos, start) { + let categorize = view.state.charCategorizer(pos); + let cat = categorize(start); + return (next) => { + let nextCat = categorize(next); + if (cat == state.CharCategory.Space) + cat = nextCat; + return cat == nextCat; + }; +} +function moveVertically(view, start, forward, distance) { + let startPos = start.head, dir = forward ? 1 : -1; + if (startPos == (forward ? view.state.doc.length : 0)) + return state.EditorSelection.cursor(startPos, start.assoc); + let goal = start.goalColumn, startY; + let rect = view.contentDOM.getBoundingClientRect(); + let startCoords = view.coordsAtPos(startPos, start.assoc || ((start.empty ? forward : start.head == start.from) ? 1 : -1)); + let docTop = view.documentTop; + if (startCoords) { + if (goal == null) + goal = startCoords.left - rect.left; + startY = dir < 0 ? startCoords.top : startCoords.bottom; + } + else { + let line = view.viewState.lineBlockAt(startPos); + if (goal == null) + goal = Math.min(rect.right - rect.left, view.defaultCharacterWidth * (startPos - line.from)); + startY = (dir < 0 ? line.top : line.bottom) + docTop; + } + let resolvedGoal = rect.left + goal; + let halfText = view.viewState.heightOracle.textHeight >> 1, dist = distance !== null && distance !== void 0 ? distance : halfText; + for (let scan = 0;; scan += halfText) { + let y = startY + (dist + scan) * dir; + let pos = posAtCoords(view, { x: resolvedGoal, y }, false, dir); + if (forward ? y > rect.bottom : y < rect.top) + return state.EditorSelection.cursor(pos.pos, pos.assoc); + let posCoords = view.coordsAtPos(pos.pos, pos.assoc), mid = posCoords ? (posCoords.top + posCoords.bottom) / 2 : 0; + if (!posCoords || (forward ? mid > startY : mid < startY)) + return state.EditorSelection.cursor(pos.pos, pos.assoc, undefined, goal); + } +} +function skipAtomicRanges(atoms, pos, bias) { + for (;;) { + let moved = 0; + for (let set of atoms) { + set.between(pos - 1, pos + 1, (from, to, value) => { + if (pos > from && pos < to) { + let side = moved || bias || (pos - from < to - pos ? -1 : 1); + pos = side < 0 ? from : to; + moved = side; + } + }); + } + if (!moved) + return pos; + } +} +function skipAtomsForSelection(atoms, sel) { + let ranges = null; + for (let i = 0; i < sel.ranges.length; i++) { + let range = sel.ranges[i], updated = null; + if (range.empty) { + let pos = skipAtomicRanges(atoms, range.from, 0); + if (pos != range.from) + updated = state.EditorSelection.cursor(pos, -1); + } + else { + let from = skipAtomicRanges(atoms, range.from, -1); + let to = skipAtomicRanges(atoms, range.to, 1); + if (from != range.from || to != range.to) { + if (range.undirectional) + updated = state.EditorSelection.undirectionalRange(range.from, range.to); + else + updated = state.EditorSelection.range(range.from == range.anchor ? from : to, range.from == range.head ? from : to); + } + } + if (updated) { + if (!ranges) + ranges = sel.ranges.slice(); + ranges[i] = updated; + } + } + return ranges ? state.EditorSelection.create(ranges, sel.mainIndex) : sel; +} +function skipAtoms(view, oldPos, pos) { + let newPos = skipAtomicRanges(view.state.facet(atomicRanges).map(f => f(view)), pos.from, oldPos.head > pos.from ? -1 : 1); + return newPos == pos.from ? pos : state.EditorSelection.cursor(newPos, newPos < pos.from ? 1 : -1); +} +class PosAssoc { + constructor(pos, assoc) { + this.pos = pos; + this.assoc = assoc; + } +} +function posAtCoords(view, coords, precise, scanY) { + let content = view.contentDOM.getBoundingClientRect(), docTop = content.top + view.viewState.paddingTop; + let { x, y } = coords, yOffset = y - docTop, block; + // First find the block at the given Y position, if any. If scanY is + // given (used for vertical cursor motion), try to skip widgets and + // line padding. + for (;;) { + if (yOffset < 0) + return new PosAssoc(0, 1); + if (yOffset > view.viewState.docHeight) + return new PosAssoc(view.state.doc.length, -1); + block = view.elementAtHeight(yOffset); + if (scanY == null) + break; + if (block.type == exports.BlockType.Text) { + if (scanY < 0 ? block.to < view.viewport.from : block.from > view.viewport.to) + break; + // Check whether we aren't landing on the top/bottom padding of the line + let rect = view.docView.coordsAt(scanY < 0 ? block.from : block.to, scanY > 0 ? -1 : 1); + if (rect && (scanY < 0 ? rect.top <= yOffset + docTop : rect.bottom >= yOffset + docTop)) + break; + } + let halfLine = view.viewState.heightOracle.textHeight / 2; + yOffset = scanY > 0 ? block.bottom + halfLine : block.top - halfLine; + } + // If outside the viewport, return null if precise==true, an + // estimate otherwise. + if (view.viewport.from >= block.to || view.viewport.to <= block.from) { + if (precise) + return null; + if (block.type == exports.BlockType.Text) { + let pos = posAtCoordsImprecise(view, content, block, x, y); + return new PosAssoc(pos, pos == block.from ? 1 : -1); + } + } + if (block.type != exports.BlockType.Text) + return yOffset < (block.top + block.bottom) / 2 ? new PosAssoc(block.from, 1) : new PosAssoc(block.to, -1); + // Here we know we're in a line, so run the logic for inline layout + let line = view.docView.lineAt(block.from, 2); + if (!line || line.length != block.length) + line = view.docView.lineAt(block.from, -2); + return new InlineCoordsScan(view, x, y, view.textDirectionAt(block.from)).scanTile(line, block.from); +} +class InlineCoordsScan { + constructor(view, x, y, baseDir) { + this.view = view; + this.x = x; + this.y = y; + this.baseDir = baseDir; + // Cached bidi info + this.line = null; + this.spans = null; + } + bidiSpansAt(pos) { + if (!this.line || this.line.from > pos || this.line.to < pos) { + this.line = this.view.state.doc.lineAt(pos); + this.spans = this.view.bidiSpans(this.line); + } + return this; + } + baseDirAt(pos, side) { + let { line, spans } = this.bidiSpansAt(pos); + let level = spans[BidiSpan.find(spans, pos - line.from, -1, side)].level; + return level == this.baseDir; + } + dirAt(pos, side) { + let { line, spans } = this.bidiSpansAt(pos); + return spans[BidiSpan.find(spans, pos - line.from, -1, side)].dir; + } + // Used to short-circuit bidi tests for content with a uniform direction + bidiIn(from, to) { + let { spans, line } = this.bidiSpansAt(from); + return spans.length > 1 || spans.length && (spans[0].level != this.baseDir || spans[0].to + line.from < to); + } + // Scan through the rectangles for the content of a tile with inline + // content, looking for one that overlaps the queried position + // vertically and is closest horizontally. The caller is responsible + // for dividing its content into N pieces, and pass an array with + // N+1 positions (including the position after the last piece). For + // a text tile, these will be character clusters, for a composite + // tile, these will be child tiles. + scan(positions, getRects, recursed = false) { + let lo = 0, hi = positions.length - 1, seen = new Set(); + let bidi = this.bidiIn(positions[0], positions[hi]); + let above, below; + let closestI = -1, closestDx = 1e9, closestRect; + // Because, when the content is bidirectional, a regular binary + // search is hard to perform (the content order does not + // correspond to visual order), this loop does something between a + // regular binary search and a full scan, depending on what it can + // get away with. The outer hi/lo bounds are only adjusted for + // elements that are part of the base order. + // + // To make sure all elements inside those bounds are visited, + // eventually, we keep a set of seen indices, and if the midpoint + // has already been handled, we start in a random index within the + // current bounds and scan forward until we find an index that + // hasn't been seen yet. + search: while (lo < hi) { + let dist = hi - lo, mid = (lo + hi) >> 1; + adjust: if (seen.has(mid)) { + for (let i = 1; i < dist; i++) { + let scan = mid + i; + if (scan >= hi) + scan -= dist; + if (!seen.has(scan)) { + mid = scan; + break adjust; + } + } + break search; // No index found, we're done + } + seen.add(mid); + let rects = getRects(mid), side = 0; + if (rects) + for (let i = 0; i < rects.length; i++) { + let rect = rects[i]; + // Ignore empty rectangles when there are other rectangles + if (rect.width == 0 && rects.length > 1) + continue; + if (rect.bottom < this.y) { + if (!above || above.bottom < rect.bottom) + above = rect; + side = 1; + } + else if (rect.top > this.y) { + if (!below || below.top > rect.top) + below = rect; + side = -1; + } + else { + let off = rect.left > this.x ? this.x - rect.left : rect.right < this.x ? this.x - rect.right : 0; + let dx = Math.abs(off); + if (dx < closestDx) { + closestI = mid; + closestDx = dx; + closestRect = rect; + } + if (off) + side = (off < 0) == (this.baseDir == exports.Direction.LTR) ? -1 : 1; + } + } + // Narrow binary search when it is safe to do so + if (side == -1 && (!bidi || this.baseDirAt(positions[mid], 1))) + hi = mid; + else if (side == 1 && (!bidi || this.baseDirAt(positions[mid + 1], -1))) + lo = mid + 1; + } + // If no element with y overlap is found, find the nearest element + // on the y axis, move this.y into it, and retry the scan. + if (!closestRect) { + if (!below && !above) + return { i: positions[0], after: false }; + let side = above && (!below || (this.y - above.bottom < below.top - this.y)) ? above : below; + this.y = (side.top + side.bottom) / 2; + return this.scan(positions, getRects, true); + } + // Handle the case where closest matched a higher element on the + // same line as an element below/above the coords + if (closestDx && !recursed) { + let { top, bottom } = closestRect; + if (above && above.bottom > (top + top + bottom) / 3) { + this.y = above.bottom - 1; + return this.scan(positions, getRects, true); + } + if (below && below.top < (top + bottom + bottom) / 3) { + this.y = below.top + 1; + return this.scan(positions, getRects, true); + } + } + let ltr = (bidi ? this.dirAt(positions[closestI], 1) : this.baseDir) == exports.Direction.LTR; + return { + i: closestI, + // Test whether x is closes to the start or end of this element + after: (this.x > (closestRect.left + closestRect.right) / 2) == ltr + }; + } + scanText(tile, offset) { + let positions = []; + for (let i = 0; i < tile.length; i = state.findClusterBreak(tile.text, i)) + positions.push(offset + i); + positions.push(offset + tile.length); + let scan = this.scan(positions, i => { + let off = positions[i] - offset, end = positions[i + 1] - offset; + return textRange(tile.dom, off, end).getClientRects(); + }); + return scan.after ? new PosAssoc(positions[scan.i + 1], -1) : new PosAssoc(positions[scan.i], 1); + } + scanTile(tile, offset) { + if (!tile.length) + return new PosAssoc(offset, 1); + if (tile.children.length == 1) { // Short-circuit single-child tiles + let child = tile.children[0]; + if (child.isText()) + return this.scanText(child, offset); + else if (child.isComposite()) + return this.scanTile(child, offset); + } + let positions = [offset]; + for (let i = 0, pos = offset; i < tile.children.length; i++) + positions.push(pos += tile.children[i].length); + let scan = this.scan(positions, i => { + let child = tile.children[i]; + if (child.flags & 48 /* TileFlag.PointWidget */) + return null; + return (child.dom.nodeType == 1 ? child.dom : textRange(child.dom, 0, child.length)).getClientRects(); + }); + let child = tile.children[scan.i], pos = positions[scan.i]; + if (child.isText()) + return this.scanText(child, pos); + if (child.isComposite()) + return this.scanTile(child, pos); + return scan.after ? new PosAssoc(positions[scan.i + 1], -1) : new PosAssoc(pos, 1); + } +} + +const LineBreakPlaceholder = "\uffff"; +class DOMReader { + constructor(points, view) { + this.points = points; + this.view = view; + this.text = ""; + this.lineSeparator = view.state.facet(state.EditorState.lineSeparator); + } + append(text) { + this.text += text; + } + lineBreak() { + this.text += LineBreakPlaceholder; + } + readRange(start, end) { + if (!start) + return this; + let parent = start.parentNode; + for (let cur = start;;) { + this.findPointBefore(parent, cur); + let oldLen = this.text.length; + this.readNode(cur); + let tile = Tile.get(cur), next = cur.nextSibling; + if (next == end) { + if ((tile === null || tile === void 0 ? void 0 : tile.breakAfter) && !next && parent != this.view.contentDOM) + this.lineBreak(); + break; + } + let nextTile = Tile.get(next); + if ((tile && nextTile ? tile.breakAfter : + (tile ? tile.breakAfter : isBlockElement(cur)) || + (isBlockElement(next) && (cur.nodeName != "BR" || (tile === null || tile === void 0 ? void 0 : tile.isWidget())) && this.text.length > oldLen)) && + !isEmptyToEnd(next, end)) + this.lineBreak(); + cur = next; + } + this.findPointBefore(parent, end); + return this; + } + readTextNode(node) { + let text = node.nodeValue; + for (let point of this.points) + if (point.node == node) + point.pos = this.text.length + Math.min(point.offset, text.length); + for (let off = 0, re = this.lineSeparator ? null : /\r\n?|\n/g;;) { + let nextBreak = -1, breakSize = 1, m; + if (this.lineSeparator) { + nextBreak = text.indexOf(this.lineSeparator, off); + breakSize = this.lineSeparator.length; + } + else if (m = re.exec(text)) { + nextBreak = m.index; + breakSize = m[0].length; + } + this.append(text.slice(off, nextBreak < 0 ? text.length : nextBreak)); + if (nextBreak < 0) + break; + this.lineBreak(); + if (breakSize > 1) + for (let point of this.points) + if (point.node == node && point.pos > this.text.length) + point.pos -= breakSize - 1; + off = nextBreak + breakSize; + } + } + readNode(node) { + let tile = Tile.get(node); + let fromView = tile && tile.overrideDOMText; + if (fromView != null) { + this.findPointInside(node, fromView.length); + for (let i = fromView.iter(); !i.next().done;) { + if (i.lineBreak) + this.lineBreak(); + else + this.append(i.value); + } + } + else if (node.nodeType == 3) { + this.readTextNode(node); + } + else if (node.nodeName == "BR") { + if (node.nextSibling) + this.lineBreak(); + } + else if (node.nodeType == 1) { + this.readRange(node.firstChild, null); + } + } + findPointBefore(node, next) { + for (let point of this.points) + if (point.node == node && node.childNodes[point.offset] == next) + point.pos = this.text.length; + } + findPointInside(node, length) { + for (let point of this.points) + if (node.nodeType == 3 ? point.node == node : node.contains(point.node)) + point.pos = this.text.length + (isAtEnd(node, point.node, point.offset) ? length : 0); + } +} +function isAtEnd(parent, node, offset) { + for (;;) { + if (!node || offset < maxOffset(node)) + return false; + if (node == parent) + return true; + offset = domIndex(node) + 1; + node = node.parentNode; + } +} +function isEmptyToEnd(node, end) { + let widgets; + for (;; node = node.nextSibling) { + if (node == end || !node) + break; + let view = Tile.get(node); + if (!(view === null || view === void 0 ? void 0 : view.isWidget())) + return false; + if (view) + (widgets || (widgets = [])).push(view); + } + if (widgets) + for (let w of widgets) { + let override = w.overrideDOMText; + if (override === null || override === void 0 ? void 0 : override.length) + return false; + } + return true; +} +class DOMPoint { + constructor(node, offset) { + this.node = node; + this.offset = offset; + this.pos = -1; + } +} + +class DOMChange { + constructor(view, start, end, typeOver) { + this.typeOver = typeOver; + this.bounds = null; + this.text = ""; + this.domChanged = start > -1; + let { impreciseHead: iHead, impreciseAnchor: iAnchor } = view.docView, curSel = view.state.selection; + if (view.state.readOnly && start > -1) { + // Ignore changes when the editor is read-only + this.newSel = null; + } + else if (start > -1 && (this.bounds = domBoundsAround(view.docView.tile, start, end, 0))) { + let selPoints = iHead || iAnchor ? [] : selectionPoints(view); + let reader = new DOMReader(selPoints, view); + reader.readRange(this.bounds.startDOM, this.bounds.endDOM); + this.text = reader.text; + this.newSel = selectionFromPoints(selPoints, this.bounds.from); + } + else { + let domSel = view.observer.selectionRange; + let head = iHead && iHead.node == domSel.focusNode && iHead.offset == domSel.focusOffset || + !contains(view.contentDOM, domSel.focusNode) + ? curSel.main.head + : view.docView.posFromDOM(domSel.focusNode, domSel.focusOffset); + let anchor = iAnchor && iAnchor.node == domSel.anchorNode && iAnchor.offset == domSel.anchorOffset || + !contains(view.contentDOM, domSel.anchorNode) + ? curSel.main.anchor + : view.docView.posFromDOM(domSel.anchorNode, domSel.anchorOffset); + // iOS will refuse to select the block gaps when doing + // select-all. + // Chrome will put the selection *inside* them, confusing + // posFromDOM + let vp = view.viewport; + if ((browser.ios || browser.chrome) && head != anchor && + Math.min(head, anchor) <= curSel.main.from && Math.max(head, anchor) >= curSel.main.to && + (vp.from > 0 || vp.to < view.state.doc.length)) { + let from = Math.min(head, anchor), to = Math.max(head, anchor); + let offFrom = vp.from - from, offTo = vp.to - to; + if ((offFrom == 0 || offFrom == 1 || from == 0) && (offTo == 0 || offTo == -1 || to == view.state.doc.length)) { + head = 0; + anchor = view.state.doc.length; + } + } + if (view.inputState.composing > -1 && curSel.ranges.length > 1) { + this.newSel = curSel.replaceRange(state.EditorSelection.range(anchor, head)); + } + else if (view.lineWrapping && anchor == head && !(curSel.main.empty && curSel.main.head == head) && + view.inputState.lastTouchTime > Date.now() - 100) { + // If this is a cursor selection change in a line-wrapping + // editor that may have been a touch, use the last touch + // position to assign a side to the cursor. + let before = view.coordsAtPos(head, -1), assoc = 0; + if (before) + assoc = view.inputState.lastTouchY <= before.bottom ? -1 : 1; + this.newSel = state.EditorSelection.create([state.EditorSelection.cursor(head, assoc)]); + } + else { + this.newSel = state.EditorSelection.single(anchor, head); + } + } + } +} +function domBoundsAround(tile, from, to, offset) { + if (tile.isComposite()) { + let fromI = -1, fromStart = -1, toI = -1, toEnd = -1; + for (let i = 0, pos = offset, prevEnd = offset; i < tile.children.length; i++) { + let child = tile.children[i], end = pos + child.length; + if (pos < from && end > to) + return domBoundsAround(child, from, to, pos); + if (end >= from && fromI == -1) { + fromI = i; + fromStart = pos; + } + if (pos > to && child.dom.parentNode == tile.dom) { + toI = i; + toEnd = prevEnd; + break; + } + prevEnd = end; + pos = end + child.breakAfter; + } + return { from: fromStart, to: toEnd < 0 ? offset + tile.length : toEnd, + startDOM: (fromI ? tile.children[fromI - 1].dom.nextSibling : null) || tile.dom.firstChild, + endDOM: toI < tile.children.length && toI >= 0 ? tile.children[toI].dom : null }; + } + else if (tile.isText()) { + return { from: offset, to: offset + tile.length, startDOM: tile.dom, endDOM: tile.dom.nextSibling }; + } + else { + return null; + } +} +function applyDOMChange(view, domChange) { + let change; + let { newSel } = domChange, { state: state$1 } = view, sel = state$1.selection.main; + let lastKey = view.inputState.lastKeyTime > Date.now() - 100 ? view.inputState.lastKeyCode : -1; + if (domChange.bounds) { + let { from, to } = domChange.bounds; + let preferredPos = sel.from, preferredSide = null; + // Prefer anchoring to end when Backspace is pressed (or, on + // Android, when something was deleted) + if (lastKey === 8 || browser.android && domChange.text.length < to - from) { + preferredPos = sel.to; + preferredSide = "end"; + } + let cmp = state$1.doc.sliceString(from, to, LineBreakPlaceholder), selEnd, diff; + if (!sel.empty && sel.from >= from && sel.to <= to && (domChange.typeOver || cmp != domChange.text) && + cmp.slice(0, sel.from - from) == domChange.text.slice(0, sel.from - from) && + cmp.slice(sel.to - from) == domChange.text.slice(selEnd = domChange.text.length - (cmp.length - (sel.to - from)))) { + // This looks like a selection replacement + change = { from: sel.from, to: sel.to, + insert: state.Text.of(domChange.text.slice(sel.from - from, selEnd).split(LineBreakPlaceholder)) }; + } + else if (diff = findDiff(cmp, domChange.text, preferredPos - from, preferredSide)) { + // Chrome inserts two newlines when pressing shift-enter at the + // end of a line. DomChange drops one of those. + if (browser.chrome && lastKey == 13 && + diff.toB == diff.from + 2 && domChange.text.slice(diff.from, diff.toB) == LineBreakPlaceholder + LineBreakPlaceholder) + diff.toB--; + change = { from: from + diff.from, to: from + diff.toA, + insert: state.Text.of(domChange.text.slice(diff.from, diff.toB).split(LineBreakPlaceholder)) }; + } + } + else if (newSel && (!view.hasFocus && state$1.facet(editable) || sameSelPos(newSel, sel))) { + newSel = null; + } + if (!change && !newSel) + return false; + if ((browser.mac || browser.android) && change && change.from == change.to && change.from == sel.head - 1 && + /^\. ?$/.test(change.insert.toString()) && view.contentDOM.getAttribute("autocorrect") == "off") { + // Detect insert-period-on-double-space Mac and Android behavior, + // and transform it into a regular space insert. + if (newSel && change.insert.length == 2) + newSel = state.EditorSelection.single(newSel.main.anchor - 1, newSel.main.head - 1); + change = { from: change.from, to: change.to, insert: state.Text.of([change.insert.toString().replace(".", " ")]) }; + } + else if (state$1.doc.lineAt(sel.from).to < sel.to && view.docView.lineHasWidget(sel.to) && + view.inputState.insertingTextAt > Date.now() - 50) { + // For a cross-line insertion, Chrome and Safari will crudely take + // the text of the line after the selection, flattening any + // widgets, and move it into the joined line. This tries to detect + // such a situation, and replaces the change with a selection + // replace of the text provided by the beforeinput event. + change = { + from: sel.from, to: sel.to, + insert: state$1.toText(view.inputState.insertingText) + }; + } + else if (browser.chrome && change && change.from == change.to && change.from == sel.head && + change.insert.toString() == "\n " && view.lineWrapping) { + // In Chrome, if you insert a space at the start of a wrapped + // line, it will actually insert a newline and a space, causing a + // bogus new line to be created in CodeMirror (#968) + if (newSel) + newSel = state.EditorSelection.single(newSel.main.anchor - 1, newSel.main.head - 1); + change = { from: sel.from, to: sel.to, insert: state.Text.of([" "]) }; + } + if (change) { + return applyDOMChangeInner(view, change, newSel, lastKey); + } + else if (newSel && !sameSelPos(newSel, sel)) { + let scrollIntoView = false, userEvent = "select"; + if (view.inputState.lastSelectionTime > Date.now() - 50) { + if (view.inputState.lastSelectionOrigin == "select") + scrollIntoView = true; + userEvent = view.inputState.lastSelectionOrigin; + if (userEvent == "select.pointer") + newSel = skipAtomsForSelection(state$1.facet(atomicRanges).map(f => f(view)), newSel); + } + view.dispatch({ selection: newSel, scrollIntoView, userEvent }); + return true; + } + else { + return false; + } +} +function applyDOMChangeInner(view, change, newSel, lastKey = -1) { + if (browser.ios && view.inputState.flushIOSKey(change)) + return true; + let sel = view.state.selection.main; + // Android browsers don't fire reasonable key events for enter, + // backspace, or delete. So this detects changes that look like + // they're caused by those keys, and reinterprets them as key + // events. (Some of these keys are also handled by beforeinput + // events and the pendingAndroidKey mechanism, but that's not + // reliable in all situations.) + if (browser.android && + ((change.to == sel.to && + // GBoard will sometimes remove a space it just inserted + // after a completion when you press enter + (change.from == sel.from || change.from == sel.from - 1 && view.state.sliceDoc(change.from, sel.from) == " ") && + change.insert.length == 1 && change.insert.lines == 2 && + dispatchKey(view.contentDOM, "Enter", 13)) || + ((change.from == sel.from - 1 && change.to == sel.to && change.insert.length == 0 || + lastKey == 8 && change.insert.length < change.to - change.from && change.to > sel.head) && + dispatchKey(view.contentDOM, "Backspace", 8)) || + (change.from == sel.from && change.to == sel.to + 1 && change.insert.length == 0 && + dispatchKey(view.contentDOM, "Delete", 46)))) + return true; + let text = change.insert.toString(); + if (view.inputState.composing >= 0) + view.inputState.composing++; + let defaultTr; + let defaultInsert = () => defaultTr || (defaultTr = applyDefaultInsert(view, change, newSel)); + if (!view.state.facet(inputHandler).some(h => h(view, change.from, change.to, text, defaultInsert))) + view.dispatch(defaultInsert()); + return true; +} +function applyDefaultInsert(view, change, newSel) { + let tr, startState = view.state, sel = startState.selection.main, inAtomic = -1; + if (change.from == change.to && change.from < sel.from || change.from > sel.to) { + let side = change.from < sel.from ? -1 : 1, pos = side < 0 ? sel.from : sel.to; + let moved = skipAtomicRanges(startState.facet(atomicRanges).map(f => f(view)), pos, side); + if (change.from == moved) + inAtomic = moved; + } + if (inAtomic > -1) { + tr = { + changes: change, + selection: state.EditorSelection.cursor(change.from + change.insert.length, -1) + }; + } + else if (change.from >= sel.from && change.to <= sel.to && change.to - change.from >= (sel.to - sel.from) / 3 && + (!newSel || newSel.main.empty && newSel.main.from == change.from + change.insert.length) && + view.inputState.composing < 0) { + let before = sel.from < change.from ? startState.sliceDoc(sel.from, change.from) : ""; + let after = sel.to > change.to ? startState.sliceDoc(change.to, sel.to) : ""; + tr = startState.replaceSelection(view.state.toText(before + change.insert.sliceString(0, undefined, view.state.lineBreak) + after)); + } + else { + let changes = startState.changes(change); + let mainSel = newSel && newSel.main.to <= changes.newLength ? newSel.main : undefined; + // Try to apply a composition change to all cursors + if (startState.selection.ranges.length > 1 && (view.inputState.composing >= 0 || view.inputState.compositionPendingChange) && + change.to <= sel.to + 10 && change.to >= sel.to - 10) { + let replaced = view.state.sliceDoc(change.from, change.to); + let compositionRange, composition = newSel && findCompositionNode(view, newSel.main.head); + if (composition) { + let dLen = change.insert.length - (change.to - change.from); + compositionRange = { from: composition.from, to: composition.to - dLen }; + } + else { + compositionRange = view.state.doc.lineAt(sel.head); + } + let offset = sel.to - change.to; + tr = startState.changeByRange(range => { + if (range.from == sel.from && range.to == sel.to) + return { changes, range: mainSel || range.map(changes) }; + let to = range.to - offset, from = to - replaced.length; + if (view.state.sliceDoc(from, to) != replaced || + // Unfortunately, there's no way to make multiple + // changes in the same node work without aborting + // composition, so cursors in the composition range are + // ignored. + to >= compositionRange.from && from <= compositionRange.to) + return { range }; + let rangeChanges = startState.changes({ from, to, insert: change.insert }), selOff = range.to - sel.to; + return { + changes: rangeChanges, + range: !mainSel ? range.map(rangeChanges) : + state.EditorSelection.range(Math.max(0, mainSel.anchor + selOff), Math.max(0, mainSel.head + selOff)) + }; + }); + } + else { + tr = { + changes, + selection: mainSel && startState.selection.replaceRange(mainSel) + }; + } + } + let userEvent = "input.type"; + if (view.composing || + view.inputState.compositionPendingChange && view.inputState.compositionEndedAt > Date.now() - 50) { + view.inputState.compositionPendingChange = false; + userEvent += ".compose"; + if (view.inputState.compositionFirstChange) { + userEvent += ".start"; + view.inputState.compositionFirstChange = false; + } + } + return startState.update(tr, { userEvent, scrollIntoView: true }); +} +function findDiff(a, b, preferredPos, preferredSide) { + let minLen = Math.min(a.length, b.length); + let from = 0; + while (from < minLen && a.charCodeAt(from) == b.charCodeAt(from)) + from++; + if (from == minLen && a.length == b.length) + return null; + let toA = a.length, toB = b.length; + while (toA > 0 && toB > 0 && a.charCodeAt(toA - 1) == b.charCodeAt(toB - 1)) { + toA--; + toB--; + } + if (preferredSide == "end") { + let adjust = Math.max(0, from - Math.min(toA, toB)); + preferredPos -= toA + adjust - from; + } + if (toA < from && a.length < b.length) { + let move = preferredPos <= from && preferredPos >= toA ? from - preferredPos : 0; + from -= move; + toB = from + (toB - toA); + toA = from; + } + else if (toB < from) { + let move = preferredPos <= from && preferredPos >= toB ? from - preferredPos : 0; + from -= move; + toA = from + (toA - toB); + toB = from; + } + return { from, toA, toB }; +} +function selectionPoints(view) { + let result = []; + if (view.root.activeElement != view.contentDOM) + return result; + let { anchorNode, anchorOffset, focusNode, focusOffset } = view.observer.selectionRange; + if (anchorNode) { + result.push(new DOMPoint(anchorNode, anchorOffset)); + if (focusNode != anchorNode || focusOffset != anchorOffset) + result.push(new DOMPoint(focusNode, focusOffset)); + } + return result; +} +function selectionFromPoints(points, base) { + if (points.length == 0) + return null; + let anchor = points[0].pos, head = points.length == 2 ? points[1].pos : anchor; + return anchor > -1 && head > -1 ? state.EditorSelection.single(anchor + base, head + base) : null; +} +function sameSelPos(selection, range) { + return range.head == selection.main.head && range.anchor == selection.main.anchor; +} + +class InputState { + setSelectionOrigin(origin) { + this.lastSelectionOrigin = origin; + this.lastSelectionTime = Date.now(); + } + constructor(view) { + this.view = view; + this.lastKeyCode = 0; + this.lastKeyTime = 0; + this.touchActive = false; + this.lastTouchTime = 0; + this.lastTouchX = 0; + this.lastTouchY = 0; + this.lastFocusTime = 0; + this.lastScrollTop = 0; + this.lastScrollLeft = 0; + this.lastWheelEvent = 0; + // On iOS, some keys need to have their default behavior happen + // (after which we retroactively handle them and reset the DOM) to + // avoid messing up the virtual keyboard state. + this.pendingIOSKey = undefined; + // Set to a time stap by scroll events when touch isn't active on + // iOS, to work around an issue where Safari will abort the scroll + // momentum if we set scrollTop + this.lastIOSMomentumScroll = 0; + // When enabled (>-1), tab presses are not given to key handlers, + // leaving the browser's default behavior. If >0, the mode expires + // at that timestamp, and any other keypress clears it. + // Esc enables temporary tab focus mode for two seconds when not + // otherwise handled. + this.tabFocusMode = -1; + this.lastSelectionOrigin = null; + this.lastSelectionTime = 0; + this.lastContextMenu = 0; + this.scrollHandlers = []; + this.handlers = Object.create(null); + // -1 means not in a composition. Otherwise, this counts the number + // of changes made during the composition. The count is used to + // avoid treating the start state of the composition, before any + // changes have been made, as part of the composition. + this.composing = -1; + // Tracks whether the next change should be marked as starting the + // composition (null means no composition, true means next is the + // first, false means first has already been marked for this + // composition) + this.compositionFirstChange = null; + // End time of the previous composition + this.compositionEndedAt = 0; + // Used in a kludge to detect when an Enter keypress should be + // considered part of the composition on Safari, which fires events + // in the wrong order + this.compositionPendingKey = false; + // Used to categorize changes as part of a composition, even when + // the mutation events fire shortly after the compositionend event + this.compositionPendingChange = false; + // Set by beforeinput, used in DOM change reader + this.insertingText = ""; + this.insertingTextAt = 0; + this.mouseSelection = null; + // When a drag from the editor is active, this points at the range + // being dragged. + this.draggedContent = null; + this.handleEvent = this.handleEvent.bind(this); + this.notifiedFocused = view.hasFocus; + // On Safari adding an input event handler somehow prevents an + // issue where the composition vanishes when you press enter. + if (browser.safari) + view.contentDOM.addEventListener("input", () => null); + if (browser.gecko) + firefoxCopyCutHack(view.contentDOM.ownerDocument); + } + handleEvent(event) { + if (!eventBelongsToEditor(this.view, event) || this.ignoreDuringComposition(event)) + return; + if (event.type == "keydown" && this.keydown(event)) + return; + if (this.view.updateState != 0 /* UpdateState.Idle */) + Promise.resolve().then(() => this.runHandlers(event.type, event)); + else + this.runHandlers(event.type, event); + } + runHandlers(type, event) { + let handlers = this.handlers[type]; + if (handlers) { + for (let observer of handlers.observers) + observer(this.view, event); + for (let handler of handlers.handlers) { + if (event.defaultPrevented) + break; + if (handler(this.view, event)) { + event.preventDefault(); + break; + } + } + } + } + ensureHandlers(plugins) { + let handlers = computeHandlers(plugins), prev = this.handlers, dom = this.view.contentDOM; + for (let type in handlers) + if (type != "scroll") { + let passive = !handlers[type].handlers.length; + let exists = prev[type]; + if (exists && passive != !exists.handlers.length) { + dom.removeEventListener(type, this.handleEvent); + exists = null; + } + if (!exists) + dom.addEventListener(type, this.handleEvent, { passive }); + } + for (let type in prev) + if (type != "scroll" && !handlers[type]) + dom.removeEventListener(type, this.handleEvent); + this.handlers = handlers; + } + keydown(event) { + // Must always run, even if a custom handler handled the event + this.lastKeyCode = event.keyCode; + this.lastKeyTime = Date.now(); + if (event.keyCode == 9 && this.tabFocusMode > -1 && (!this.tabFocusMode || Date.now() <= this.tabFocusMode)) + return true; + if (this.tabFocusMode > 0 && event.keyCode != 27 && modifierCodes.indexOf(event.keyCode) < 0) + this.tabFocusMode = -1; + // Chrome for Android usually doesn't fire proper key events, but + // occasionally does, usually surrounded by a bunch of complicated + // composition changes. When an enter or backspace key event is + // seen, hold off on handling DOM events for a bit, and then + // dispatch it. + if (browser.android && browser.chrome && !event.synthetic && + (event.keyCode == 13 || event.keyCode == 8)) { + this.view.observer.delayAndroidKey(event.key, event.keyCode); + return true; + } + // Preventing the default behavior of Enter on iOS makes the + // virtual keyboard get stuck in the wrong (lowercase) + // state. So we let it go through, and then, in + // applyDOMChange, notify key handlers of it and reset to + // the state they produce. + if (browser.ios && !event.synthetic && !event.altKey && !event.metaKey && + (PendingKeys.some(key => key.keyCode == event.keyCode) && !event.ctrlKey || + EmacsyPendingKeys.indexOf(event.key) > -1 && event.ctrlKey)) { + let mods = { ctrlKey: event.ctrlKey, altKey: event.altKey, metaKey: event.metaKey, shiftKey: event.shiftKey }; + // On iOS with autocapitalize, drop the shift modifier for these + // keys, since it will be set at the start of every sentence. + if (mods.shiftKey && browser.ios && !/^(off|none)$/.test(this.view.contentDOM.autocapitalize) && + iosVirtualKeyboardOpen(this.view.win)) + mods.shiftKey = false; + this.pendingIOSKey = { key: event.key, keyCode: event.keyCode, mods }; + setTimeout(() => this.flushIOSKey(), 250); + return true; + } + if (event.keyCode != 229) + this.view.observer.forceFlush(); + return false; + } + flushIOSKey(change) { + let key = this.pendingIOSKey; + if (!key) + return false; + // This looks like an autocorrection before Enter + if (key.key == "Enter" && change && change.from < change.to && /^\S+$/.test(change.insert.toString())) + return false; + this.pendingIOSKey = undefined; + return dispatchKey(this.view.contentDOM, key.key, key.keyCode, key.mods); + } + ignoreDuringComposition(event) { + if (!/^key/.test(event.type) || event.synthetic) + return false; + if (this.composing > 0) + return true; + // See https://www.stum.de/2016/06/24/handling-ime-events-in-javascript/. + // On some input method editors (IMEs), the Enter key is used to + // confirm character selection. On Safari, when Enter is pressed, + // compositionend and keydown events are sometimes emitted in the + // wrong order. The key event should still be ignored, even when + // it happens after the compositionend event. + if (browser.safari && !browser.ios && this.compositionPendingKey && Date.now() - this.compositionEndedAt < 100) { + this.compositionPendingKey = false; + return true; + } + return false; + } + startMouseSelection(mouseSelection) { + if (this.mouseSelection) + this.mouseSelection.destroy(); + this.mouseSelection = mouseSelection; + } + update(update) { + this.view.observer.update(update); + if (this.mouseSelection) + this.mouseSelection.update(update); + if (this.draggedContent && update.docChanged) + this.draggedContent = this.draggedContent.map(update.changes); + if (update.transactions.length) + this.lastKeyCode = this.lastSelectionTime = 0; + } + destroy() { + if (this.mouseSelection) + this.mouseSelection.destroy(); + } +} +function iosVirtualKeyboardOpen(win) { + if (!win.visualViewport) + return false; + return win.visualViewport.height * win.visualViewport.scale / win.document.documentElement.clientHeight < 0.85; +} +function bindHandler(plugin, handler) { + return (view, event) => { + try { + return handler.call(plugin, event, view); + } + catch (e) { + logException(view.state, e); + } + }; +} +function computeHandlers(plugins) { + let result = Object.create(null); + function record(type) { + return result[type] || (result[type] = { observers: [], handlers: [] }); + } + for (let plugin of plugins) { + let spec = plugin.spec, handlers = spec && spec.plugin.domEventHandlers, observers = spec && spec.plugin.domEventObservers; + if (handlers) + for (let type in handlers) { + let f = handlers[type]; + if (f) + record(type).handlers.push(bindHandler(plugin.value, f)); + } + if (observers) + for (let type in observers) { + let f = observers[type]; + if (f) + record(type).observers.push(bindHandler(plugin.value, f)); + } + } + for (let type in handlers) + record(type).handlers.push(handlers[type]); + for (let type in observers) + record(type).observers.push(observers[type]); + return result; +} +const PendingKeys = [ + { key: "Backspace", keyCode: 8, inputType: "deleteContentBackward" }, + { key: "Enter", keyCode: 13, inputType: "insertParagraph" }, + { key: "Enter", keyCode: 13, inputType: "insertLineBreak" }, + { key: "Delete", keyCode: 46, inputType: "deleteContentForward" } +]; +const EmacsyPendingKeys = "dthko"; +// Key codes for modifier keys +const modifierCodes = [16, 17, 18, 20, 91, 92, 224, 225]; +const dragScrollMargin = 6; +function dragScrollSpeed(dist) { + return Math.max(0, dist) * 0.7 + 8; +} +function dist(a, b) { + return Math.max(Math.abs(a.clientX - b.clientX), Math.abs(a.clientY - b.clientY)); +} +class MouseSelection { + constructor(view, startEvent, style, mustSelect) { + this.view = view; + this.startEvent = startEvent; + this.style = style; + this.mustSelect = mustSelect; + this.scrollSpeed = { x: 0, y: 0 }; + this.scrolling = -1; + this.lastEvent = startEvent; + this.scrollParents = scrollableParents(view.contentDOM); + this.atoms = view.state.facet(atomicRanges).map(f => f(view)); + let doc = view.contentDOM.ownerDocument; + doc.addEventListener("mousemove", this.move = this.move.bind(this)); + doc.addEventListener("mouseup", this.up = this.up.bind(this)); + this.extend = startEvent.shiftKey; + this.multiple = view.state.facet(state.EditorState.allowMultipleSelections) && addsSelectionRange(view, startEvent); + this.dragging = isInPrimarySelection(view, startEvent) && getClickType(startEvent) == 1 ? null : false; + } + start(event) { + // When clicking outside of the selection, immediately apply the + // effect of starting the selection + if (this.dragging === false) + this.select(event); + } + move(event) { + if (event.buttons == 0) + return this.destroy(); + if (this.dragging || this.dragging == null && dist(this.startEvent, event) < 10) + return; + this.select(this.lastEvent = event); + let sx = 0, sy = 0; + let left = 0, top = 0, right = this.view.win.innerWidth, bottom = this.view.win.innerHeight; + if (this.scrollParents.x) + ({ left, right } = this.scrollParents.x.getBoundingClientRect()); + if (this.scrollParents.y) + ({ top, bottom } = this.scrollParents.y.getBoundingClientRect()); + let margins = getScrollMargins(this.view); + if (event.clientX - margins.left <= left + dragScrollMargin) + sx = -dragScrollSpeed(left - event.clientX); + else if (event.clientX + margins.right >= right - dragScrollMargin) + sx = dragScrollSpeed(event.clientX - right); + if (event.clientY - margins.top <= top + dragScrollMargin) + sy = -dragScrollSpeed(top - event.clientY); + else if (event.clientY + margins.bottom >= bottom - dragScrollMargin) + sy = dragScrollSpeed(event.clientY - bottom); + this.setScrollSpeed(sx, sy); + } + up(event) { + if (this.dragging == null) + this.select(this.lastEvent); + if (!this.dragging) + event.preventDefault(); + this.destroy(); + } + destroy() { + this.setScrollSpeed(0, 0); + let doc = this.view.contentDOM.ownerDocument; + doc.removeEventListener("mousemove", this.move); + doc.removeEventListener("mouseup", this.up); + this.view.inputState.mouseSelection = this.view.inputState.draggedContent = null; + } + setScrollSpeed(sx, sy) { + this.scrollSpeed = { x: sx, y: sy }; + if (sx || sy) { + if (this.scrolling < 0) + this.scrolling = setInterval(() => this.scroll(), 50); + } + else if (this.scrolling > -1) { + clearInterval(this.scrolling); + this.scrolling = -1; + } + } + scroll() { + let { x, y } = this.scrollSpeed; + if (x && this.scrollParents.x) { + this.scrollParents.x.scrollLeft += x; + x = 0; + } + if (y && this.scrollParents.y) { + this.scrollParents.y.scrollTop += y; + y = 0; + } + if (x || y) + this.view.win.scrollBy(x, y); + if (this.dragging === false) + this.select(this.lastEvent); + } + select(event) { + let { view } = this, selection = skipAtomsForSelection(this.atoms, this.style.get(event, this.extend, this.multiple)); + if (this.mustSelect || !selection.eq(view.state.selection, this.dragging === false)) + this.view.dispatch({ + selection, + userEvent: "select.pointer" + }); + this.mustSelect = false; + } + update(update) { + if (update.transactions.some(tr => tr.isUserEvent("input.type"))) + this.destroy(); + else if (this.style.update(update)) + setTimeout(() => this.select(this.lastEvent), 20); + } +} +function addsSelectionRange(view, event) { + let facet = view.state.facet(clickAddsSelectionRange); + return facet.length ? facet[0](event) : browser.mac ? event.metaKey : event.ctrlKey; +} +function dragMovesSelection(view, event) { + let facet = view.state.facet(dragMovesSelection$1); + return facet.length ? facet[0](event) : browser.mac ? !event.altKey : !event.ctrlKey; +} +function isInPrimarySelection(view, event) { + let { main } = view.state.selection; + if (main.empty) + return false; + // On boundary clicks, check whether the coordinates are inside the + // selection's client rectangles + let sel = getSelection(view.root); + if (!sel || sel.rangeCount == 0) + return true; + let rects = sel.getRangeAt(0).getClientRects(); + for (let i = 0; i < rects.length; i++) { + let rect = rects[i]; + if (rect.left <= event.clientX && rect.right >= event.clientX && + rect.top <= event.clientY && rect.bottom >= event.clientY) + return true; + } + return false; +} +function eventBelongsToEditor(view, event) { + if (!event.bubbles) + return true; + if (event.defaultPrevented) + return false; + for (let node = event.target, tile; node != view.contentDOM; node = node.parentNode) + if (!node || node.nodeType == 11 || + ((tile = Tile.get(node)) && tile.isWidget() && !tile.isHidden && tile.widget.ignoreEvent(event))) + return false; + return true; +} +const handlers = Object.create(null); +const observers = Object.create(null); +// This is very crude, but unfortunately both these browsers _pretend_ +// that they have a clipboard API—all the objects and methods are +// there, they just don't work, and they are hard to test. +const brokenClipboardAPI = (browser.ie && browser.ie_version < 15) || + (browser.ios && browser.webkit_version < 604); +function capturePaste(view) { + let parent = view.dom.parentNode; + if (!parent) + return; + let target = parent.appendChild(document.createElement("textarea")); + target.style.cssText = "position: fixed; left: -10000px; top: 10px"; + target.focus(); + setTimeout(() => { + view.focus(); + target.remove(); + doPaste(view, target.value); + }, 50); +} +function textFilter(state, facet, text) { + for (let filter of state.facet(facet)) + text = filter(text, state); + return text; +} +function doPaste(view, input) { + input = textFilter(view.state, clipboardInputFilter, input); + let { state: state$1 } = view, changes, i = 1, text = state$1.toText(input); + let byLine = text.lines == state$1.selection.ranges.length; + let linewise = lastLinewiseCopy != null && state$1.selection.ranges.every(r => r.empty) && lastLinewiseCopy == text.toString(); + if (linewise) { + let lastLine = -1; + changes = state$1.changeByRange(range => { + let line = state$1.doc.lineAt(range.from); + if (line.from == lastLine) + return { range }; + lastLine = line.from; + let insert = state$1.toText((byLine ? text.line(i++).text : input) + state$1.lineBreak); + return { changes: { from: line.from, insert }, + range: state.EditorSelection.cursor(range.from + insert.length) }; + }); + } + else if (byLine) { + changes = state$1.changeByRange(range => { + let line = text.line(i++); + return { changes: { from: range.from, to: range.to, insert: line.text }, + range: state.EditorSelection.cursor(range.from + line.length) }; + }); + } + else { + changes = state$1.replaceSelection(text); + } + view.dispatch(changes, { + userEvent: "input.paste", + scrollIntoView: true + }); +} +observers.scroll = view => { + let iState = view.inputState; + iState.lastScrollTop = view.scrollDOM.scrollTop; + iState.lastScrollLeft = view.scrollDOM.scrollLeft; + if (browser.ios && !iState.touchActive) + iState.lastIOSMomentumScroll = Date.now(); +}; +observers.wheel = observers.mousewheel = view => { + view.inputState.lastWheelEvent = Date.now(); +}; +handlers.keydown = (view, event) => { + view.inputState.setSelectionOrigin("select"); + if (event.keyCode == 27 && view.inputState.tabFocusMode != 0) + view.inputState.tabFocusMode = Date.now() + 2000; + return false; +}; +observers.touchstart = (view, e) => { + let iState = view.inputState, touch = e.targetTouches[0]; + iState.touchActive = true; + iState.lastTouchTime = Date.now(); + if (touch) { + iState.lastTouchX = touch.clientX; + iState.lastTouchY = touch.clientY; + } + iState.setSelectionOrigin("select.pointer"); +}; +observers.touchmove = view => { + view.inputState.lastTouchTime = Date.now(); + view.inputState.setSelectionOrigin("select.pointer"); +}; +observers.touchend = (view, e) => { + view.inputState.touchActive = false; + view.inputState.lastTouchTime = Date.now(); +}; +handlers.mousedown = (view, event) => { + view.observer.flush(); + if (view.inputState.lastTouchTime > Date.now() - 2000) + return false; // Ignore touch interaction + let style = null; + for (let makeStyle of view.state.facet(mouseSelectionStyle)) { + style = makeStyle(view, event); + if (style) + break; + } + if (!style && event.button == 0) + style = basicMouseSelection(view, event); + if (style) { + let mustFocus = !view.hasFocus; + view.inputState.startMouseSelection(new MouseSelection(view, event, style, mustFocus)); + if (mustFocus) + view.observer.ignore(() => { + focusPreventScroll(view.contentDOM); + let active = view.root.activeElement; + if (active && !active.contains(view.contentDOM)) + active.blur(); + }); + let mouseSel = view.inputState.mouseSelection; + if (mouseSel) { + mouseSel.start(event); + return mouseSel.dragging === false; + } + } + else { + view.inputState.setSelectionOrigin("select.pointer"); + } + return false; +}; +function rangeForClick(view, pos, bias, type) { + if (type == 1) { // Single click + return state.EditorSelection.cursor(pos, bias); + } + else if (type == 2) { // Double click + return groupAt(view.state, pos, bias); + } + else { // Triple click + let visual = view.docView.lineAt(pos, bias), line = view.state.doc.lineAt(visual ? visual.posAtEnd : pos); + let from = visual ? visual.posAtStart : line.from, to = visual ? visual.posAtEnd : line.to; + if (to < view.state.doc.length && to == line.to) + to++; + return state.EditorSelection.undirectionalRange(from, to); + } +} +const BadMouseDetail = browser.ie && browser.ie_version <= 11; +let lastMouseDown = null, lastMouseDownCount = 0, lastMouseDownTime = 0; +function getClickType(event) { + if (!BadMouseDetail) + return event.detail; + let last = lastMouseDown, lastTime = lastMouseDownTime; + lastMouseDown = event; + lastMouseDownTime = Date.now(); + return lastMouseDownCount = !last || (lastTime > Date.now() - 400 && Math.abs(last.clientX - event.clientX) < 2 && + Math.abs(last.clientY - event.clientY) < 2) ? (lastMouseDownCount + 1) % 3 : 1; +} +function basicMouseSelection(view, event) { + let start = view.posAndSideAtCoords({ x: event.clientX, y: event.clientY }, false), type = getClickType(event); + let startSel = view.state.selection; + return { + update(update) { + if (update.docChanged) { + start.pos = update.changes.mapPos(start.pos); + startSel = startSel.map(update.changes); + } + }, + get(event, extend, multiple) { + let cur = view.posAndSideAtCoords({ x: event.clientX, y: event.clientY }, false), removed; + let range = rangeForClick(view, cur.pos, cur.assoc, type); + if (start.pos != cur.pos && !extend) { + let startRange = rangeForClick(view, start.pos, start.assoc, type); + let from = Math.min(startRange.from, range.from), to = Math.max(startRange.to, range.to); + range = from < range.from ? state.EditorSelection.range(from, to, range.assoc) : state.EditorSelection.range(to, from, range.assoc); + } + if (extend) + return startSel.replaceRange(startSel.main.extend(range.from, range.to, range.assoc)); + else if (multiple && type == 1 && startSel.ranges.length > 1 && (removed = removeRangeAround(startSel, cur.pos))) + return removed; + else if (multiple) + return startSel.addRange(range); + else + return state.EditorSelection.create([range]); + } + }; +} +function removeRangeAround(sel, pos) { + for (let i = 0; i < sel.ranges.length; i++) { + let { from, to } = sel.ranges[i]; + if (from <= pos && to >= pos) + return state.EditorSelection.create(sel.ranges.slice(0, i).concat(sel.ranges.slice(i + 1)), sel.mainIndex == i ? 0 : sel.mainIndex - (sel.mainIndex > i ? 1 : 0)); + } + return null; +} +handlers.dragstart = (view, event) => { + let { selection: { main: range } } = view.state; + if (event.target.draggable) { + let tile = view.docView.tile.nearest(event.target); + if (tile && tile.isWidget()) { + let from = tile.posAtStart, to = from + tile.length; + if (from >= range.to || to <= range.from) + range = state.EditorSelection.undirectionalRange(from, to); + } + } + let { inputState } = view; + if (inputState.mouseSelection) + inputState.mouseSelection.dragging = true; + inputState.draggedContent = range; + if (event.dataTransfer) { + event.dataTransfer.setData("Text", textFilter(view.state, clipboardOutputFilter, view.state.sliceDoc(range.from, range.to))); + event.dataTransfer.effectAllowed = "copyMove"; + } + return false; +}; +handlers.dragend = view => { + view.inputState.draggedContent = null; + return false; +}; +function dropText(view, event, text, direct) { + text = textFilter(view.state, clipboardInputFilter, text); + if (!text) + return; + let dropPos = view.posAtCoords({ x: event.clientX, y: event.clientY }, false); + let { draggedContent } = view.inputState; + let del = direct && draggedContent && dragMovesSelection(view, event) + ? { from: draggedContent.from, to: draggedContent.to } : null; + let ins = { from: dropPos, insert: text }; + let changes = view.state.changes(del ? [del, ins] : ins); + view.focus(); + view.dispatch({ + changes, + selection: { anchor: changes.mapPos(dropPos, -1), head: changes.mapPos(dropPos, 1) }, + userEvent: del ? "move.drop" : "input.drop" + }); + view.inputState.draggedContent = null; +} +handlers.drop = (view, event) => { + if (!event.dataTransfer) + return false; + if (view.state.readOnly) + return true; + let files = event.dataTransfer.files; + if (files && files.length) { // For a file drop, read the file's text. + let text = Array(files.length), read = 0; + let finishFile = () => { + if (++read == files.length) + dropText(view, event, text.filter(s => s != null).join(view.state.lineBreak), false); + }; + for (let i = 0; i < files.length; i++) { + let reader = new FileReader; + reader.onerror = finishFile; + reader.onload = () => { + if (!/[\x00-\x08\x0e-\x1f]{2}/.test(reader.result)) + text[i] = reader.result; + finishFile(); + }; + reader.readAsText(files[i]); + } + return true; + } + else { + let text = event.dataTransfer.getData("Text"); + if (text) { + dropText(view, event, text, true); + return true; + } + } + return false; +}; +handlers.paste = (view, event) => { + if (view.state.readOnly) + return true; + view.observer.flush(); + let data = brokenClipboardAPI ? null : event.clipboardData; + if (data) { + doPaste(view, data.getData("text/plain") || data.getData("text/uri-list")); + return true; + } + else { + capturePaste(view); + return false; + } +}; +function captureCopy(view, text) { + // The extra wrapper is somehow necessary on IE/Edge to prevent the + // content from being mangled when it is put onto the clipboard + let parent = view.dom.parentNode; + if (!parent) + return; + let target = parent.appendChild(document.createElement("textarea")); + target.style.cssText = "position: fixed; left: -10000px; top: 10px"; + target.value = text; + target.focus(); + target.selectionEnd = text.length; + target.selectionStart = 0; + setTimeout(() => { + target.remove(); + view.focus(); + }, 50); +} +function copiedRange(state) { + let content = [], ranges = [], linewise = false; + for (let range of state.selection.ranges) + if (!range.empty) { + content.push(state.sliceDoc(range.from, range.to)); + ranges.push(range); + } + if (!content.length) { + // Nothing selected, do a line-wise copy + let upto = -1; + for (let { from } of state.selection.ranges) { + let line = state.doc.lineAt(from); + if (line.number > upto) { + content.push(line.text); + ranges.push({ from: line.from, to: Math.min(state.doc.length, line.to + 1) }); + } + upto = line.number; + } + linewise = true; + } + return { text: textFilter(state, clipboardOutputFilter, content.join(state.lineBreak)), ranges, linewise }; +} +let lastLinewiseCopy = null; +handlers.copy = handlers.cut = (view, event) => { + // If the DOM selection is outside this editor, don't intercept. + // This happens when a parent editor (like ProseMirror) selects content that + // spans multiple elements including this CodeMirror. The copy event may + // bubble through CodeMirror (e.g. when CodeMirror is the first or the last + // element in the selection), but we should let the parent handle it. + if (!hasSelection(view.contentDOM, view.observer.selectionRange)) + return false; + let { text, ranges, linewise } = copiedRange(view.state); + if (!text && !linewise) + return false; + lastLinewiseCopy = linewise ? text : null; + if (event.type == "cut" && !view.state.readOnly) + view.dispatch({ + changes: ranges, + scrollIntoView: true, + userEvent: "delete.cut" + }); + let data = brokenClipboardAPI ? null : event.clipboardData; + if (data) { + data.clearData(); + data.setData("text/plain", text); + return true; + } + else { + captureCopy(view, text); + return false; + } +}; +const isFocusChange = state.Annotation.define(); +function focusChangeTransaction(state, focus) { + let effects = []; + for (let getEffect of state.facet(focusChangeEffect)) { + let effect = getEffect(state, focus); + if (effect) + effects.push(effect); + } + return effects.length ? state.update({ effects, annotations: isFocusChange.of(true) }) : null; +} +function updateForFocusChange(view) { + setTimeout(() => { + let focus = view.hasFocus; + if (focus != view.inputState.notifiedFocused) { + let tr = focusChangeTransaction(view.state, focus); + if (tr) + view.dispatch(tr); + else + view.update([]); + } + }, 10); +} +observers.focus = view => { + view.inputState.lastFocusTime = Date.now(); + // When focusing reset the scroll position, move it back to where it was + if (!view.scrollDOM.scrollTop && (view.inputState.lastScrollTop || view.inputState.lastScrollLeft)) { + view.scrollDOM.scrollTop = view.inputState.lastScrollTop; + view.scrollDOM.scrollLeft = view.inputState.lastScrollLeft; + } + updateForFocusChange(view); +}; +observers.blur = view => { + view.observer.clearSelectionRange(); + updateForFocusChange(view); +}; +observers.compositionstart = observers.compositionupdate = view => { + if (view.observer.editContext) + return; // Composition handled by edit context + if (view.inputState.compositionFirstChange == null) + view.inputState.compositionFirstChange = true; + if (view.inputState.composing < 0) { + // FIXME possibly set a timeout to clear it again on Android + view.inputState.composing = 0; + } +}; +observers.compositionend = view => { + if (view.observer.editContext) + return; // Composition handled by edit context + view.inputState.composing = -1; + view.inputState.compositionEndedAt = Date.now(); + view.inputState.compositionPendingKey = true; + view.inputState.compositionPendingChange = view.observer.pendingRecords().length > 0; + view.inputState.compositionFirstChange = null; + if (browser.chrome && browser.android) { + // Delay flushing for a bit on Android because it'll often fire a + // bunch of contradictory changes in a row at end of composition + view.observer.flushSoon(); + } + else if (view.inputState.compositionPendingChange) { + // If we found pending records, schedule a flush. + Promise.resolve().then(() => view.observer.flush()); + } + else { + // Otherwise, make sure that, if no changes come in soon, the + // composition view is cleared. + setTimeout(() => { + if (view.inputState.composing < 0 && view.docView.hasComposition) + view.update([]); + }, 50); + } +}; +observers.contextmenu = view => { + view.inputState.lastContextMenu = Date.now(); +}; +handlers.beforeinput = (view, event) => { + var _a, _b; + if (event.inputType == "insertText" || event.inputType == "insertCompositionText") { + view.inputState.insertingText = event.data; + view.inputState.insertingTextAt = Date.now(); + } + // In EditContext mode, we must handle insertReplacementText events + // directly, to make spell checking corrections work + if (event.inputType == "insertReplacementText" && view.observer.editContext) { + let text = (_a = event.dataTransfer) === null || _a === void 0 ? void 0 : _a.getData("text/plain"), ranges = event.getTargetRanges(); + if (text && ranges.length) { + let r = ranges[0]; + let from = view.posAtDOM(r.startContainer, r.startOffset), to = view.posAtDOM(r.endContainer, r.endOffset); + applyDOMChangeInner(view, { from, to, insert: view.state.toText(text) }, null); + return true; + } + } + // Because Chrome Android doesn't fire useful key events, use + // beforeinput to detect backspace (and possibly enter and delete, + // but those usually don't even seem to fire beforeinput events at + // the moment) and fake a key event for it. + // + // (preventDefault on beforeinput, though supported in the spec, + // seems to do nothing at all on Chrome). + let pending; + if (browser.chrome && browser.android && (pending = PendingKeys.find(key => key.inputType == event.inputType))) { + view.observer.delayAndroidKey(pending.key, pending.keyCode); + // Acode's scrolling mode preserves keyboard dismissal. Do not schedule + // a delayed focus cycle that could undo an Android Back press. + if (!view.state.facet(controlledTouchScroll) && + (pending.key == "Backspace" || pending.key == "Delete")) { + let startViewHeight = ((_b = window.visualViewport) === null || _b === void 0 ? void 0 : _b.height) || 0; + setTimeout(() => { + var _a; + // Backspacing near uneditable nodes on Chrome Android sometimes + // closes the virtual keyboard. This tries to crudely detect + // that and refocus to get it back. + if ((((_a = window.visualViewport) === null || _a === void 0 ? void 0 : _a.height) || 0) > startViewHeight + 10 && view.hasFocus) { + view.contentDOM.blur(); + view.focus(); + } + }, 100); + } + } + if (browser.ios && event.inputType == "deleteContentForward") { + // For some reason, DOM changes (and beforeinput) happen _before_ + // the key event for ctrl-d on iOS when using an external + // keyboard. + view.observer.flushSoon(); + } + // Safari will occasionally forget to fire compositionend at the end of a dead-key composition + if (browser.safari && event.inputType == "insertText" && view.inputState.composing >= 0) { + setTimeout(() => observers.compositionend(view, event), 20); + } + return false; +}; +const appliedFirefoxHack = new Set; +// In Firefox, when cut/copy handlers are added to the document, that +// somehow avoids a bug where those events aren't fired when the +// selection is empty. See issue #1082 and +// https://bugzilla.mozilla.org/show_bug.cgi?id=995961 +function firefoxCopyCutHack(doc) { + if (!appliedFirefoxHack.has(doc)) { + appliedFirefoxHack.add(doc); + doc.addEventListener("copy", () => { }); + doc.addEventListener("cut", () => { }); + } +} + +const wrappingWhiteSpace = ["pre-wrap", "normal", "pre-line", "break-spaces"]; +// Used to track, during updateHeight, if any actual heights changed +let heightChangeFlag = false; +function clearHeightChangeFlag() { heightChangeFlag = false; } +class HeightOracle { + constructor(lineWrapping) { + this.lineWrapping = lineWrapping; + this.doc = state.Text.empty; + this.heightSamples = {}; + this.lineHeight = 14; // The height of an entire line (line-height) + this.charWidth = 7; + this.textHeight = 14; // The height of the actual font (font-size) + this.lineLength = 30; + } + heightForGap(from, to) { + let lines = this.doc.lineAt(to).number - this.doc.lineAt(from).number + 1; + if (this.lineWrapping) + lines += Math.max(0, Math.ceil(((to - from) - (lines * this.lineLength * 0.5)) / this.lineLength)); + return this.lineHeight * lines; + } + heightForLine(length) { + if (!this.lineWrapping) + return this.lineHeight; + let lines = 1 + Math.max(0, Math.ceil((length - this.lineLength) / Math.max(1, this.lineLength - 5))); + return lines * this.lineHeight; + } + setDoc(doc) { this.doc = doc; return this; } + mustRefreshForWrapping(whiteSpace) { + return (wrappingWhiteSpace.indexOf(whiteSpace) > -1) != this.lineWrapping; + } + mustRefreshForHeights(lineHeights) { + let newHeight = false; + for (let i = 0; i < lineHeights.length; i++) { + let h = lineHeights[i]; + if (h < 0) { + i++; + } + else if (!this.heightSamples[Math.floor(h * 10)]) { // Round to .1 pixels + newHeight = true; + this.heightSamples[Math.floor(h * 10)] = true; + } + } + return newHeight; + } + refresh(whiteSpace, lineHeight, charWidth, textHeight, lineLength, knownHeights) { + let lineWrapping = wrappingWhiteSpace.indexOf(whiteSpace) > -1; + let changed = Math.abs(lineHeight - this.lineHeight) > 0.3 || this.lineWrapping != lineWrapping; + this.lineWrapping = lineWrapping; + this.lineHeight = lineHeight; + this.charWidth = charWidth; + this.textHeight = textHeight; + this.lineLength = lineLength; + if (changed) { + this.heightSamples = {}; + for (let i = 0; i < knownHeights.length; i++) { + let h = knownHeights[i]; + if (h < 0) + i++; + else + this.heightSamples[Math.floor(h * 10)] = true; + } + } + return changed; + } +} +// This object is used by `updateHeight` to make DOM measurements +// arrive at the right nodes. The `heights` array is a sequence of +// block heights, starting from position `from`. +class MeasuredHeights { + constructor(from, heights) { + this.from = from; + this.heights = heights; + this.index = 0; + } + get more() { return this.index < this.heights.length; } +} +/** +Record used to represent information about a block-level element +in the editor view. +*/ +class BlockInfo { + /** + @internal + */ + constructor( + /** + The start of the element in the document. + */ + from, + /** + The length of the element. + */ + length, + /** + The top position of the element (relative to the top of the + document). + */ + top, + /** + Its height. + */ + height, + /** + @internal Weird packed field that holds an array of children + for composite blocks, a decoration for block widgets, and a + number indicating the amount of widget-created line breaks for + text blocks. + */ + _content) { + this.from = from; + this.length = length; + this.top = top; + this.height = height; + this._content = _content; + } + /** + The type of element this is. When querying lines, this may be + an array of all the blocks that make up the line. + */ + get type() { + return typeof this._content == "number" ? exports.BlockType.Text : + Array.isArray(this._content) ? this._content : this._content.type; + } + /** + The end of the element as a document position. + */ + get to() { return this.from + this.length; } + /** + The bottom position of the element. + */ + get bottom() { return this.top + this.height; } + /** + If this is a widget block, this will return the widget + associated with it. + */ + get widget() { + return this._content instanceof PointDecoration ? this._content.widget : null; + } + /** + If this is a textblock, this holds the number of line breaks + that appear in widgets inside the block. + */ + get widgetLineBreaks() { + return typeof this._content == "number" ? this._content : 0; + } + /** + @internal + */ + join(other) { + let content = (Array.isArray(this._content) ? this._content : [this]) + .concat(Array.isArray(other._content) ? other._content : [other]); + return new BlockInfo(this.from, this.length + other.length, this.top, this.height + other.height, content); + } +} +var QueryType; +(function (QueryType) { + QueryType[QueryType["ByPos"] = 0] = "ByPos"; + QueryType[QueryType["ByHeight"] = 1] = "ByHeight"; + QueryType[QueryType["ByPosNoHeight"] = 2] = "ByPosNoHeight"; +})(QueryType || (QueryType = {})); +const Epsilon = 1e-3; +class HeightMap { + constructor(length, // The number of characters covered + height, // Height of this part of the document + flags = 2 /* Flag.Outdated */) { + this.length = length; + this.height = height; + this.flags = flags; + } + get outdated() { return (this.flags & 2 /* Flag.Outdated */) > 0; } + set outdated(value) { this.flags = (value ? 2 /* Flag.Outdated */ : 0) | (this.flags & -3 /* Flag.Outdated */); } + setHeight(height) { + if (this.height != height) { + if (Math.abs(this.height - height) > Epsilon) + heightChangeFlag = true; + this.height = height; + } + } + // Base case is to replace a leaf node, which simply builds a tree + // from the new nodes and returns that (HeightMapBranch and + // HeightMapGap override this to actually use from/to) + replace(_from, _to, nodes) { + return HeightMap.of(nodes); + } + // Again, these are base cases, and are overridden for branch and gap nodes. + decomposeLeft(_to, result) { result.push(this); } + decomposeRight(_from, result) { result.push(this); } + applyChanges(decorations, oldDoc, oracle, changes) { + let me = this, doc = oracle.doc; + for (let i = changes.length - 1; i >= 0; i--) { + let { fromA, toA, fromB, toB } = changes[i]; + let start = me.lineAt(fromA, QueryType.ByPosNoHeight, oracle.setDoc(oldDoc), 0, 0); + let end = start.to >= toA ? start : me.lineAt(toA, QueryType.ByPosNoHeight, oracle, 0, 0); + toB += end.to - toA; + toA = end.to; + while (i > 0 && start.from <= changes[i - 1].toA) { + fromA = changes[i - 1].fromA; + fromB = changes[i - 1].fromB; + i--; + if (fromA < start.from) + start = me.lineAt(fromA, QueryType.ByPosNoHeight, oracle, 0, 0); + } + fromB += start.from - fromA; + fromA = start.from; + let nodes = NodeBuilder.build(oracle.setDoc(doc), decorations, fromB, toB); + me = replace(me, me.replace(fromA, toA, nodes)); + } + return me.updateHeight(oracle, 0); + } + static empty() { return new HeightMapText(0, 0, 0); } + // nodes uses null values to indicate the position of line breaks. + // There are never line breaks at the start or end of the array, or + // two line breaks next to each other, and the array isn't allowed + // to be empty (same restrictions as return value from the builder). + static of(nodes) { + if (nodes.length == 1) + return nodes[0]; + let i = 0, j = nodes.length, before = 0, after = 0; + for (;;) { + if (i == j) { + if (before > after * 2) { + let split = nodes[i - 1]; + if (split.break) + nodes.splice(--i, 1, split.left, null, split.right); + else + nodes.splice(--i, 1, split.left, split.right); + j += 1 + split.break; + before -= split.size; + } + else if (after > before * 2) { + let split = nodes[j]; + if (split.break) + nodes.splice(j, 1, split.left, null, split.right); + else + nodes.splice(j, 1, split.left, split.right); + j += 2 + split.break; + after -= split.size; + } + else { + break; + } + } + else if (before < after) { + let next = nodes[i++]; + if (next) + before += next.size; + } + else { + let next = nodes[--j]; + if (next) + after += next.size; + } + } + let brk = 0; + if (nodes[i - 1] == null) { + brk = 1; + i--; + } + else if (nodes[i] == null) { + brk = 1; + j++; + } + return new HeightMapBranch(HeightMap.of(nodes.slice(0, i)), brk, HeightMap.of(nodes.slice(j))); + } +} +function replace(old, val) { + if (old == val) + return old; + if (old.constructor != val.constructor) + heightChangeFlag = true; + return val; +} +HeightMap.prototype.size = 1; +const SpaceDeco = Decoration.replace({}); +class HeightMapBlock extends HeightMap { + constructor(length, height, deco) { + super(length, height); + this.deco = deco; + this.spaceAbove = 0; + } + mainBlock(top, offset) { + return new BlockInfo(offset, this.length, top + this.spaceAbove, this.height - this.spaceAbove, this.deco || 0); + } + blockAt(height, _oracle, top, offset) { + return this.spaceAbove && height < top + this.spaceAbove ? new BlockInfo(offset, 0, top, this.spaceAbove, SpaceDeco) + : this.mainBlock(top, offset); + } + lineAt(_value, _type, oracle, top, offset) { + let main = this.mainBlock(top, offset); + return this.spaceAbove ? this.blockAt(0, oracle, top, offset).join(main) : main; + } + forEachLine(from, to, oracle, top, offset, f) { + if (from <= offset + this.length && to >= offset) + f(this.lineAt(0, QueryType.ByPos, oracle, top, offset)); + } + setMeasuredHeight(measured) { + let next = measured.heights[measured.index++]; + if (next < 0) { + this.spaceAbove = -next; + next = measured.heights[measured.index++]; + } + else { + this.spaceAbove = 0; + } + this.setHeight(next); + } + updateHeight(oracle, offset = 0, _force = false, measured) { + if (measured && measured.from <= offset && measured.more) + this.setMeasuredHeight(measured); + this.outdated = false; + return this; + } + toString() { return `block(${this.length})`; } +} +class HeightMapText extends HeightMapBlock { + constructor(length, height, above) { + super(length, height, null); + this.collapsed = 0; // Amount of collapsed content in the line + this.widgetHeight = 0; // Maximum inline widget height + this.breaks = 0; // Number of widget-introduced line breaks on the line + this.spaceAbove = above; + } + mainBlock(top, offset) { + return new BlockInfo(offset, this.length, top + this.spaceAbove, this.height - this.spaceAbove, this.breaks); + } + replace(_from, _to, nodes) { + let node = nodes[0]; + if (nodes.length == 1 && (node instanceof HeightMapText || node instanceof HeightMapGap && (node.flags & 4 /* Flag.SingleLine */)) && + Math.abs(this.length - node.length) < 10) { + if (node instanceof HeightMapGap) + node = new HeightMapText(node.length, this.height, this.spaceAbove); + else + node.height = this.height; + if (!this.outdated) + node.outdated = false; + return node; + } + else { + return HeightMap.of(nodes); + } + } + updateHeight(oracle, offset = 0, force = false, measured) { + if (measured && measured.from <= offset && measured.more) { + this.setMeasuredHeight(measured); + } + else if (force || this.outdated) { + this.spaceAbove = 0; + this.setHeight(Math.max(this.widgetHeight, oracle.heightForLine(this.length - this.collapsed)) + + this.breaks * oracle.lineHeight); + } + this.outdated = false; + return this; + } + toString() { + return `line(${this.length}${this.collapsed ? -this.collapsed : ""}${this.widgetHeight ? ":" + this.widgetHeight : ""})`; + } +} +class HeightMapGap extends HeightMap { + constructor(length) { super(length, 0); } + heightMetrics(oracle, offset) { + let firstLine = oracle.doc.lineAt(offset).number, lastLine = oracle.doc.lineAt(offset + this.length).number; + let lines = lastLine - firstLine + 1; + let perLine, perChar = 0; + if (oracle.lineWrapping) { + let totalPerLine = Math.min(this.height, oracle.lineHeight * lines); + perLine = totalPerLine / lines; + if (this.length > lines + 1) + perChar = (this.height - totalPerLine) / (this.length - lines - 1); + } + else { + perLine = this.height / lines; + } + return { firstLine, lastLine, perLine, perChar }; + } + blockAt(height, oracle, top, offset) { + let { firstLine, lastLine, perLine, perChar } = this.heightMetrics(oracle, offset); + if (oracle.lineWrapping) { + let guess = offset + (height < oracle.lineHeight ? 0 + : Math.round(Math.max(0, Math.min(1, (height - top) / this.height)) * this.length)); + let line = oracle.doc.lineAt(guess), lineHeight = perLine + line.length * perChar; + let lineTop = Math.max(top, height - lineHeight / 2); + return new BlockInfo(line.from, line.length, lineTop, lineHeight, 0); + } + else { + let line = Math.max(0, Math.min(lastLine - firstLine, Math.floor((height - top) / perLine))); + let { from, length } = oracle.doc.line(firstLine + line); + return new BlockInfo(from, length, top + perLine * line, perLine, 0); + } + } + lineAt(value, type, oracle, top, offset) { + if (type == QueryType.ByHeight) + return this.blockAt(value, oracle, top, offset); + if (type == QueryType.ByPosNoHeight) { + let { from, to } = oracle.doc.lineAt(value); + return new BlockInfo(from, to - from, 0, 0, 0); + } + let { firstLine, perLine, perChar } = this.heightMetrics(oracle, offset); + let line = oracle.doc.lineAt(value), lineHeight = perLine + line.length * perChar; + let linesAbove = line.number - firstLine; + let lineTop = top + perLine * linesAbove + perChar * (line.from - offset - linesAbove); + return new BlockInfo(line.from, line.length, Math.max(top, Math.min(lineTop, top + this.height - lineHeight)), lineHeight, 0); + } + forEachLine(from, to, oracle, top, offset, f) { + from = Math.max(from, offset); + to = Math.min(to, offset + this.length); + let { firstLine, perLine, perChar } = this.heightMetrics(oracle, offset); + for (let pos = from, lineTop = top; pos <= to;) { + let line = oracle.doc.lineAt(pos); + if (pos == from) { + let linesAbove = line.number - firstLine; + lineTop += perLine * linesAbove + perChar * (from - offset - linesAbove); + } + let lineHeight = perLine + perChar * line.length; + f(new BlockInfo(line.from, line.length, lineTop, lineHeight, 0)); + lineTop += lineHeight; + pos = line.to + 1; + } + } + replace(from, to, nodes) { + let after = this.length - to; + if (after > 0) { + let last = nodes[nodes.length - 1]; + if (last instanceof HeightMapGap) + nodes[nodes.length - 1] = new HeightMapGap(last.length + after); + else + nodes.push(null, new HeightMapGap(after - 1)); + } + if (from > 0) { + let first = nodes[0]; + if (first instanceof HeightMapGap) + nodes[0] = new HeightMapGap(from + first.length); + else + nodes.unshift(new HeightMapGap(from - 1), null); + } + return HeightMap.of(nodes); + } + decomposeLeft(to, result) { + result.push(new HeightMapGap(to - 1), null); + } + decomposeRight(from, result) { + result.push(null, new HeightMapGap(this.length - from - 1)); + } + updateHeight(oracle, offset = 0, force = false, measured) { + let end = offset + this.length; + if (measured && measured.from <= offset + this.length && measured.more) { + // Fill in part of this gap with measured lines. We know there + // can't be widgets or collapsed ranges in those lines, because + // they would already have been added to the heightmap (gaps + // only contain plain text). + let nodes = [], pos = Math.max(offset, measured.from), singleHeight = -1; + if (measured.from > offset) + nodes.push(new HeightMapGap(measured.from - offset - 1).updateHeight(oracle, offset)); + while (pos <= end && measured.more) { + let len = oracle.doc.lineAt(pos).length; + if (nodes.length) + nodes.push(null); + let height = measured.heights[measured.index++], above = 0; + if (height < 0) { + above = -height; + height = measured.heights[measured.index++]; + } + if (singleHeight == -1) + singleHeight = height; + else if (Math.abs(height - singleHeight) >= Epsilon) + singleHeight = -2; + let line = new HeightMapText(len, height, above); + line.outdated = false; + nodes.push(line); + pos += len + 1; + } + if (pos <= end) + nodes.push(null, new HeightMapGap(end - pos).updateHeight(oracle, pos)); + let result = HeightMap.of(nodes); + if (singleHeight < 0 || Math.abs(result.height - this.height) >= Epsilon || + Math.abs(singleHeight - this.heightMetrics(oracle, offset).perLine) >= Epsilon) + heightChangeFlag = true; + return replace(this, result); + } + else if (force || this.outdated) { + this.setHeight(oracle.heightForGap(offset, offset + this.length)); + this.outdated = false; + } + return this; + } + toString() { return `gap(${this.length})`; } +} +class HeightMapBranch extends HeightMap { + constructor(left, brk, right) { + super(left.length + brk + right.length, left.height + right.height, brk | (left.outdated || right.outdated ? 2 /* Flag.Outdated */ : 0)); + this.left = left; + this.right = right; + this.size = left.size + right.size; + } + get break() { return this.flags & 1 /* Flag.Break */; } + blockAt(height, oracle, top, offset) { + let mid = top + this.left.height; + return height < mid ? this.left.blockAt(height, oracle, top, offset) + : this.right.blockAt(height, oracle, mid, offset + this.left.length + this.break); + } + lineAt(value, type, oracle, top, offset) { + let rightTop = top + this.left.height, rightOffset = offset + this.left.length + this.break; + let left = type == QueryType.ByHeight ? value < rightTop : value < rightOffset; + let base = left ? this.left.lineAt(value, type, oracle, top, offset) + : this.right.lineAt(value, type, oracle, rightTop, rightOffset); + if (this.break || (left ? base.to < rightOffset : base.from > rightOffset)) + return base; + let subQuery = type == QueryType.ByPosNoHeight ? QueryType.ByPosNoHeight : QueryType.ByPos; + if (left) + return base.join(this.right.lineAt(rightOffset, subQuery, oracle, rightTop, rightOffset)); + else + return this.left.lineAt(rightOffset, subQuery, oracle, top, offset).join(base); + } + forEachLine(from, to, oracle, top, offset, f) { + let rightTop = top + this.left.height, rightOffset = offset + this.left.length + this.break; + if (this.break) { + if (from < rightOffset) + this.left.forEachLine(from, to, oracle, top, offset, f); + if (to >= rightOffset) + this.right.forEachLine(from, to, oracle, rightTop, rightOffset, f); + } + else { + let mid = this.lineAt(rightOffset, QueryType.ByPos, oracle, top, offset); + if (from < mid.from) + this.left.forEachLine(from, mid.from - 1, oracle, top, offset, f); + if (mid.to >= from && mid.from <= to) + f(mid); + if (to > mid.to) + this.right.forEachLine(mid.to + 1, to, oracle, rightTop, rightOffset, f); + } + } + replace(from, to, nodes) { + let rightStart = this.left.length + this.break; + if (to < rightStart) + return this.balanced(this.left.replace(from, to, nodes), this.right); + if (from > this.left.length) + return this.balanced(this.left, this.right.replace(from - rightStart, to - rightStart, nodes)); + let result = []; + if (from > 0) + this.decomposeLeft(from, result); + let left = result.length; + for (let node of nodes) + result.push(node); + if (from > 0) + mergeGaps(result, left - 1); + if (to < this.length) { + let right = result.length; + this.decomposeRight(to, result); + mergeGaps(result, right); + } + return HeightMap.of(result); + } + decomposeLeft(to, result) { + let left = this.left.length; + if (to <= left) + return this.left.decomposeLeft(to, result); + result.push(this.left); + if (this.break) { + left++; + if (to >= left) + result.push(null); + } + if (to > left) + this.right.decomposeLeft(to - left, result); + } + decomposeRight(from, result) { + let left = this.left.length, right = left + this.break; + if (from >= right) + return this.right.decomposeRight(from - right, result); + if (from < left) + this.left.decomposeRight(from, result); + if (this.break && from < right) + result.push(null); + result.push(this.right); + } + balanced(left, right) { + if (left.size > 2 * right.size || right.size > 2 * left.size) + return HeightMap.of(this.break ? [left, null, right] : [left, right]); + this.left = replace(this.left, left); + this.right = replace(this.right, right); + this.setHeight(left.height + right.height); + this.outdated = left.outdated || right.outdated; + this.size = left.size + right.size; + this.length = left.length + this.break + right.length; + return this; + } + updateHeight(oracle, offset = 0, force = false, measured) { + let { left, right } = this, rightStart = offset + left.length + this.break, rebalance = null; + if (measured && measured.from <= offset + left.length && measured.more) + rebalance = left = left.updateHeight(oracle, offset, force, measured); + else + left.updateHeight(oracle, offset, force); + if (measured && measured.from <= rightStart + right.length && measured.more) + rebalance = right = right.updateHeight(oracle, rightStart, force, measured); + else + right.updateHeight(oracle, rightStart, force); + if (rebalance) + return this.balanced(left, right); + this.height = this.left.height + this.right.height; + this.outdated = false; + return this; + } + toString() { return this.left + (this.break ? " " : "-") + this.right; } +} +function mergeGaps(nodes, around) { + let before, after; + if (nodes[around] == null && + (before = nodes[around - 1]) instanceof HeightMapGap && + (after = nodes[around + 1]) instanceof HeightMapGap) + nodes.splice(around - 1, 3, new HeightMapGap(before.length + 1 + after.length)); +} +const relevantWidgetHeight = 5; +class NodeBuilder { + constructor(pos, oracle) { + this.pos = pos; + this.oracle = oracle; + this.nodes = []; + this.lineStart = -1; + this.lineEnd = -1; + this.covering = null; + this.writtenTo = pos; + } + get isCovered() { + return this.covering && this.nodes[this.nodes.length - 1] == this.covering; + } + span(_from, to) { + if (this.lineStart > -1) { + let end = Math.min(to, this.lineEnd), last = this.nodes[this.nodes.length - 1]; + if (last instanceof HeightMapText) + last.length += end - this.pos; + else if (end > this.pos || !this.isCovered) + this.nodes.push(new HeightMapText(end - this.pos, -1, 0)); + this.writtenTo = end; + if (to > end) { + this.nodes.push(null); + this.writtenTo++; + this.lineStart = -1; + } + } + this.pos = to; + } + point(from, to, deco) { + if (from < to || deco.heightRelevant) { + let height = deco.widget ? deco.widget.estimatedHeight : 0; + let breaks = deco.widget ? deco.widget.lineBreaks : 0; + if (height < 0) + height = this.oracle.lineHeight; + let len = to - from; + if (deco.block) { + this.addBlock(new HeightMapBlock(len, height, deco)); + } + else if (len || breaks || height >= relevantWidgetHeight) { + this.addLineDeco(height, breaks, len); + } + } + else if (to > from) { + this.span(from, to); + } + if (this.lineEnd > -1 && this.lineEnd < this.pos) + this.lineEnd = this.oracle.doc.lineAt(this.pos).to; + } + enterLine() { + if (this.lineStart > -1) + return; + let { from, to } = this.oracle.doc.lineAt(this.pos); + this.lineStart = from; + this.lineEnd = to; + if (this.writtenTo < from) { + if (this.writtenTo < from - 1 || this.nodes[this.nodes.length - 1] == null) + this.nodes.push(this.blankContent(this.writtenTo, from - 1)); + this.nodes.push(null); + } + if (this.pos > from) + this.nodes.push(new HeightMapText(this.pos - from, -1, 0)); + this.writtenTo = this.pos; + } + blankContent(from, to) { + let gap = new HeightMapGap(to - from); + if (this.oracle.doc.lineAt(from).to == to) + gap.flags |= 4 /* Flag.SingleLine */; + return gap; + } + ensureLine() { + this.enterLine(); + let last = this.nodes.length ? this.nodes[this.nodes.length - 1] : null; + if (last instanceof HeightMapText) + return last; + let line = new HeightMapText(0, -1, 0); + this.nodes.push(line); + return line; + } + addBlock(block) { + this.enterLine(); + let deco = block.deco; + if (deco && deco.startSide > 0 && !this.isCovered) + this.ensureLine(); + this.nodes.push(block); + this.writtenTo = this.pos = this.pos + block.length; + if (deco && deco.endSide > 0) + this.covering = block; + } + addLineDeco(height, breaks, length) { + let line = this.ensureLine(); + line.length += length; + line.collapsed += length; + line.widgetHeight = Math.max(line.widgetHeight, height); + line.breaks += breaks; + this.writtenTo = this.pos = this.pos + length; + } + finish(from) { + let last = this.nodes.length == 0 ? null : this.nodes[this.nodes.length - 1]; + if (this.lineStart > -1 && !(last instanceof HeightMapText) && !this.isCovered) + this.nodes.push(new HeightMapText(0, -1, 0)); + else if (this.writtenTo < this.pos || last == null) + this.nodes.push(this.blankContent(this.writtenTo, this.pos)); + let pos = from; + for (let node of this.nodes) { + if (node instanceof HeightMapText) + node.updateHeight(this.oracle, pos); + pos += node ? node.length : 1; + } + return this.nodes; + } + // Always called with a region that on both sides either stretches + // to a line break or the end of the document. + // The returned array uses null to indicate line breaks, but never + // starts or ends in a line break, or has multiple line breaks next + // to each other. + static build(oracle, decorations, from, to) { + let builder = new NodeBuilder(from, oracle); + state.RangeSet.spans(decorations, from, to, builder, 0); + return builder.finish(from); + } +} +function heightRelevantDecoChanges(a, b, diff) { + let comp = new DecorationComparator; + state.RangeSet.compare(a, b, diff, comp, 0); + return comp.changes; +} +class DecorationComparator { + constructor() { + this.changes = []; + } + compareRange() { } + comparePoint(from, to, a, b) { + if (from < to || a && a.heightRelevant || b && b.heightRelevant) + addRange(from, to, this.changes, 5); + } +} + +function visiblePixelRange(dom, paddingTop) { + let rect = dom.getBoundingClientRect(); + let doc = dom.ownerDocument, win = doc.defaultView || window; + let left = Math.max(0, rect.left), right = Math.min(win.innerWidth, rect.right); + let top = Math.max(0, rect.top), bottom = Math.min(win.innerHeight, rect.bottom); + for (let parent = dom.parentNode; parent && parent != doc.body;) { + if (parent.nodeType == 1) { + let elt = parent; + let style = window.getComputedStyle(elt); + if ((elt.scrollHeight > elt.clientHeight || elt.scrollWidth > elt.clientWidth) && + style.overflow != "visible") { + let parentRect = elt.getBoundingClientRect(); + left = Math.max(left, parentRect.left); + right = Math.min(right, parentRect.right); + top = Math.max(top, parentRect.top); + bottom = Math.min(parent == dom.parentNode ? win.innerHeight : bottom, parentRect.bottom); + } + parent = style.position == "absolute" || style.position == "fixed" ? elt.offsetParent : elt.parentNode; + } + else if (parent.nodeType == 11) { // Shadow root + parent = parent.host; + } + else { + break; + } + } + return { left: left - rect.left, right: Math.max(left, right) - rect.left, + top: top - (rect.top + paddingTop), bottom: Math.max(top, bottom) - (rect.top + paddingTop) }; +} +function inWindow(elt) { + let rect = elt.getBoundingClientRect(), win = elt.ownerDocument.defaultView || window; + return rect.left < win.innerWidth && rect.right > 0 && + rect.top < win.innerHeight && rect.bottom > 0; +} +function fullPixelRange(dom, paddingTop) { + let rect = dom.getBoundingClientRect(); + return { left: 0, right: rect.right - rect.left, + top: paddingTop, bottom: rect.bottom - (rect.top + paddingTop) }; +} +// Line gaps are placeholder widgets used to hide pieces of overlong +// lines within the viewport, as a kludge to keep the editor +// responsive when a ridiculously long line is loaded into it. +class LineGap { + constructor(from, to, size, displaySize) { + this.from = from; + this.to = to; + this.size = size; + this.displaySize = displaySize; + } + static same(a, b) { + if (a.length != b.length) + return false; + for (let i = 0; i < a.length; i++) { + let gA = a[i], gB = b[i]; + if (gA.from != gB.from || gA.to != gB.to || gA.size != gB.size) + return false; + } + return true; + } + draw(viewState, wrapping) { + return Decoration.replace({ + widget: new LineGapWidget(this.displaySize * (wrapping ? viewState.scaleY : viewState.scaleX), wrapping) + }).range(this.from, this.to); + } +} +class LineGapWidget extends WidgetType { + constructor(size, vertical) { + super(); + this.size = size; + this.vertical = vertical; + } + eq(other) { return other.size == this.size && other.vertical == this.vertical; } + toDOM() { + let elt = document.createElement("div"); + if (this.vertical) { + elt.style.height = this.size + "px"; + } + else { + elt.style.width = this.size + "px"; + elt.style.height = "2px"; + elt.style.display = "inline-block"; + } + return elt; + } + get estimatedHeight() { return this.vertical ? this.size : -1; } +} +/** +@internal +*/ +function applyViewportBuffer(marginTop, marginBottom, maxAhead, direction, active, editorHeight, scaleY) { + if (active && direction) { + let ahead = Math.max(1000 /* VP.Margin */ / 2, editorHeight * maxAhead * scaleY); + if (direction < 0) + marginTop = Math.max(marginTop, ahead); + else + marginBottom = Math.max(marginBottom, ahead); + } + return { top: marginTop, bottom: marginBottom }; +} +class ViewState { + constructor(view, state$1) { + this.view = view; + this.state = state$1; + // These are contentDOM-local coordinates + this.pixelViewport = { left: 0, right: window.innerWidth, top: 0, bottom: 0 }; + this.inView = true; + this.scrollDirection = 0; + this.viewportBufferUntil = 0; + this.controlledScrollCorridor = null; + this.paddingTop = 0; // Padding above the document, scaled + this.paddingBottom = 0; // Padding below the document, scaled + this.contentDOMWidth = 0; // contentDOM.getBoundingClientRect().width + this.contentDOMHeight = 0; // contentDOM.getBoundingClientRect().height + this.editorHeight = 0; // scrollDOM.clientHeight, unscaled + this.editorWidth = 0; // scrollDOM.clientWidth, unscaled + // The CSS-transformation scale of the editor (transformed size / + // concrete size) + this.scaleX = 1; + this.scaleY = 1; + // Last seen vertical offset of the element at the top of the scroll + // container, or top of the window if there's no wrapping scroller + this.scrollOffset = 0; + this.scrolledToBottom = false; + // The vertical position (document-relative) to which to anchor the + // scroll position. -1 means anchor to the end of the document. + this.scrollAnchorPos = 0; + // The height at the anchor position. Set by the DOM update phase. + // -1 means no height available. + this.scrollAnchorHeight = -1; + // See VP.MaxDOMHeight + this.scaler = IdScaler; + this.scrollTarget = null; + // Briefly set to true when printing, to disable viewport limiting + this.printing = false; + // Flag set when editor content was redrawn, so that the next + // measure stage knows it must read DOM layout + this.mustMeasureContent = true; + this.defaultTextDirection = exports.Direction.LTR; + this.visibleRanges = []; + // Cursor 'assoc' is only significant when the cursor is on a line + // wrap point, where it must stick to the character that it is + // associated with. Since browsers don't provide a reasonable + // interface to set or query this, when a selection is set that + // might cause this to be significant, this flag is set. The next + // measure phase will check whether the cursor is on a line-wrapping + // boundary and, if so, reset it to make sure it is positioned in + // the right place. + this.mustEnforceCursorAssoc = false; + let guessWrapping = state$1.facet(contentAttributes).some(v => typeof v != "function" && v.class == "cm-lineWrapping"); + this.heightOracle = new HeightOracle(guessWrapping); + this.stateDeco = staticDeco(state$1); + this.heightMap = HeightMap.empty().applyChanges(this.stateDeco, state.Text.empty, this.heightOracle.setDoc(state$1.doc), [new ChangedRange(0, 0, 0, state$1.doc.length)]); + for (let i = 0; i < 2; i++) { + this.viewport = this.getViewport(0, null); + if (!this.updateForViewport()) + break; + } + this.updateViewportLines(); + this.lineGaps = this.ensureLineGaps([]); + this.lineGapDeco = Decoration.set(this.lineGaps.map(gap => gap.draw(this, false))); + this.scrollParent = view.scrollDOM; + this.computeVisibleRanges(); + } + updateForViewport() { + let viewports = [this.viewport], { main } = this.state.selection; + for (let i = 0; i <= 1; i++) { + let pos = i ? main.head : main.anchor; + if (!viewports.some(({ from, to }) => pos >= from && pos <= to)) { + let { from, to } = this.lineBlockAt(pos); + viewports.push(new Viewport(from, to)); + } + } + this.viewports = viewports.sort((a, b) => a.from - b.from); + return this.updateScaler(); + } + updateScaler() { + let scaler = this.scaler; + this.scaler = this.heightMap.height <= 7000000 /* VP.MaxDOMHeight */ ? IdScaler : + new BigScaler(this.heightOracle, this.heightMap, this.viewports); + return scaler.eq(this.scaler) ? 0 : 2 /* UpdateFlag.Height */; + } + updateViewportLines() { + this.viewportLines = []; + this.heightMap.forEachLine(this.viewport.from, this.viewport.to, this.heightOracle.setDoc(this.state.doc), 0, 0, block => { + this.viewportLines.push(scaleBlock(block, this.scaler)); + }); + } + update(update, scrollTarget = null) { + this.state = update.state; + if (!this.state.facet(viewportBuffer)) { + this.scrollDirection = 0; + this.viewportBufferUntil = 0; + } + let prevDeco = this.stateDeco; + this.stateDeco = staticDeco(this.state); + let contentChanges = update.changedRanges; + let heightChanges = ChangedRange.extendWithRanges(contentChanges, heightRelevantDecoChanges(prevDeco, this.stateDeco, update ? update.changes : state.ChangeSet.empty(this.state.doc.length))); + let prevHeight = this.heightMap.height; + let scrollAnchor = this.scrolledToBottom ? null : this.scrollAnchorAt(this.scrollOffset); + clearHeightChangeFlag(); + this.heightMap = this.heightMap.applyChanges(this.stateDeco, update.startState.doc, this.heightOracle.setDoc(this.state.doc), heightChanges); + if (this.heightMap.height != prevHeight || heightChangeFlag) + update.flags |= 2 /* UpdateFlag.Height */; + if (scrollAnchor) { + this.scrollAnchorPos = update.changes.mapPos(scrollAnchor.from, -1); + this.scrollAnchorHeight = scrollAnchor.top; + } + else { + this.scrollAnchorPos = -1; + this.scrollAnchorHeight = prevHeight; + } + let viewport = heightChanges.length ? this.mapViewport(this.viewport, update.changes) : this.viewport; + if (scrollTarget && (scrollTarget.range.head < viewport.from || scrollTarget.range.head > viewport.to) || + !this.viewportIsAppropriate(viewport)) + viewport = this.getViewport(0, scrollTarget); + let viewportChange = viewport.from != this.viewport.from || viewport.to != this.viewport.to; + this.viewport = viewport; + update.flags |= this.updateForViewport(); + if (viewportChange || !update.changes.empty || (update.flags & 2 /* UpdateFlag.Height */)) + this.updateViewportLines(); + if (this.lineGaps.length || this.viewport.to - this.viewport.from > (2000 /* LG.Margin */ << 1)) + this.updateLineGaps(this.ensureLineGaps(this.mapLineGaps(this.lineGaps, update.changes))); + update.flags |= this.computeVisibleRanges(update.changes); + if (scrollTarget) + this.scrollTarget = scrollTarget; + if (!this.mustEnforceCursorAssoc && (update.selectionSet || update.focusChanged) && update.view.lineWrapping && + update.state.selection.main.empty && update.state.selection.main.assoc && + !update.state.facet(nativeSelectionHidden)) + this.mustEnforceCursorAssoc = true; + } + measure() { + let { view } = this, dom = view.contentDOM, style = window.getComputedStyle(dom); + let oracle = this.heightOracle; + let whiteSpace = style.whiteSpace; + this.defaultTextDirection = style.direction == "rtl" ? exports.Direction.RTL : exports.Direction.LTR; + let refresh = this.heightOracle.mustRefreshForWrapping(whiteSpace) || this.mustMeasureContent === "refresh"; + let domRect = dom.getBoundingClientRect(); + let measureContent = refresh || this.mustMeasureContent || this.contentDOMHeight != domRect.height; + this.contentDOMHeight = domRect.height; + this.mustMeasureContent = false; + let result = 0, bias = 0; + if (domRect.width && domRect.height) { + let { scaleX, scaleY } = getScale(dom, domRect); + if (scaleX > .005 && Math.abs(this.scaleX - scaleX) > .005 || + scaleY > .005 && Math.abs(this.scaleY - scaleY) > .005) { + this.scaleX = scaleX; + this.scaleY = scaleY; + result |= 16 /* UpdateFlag.Geometry */; + refresh = measureContent = true; + } + } + // Vertical padding + let paddingTop = (parseInt(style.paddingTop) || 0) * this.scaleY; + let paddingBottom = (parseInt(style.paddingBottom) || 0) * this.scaleY; + if (this.paddingTop != paddingTop || this.paddingBottom != paddingBottom) { + this.paddingTop = paddingTop; + this.paddingBottom = paddingBottom; + result |= 16 /* UpdateFlag.Geometry */ | 2 /* UpdateFlag.Height */; + } + if (this.editorWidth != view.scrollDOM.clientWidth) { + if (oracle.lineWrapping) + measureContent = true; + this.editorWidth = view.scrollDOM.clientWidth; + result |= 16 /* UpdateFlag.Geometry */; + } + let scrollParent = scrollableParents(this.view.contentDOM, false).y; + if (scrollParent != this.scrollParent) { + this.scrollParent = scrollParent; + this.scrollAnchorHeight = -1; + this.scrollOffset = 0; + } + let scrollOffset = this.getScrollOffset(); + if (this.scrollOffset != scrollOffset) { + this.scrollAnchorHeight = -1; + this.scrollOffset = scrollOffset; + } + this.scrolledToBottom = isScrolledToBottom(this.scrollParent || view.win); + // Pixel viewport + let pixelViewport = (this.printing ? fullPixelRange : visiblePixelRange)(dom, this.paddingTop); + let dTop = pixelViewport.top - this.pixelViewport.top, dBottom = pixelViewport.bottom - this.pixelViewport.bottom; + this.pixelViewport = pixelViewport; + let inView = this.pixelViewport.bottom > this.pixelViewport.top && this.pixelViewport.right > this.pixelViewport.left; + if (inView != this.inView) { + this.inView = inView; + if (inView) + measureContent = true; + } + if (!this.inView && !this.scrollTarget && !inWindow(view.dom)) + return 0; + let contentWidth = domRect.width; + if (this.contentDOMWidth != contentWidth || this.editorHeight != view.scrollDOM.clientHeight) { + this.contentDOMWidth = domRect.width; + this.editorHeight = view.scrollDOM.clientHeight; + result |= 16 /* UpdateFlag.Geometry */; + } + if (measureContent) { + let lineHeights = view.docView.measureVisibleLineHeights(this.viewport); + if (oracle.mustRefreshForHeights(lineHeights)) + refresh = true; + if (refresh || oracle.lineWrapping && Math.abs(contentWidth - this.contentDOMWidth) > oracle.charWidth) { + let { lineHeight, charWidth, textHeight } = view.docView.measureTextSize(); + refresh = lineHeight > 0 && oracle.refresh(whiteSpace, lineHeight, charWidth, textHeight, Math.max(5, contentWidth / charWidth), lineHeights); + if (refresh) { + view.docView.minWidth = 0; + result |= 16 /* UpdateFlag.Geometry */; + } + } + if (dTop > 0 && dBottom > 0) + bias = Math.max(dTop, dBottom); + else if (dTop < 0 && dBottom < 0) + bias = Math.min(dTop, dBottom); + clearHeightChangeFlag(); + for (let vp of this.viewports) { + let heights = vp.from == this.viewport.from ? lineHeights : view.docView.measureVisibleLineHeights(vp); + this.heightMap = (refresh ? HeightMap.empty().applyChanges(this.stateDeco, state.Text.empty, this.heightOracle, [new ChangedRange(0, 0, 0, view.state.doc.length)]) : this.heightMap).updateHeight(oracle, 0, refresh, new MeasuredHeights(vp.from, heights)); + } + if (heightChangeFlag) + result |= 2 /* UpdateFlag.Height */; + } + let viewportChange = !this.viewportIsAppropriate(this.viewport, bias) || + this.scrollTarget && (this.scrollTarget.range.head < this.viewport.from || + this.scrollTarget.range.head > this.viewport.to); + if (viewportChange) { + if (result & 2 /* UpdateFlag.Height */) + result |= this.updateScaler(); + this.viewport = this.getViewport(bias, this.scrollTarget); + result |= this.updateForViewport(); + } + if ((result & 2 /* UpdateFlag.Height */) || viewportChange) + this.updateViewportLines(); + if (this.lineGaps.length || this.viewport.to - this.viewport.from > (2000 /* LG.Margin */ << 1)) + this.updateLineGaps(this.ensureLineGaps(refresh ? [] : this.lineGaps, view)); + result |= this.computeVisibleRanges(); + if (this.mustEnforceCursorAssoc) { + this.mustEnforceCursorAssoc = false; + // This is done in the read stage, because moving the selection + // to a line end is going to trigger a layout anyway, so it + // can't be a pure write. It should be rare that it does any + // writing. + view.docView.enforceCursorAssoc(); + } + return result; + } + get visibleTop() { return this.scaler.fromDOM(this.pixelViewport.top); } + get visibleBottom() { return this.scaler.fromDOM(this.pixelViewport.bottom); } + activateViewportBuffer(delta) { + let config = this.state.facet(viewportBuffer); + if (!config || Math.abs(delta) <= 1) + return null; + this.scrollDirection = delta < 0 ? -1 : 1; + this.viewportBufferUntil = Date.now() + config.settleDelay; + return config.settleDelay; + } + noteScrollActivity() { + return this.activateViewportBuffer(this.getScrollOffset() - this.scrollOffset); + } + setControlledScrollCorridor(from, to) { + let corridor = { + from: Math.max(0, Math.min(from, this.docHeight)), + to: Math.max(0, Math.min(to, this.docHeight)) + }; + if (corridor.to < corridor.from) + [corridor.from, corridor.to] = [corridor.to, corridor.from]; + let current = this.controlledScrollCorridor; + if (current && Math.abs(current.from - corridor.from) < 1 && Math.abs(current.to - corridor.to) < 1) + return false; + this.controlledScrollCorridor = corridor; + return true; + } + clearControlledScrollCorridor() { + if (!this.controlledScrollCorridor) + return false; + this.controlledScrollCorridor = null; + return true; + } + get controlledScrollActive() { return !!this.controlledScrollCorridor; } + controlledScrollBounds() { + let lines = this.viewportLines, scale = this.scaleY || 1; + if (!lines.length) + return { from: 0, to: 0 }; + let maxScroll = Math.max(0, this.view.scrollDOM.scrollHeight - this.view.scrollDOM.clientHeight); + let from = this.viewport.from == 0 ? 0 : Math.ceil(lines[0].top / scale + 2); + let to = this.viewport.to == this.state.doc.length ? maxScroll + : Math.floor(lines[lines.length - 1].bottom / scale - this.view.scrollDOM.clientHeight - 2); + return { from: Math.max(0, Math.min(from, maxScroll)), + to: Math.max(0, Math.min(Math.max(from, to), maxScroll)) }; + } + viewportMargins(bias) { + let marginTop = (0.5 - Math.max(-0.5, Math.min(0.5, bias / 1000 /* VP.Margin */ / 2))) * 1000 /* VP.Margin */; + let marginBottom = 1000 /* VP.Margin */ - marginTop; + let config = this.state.facet(viewportBuffer); + let margins = applyViewportBuffer(marginTop, marginBottom, (config === null || config === void 0 ? void 0 : config.maxAhead) || 0, this.scrollDirection, !!config && Date.now() < this.viewportBufferUntil, this.editorHeight, this.scaleY); + if (this.controlledScrollCorridor) { + margins.top = Math.max(margins.top, this.visibleTop - this.controlledScrollCorridor.from); + margins.bottom = Math.max(margins.bottom, this.controlledScrollCorridor.to - this.visibleBottom); + } + return margins; + } + getViewport(bias, scrollTarget) { + // This will divide VP.Margin between the top and the + // bottom, depending on the bias (the change in viewport position + // since the last update). It'll hold a number between 0 and 1 + let map = this.heightMap, oracle = this.heightOracle; + let { visibleTop, visibleBottom } = this; + let margins = this.viewportMargins(bias); + let viewport = new Viewport(map.lineAt(visibleTop - margins.top, QueryType.ByHeight, oracle, 0, 0).from, map.lineAt(visibleBottom + margins.bottom, QueryType.ByHeight, oracle, 0, 0).to); + // If scrollTarget is given, make sure the viewport includes that position + if (scrollTarget) { + let { head } = scrollTarget.range; + if (head < viewport.from || head > viewport.to) { + let viewHeight = Math.min(this.editorHeight, this.pixelViewport.bottom - this.pixelViewport.top); + let block = map.lineAt(head, QueryType.ByPos, oracle, 0, 0), topPos; + if (scrollTarget.y == "center") + topPos = (block.top + block.bottom) / 2 - viewHeight / 2; + else if (scrollTarget.y == "start" || scrollTarget.y == "nearest" && head < viewport.from) + topPos = block.top; + else + topPos = block.bottom - viewHeight; + viewport = new Viewport(map.lineAt(topPos - margins.top, QueryType.ByHeight, oracle, 0, 0).from, map.lineAt(topPos + viewHeight + margins.bottom, QueryType.ByHeight, oracle, 0, 0).to); + } + } + return viewport; + } + mapViewport(viewport, changes) { + let from = changes.mapPos(viewport.from, -1), to = changes.mapPos(viewport.to, 1); + return new Viewport(this.heightMap.lineAt(from, QueryType.ByPos, this.heightOracle, 0, 0).from, this.heightMap.lineAt(to, QueryType.ByPos, this.heightOracle, 0, 0).to); + } + // Checks if a given viewport covers the visible part of the + // document and not too much beyond that. + viewportIsAppropriate({ from, to }, bias = 0) { + if (!this.inView) + return true; + let { top } = this.heightMap.lineAt(from, QueryType.ByPos, this.heightOracle, 0, 0); + let { bottom } = this.heightMap.lineAt(to, QueryType.ByPos, this.heightOracle, 0, 0); + let { visibleTop, visibleBottom } = this; + let margins = this.viewportMargins(bias); + let active = Date.now() < this.viewportBufferUntil; + let topCover = active && this.scrollDirection < 0 ? margins.top : Math.max(10 /* VP.MinCoverMargin */, Math.min(-bias, 250 /* VP.MaxCoverMargin */)); + let bottomCover = active && this.scrollDirection > 0 ? margins.bottom : Math.max(10 /* VP.MinCoverMargin */, Math.min(bias, 250 /* VP.MaxCoverMargin */)); + return (from == 0 || top <= visibleTop - topCover) && + (to == this.state.doc.length || + bottom >= visibleBottom + bottomCover) && + (top > visibleTop - 2 * Math.max(1000 /* VP.Margin */, margins.top) && + bottom < visibleBottom + 2 * Math.max(1000 /* VP.Margin */, margins.bottom)); + } + mapLineGaps(gaps, changes) { + if (!gaps.length || changes.empty) + return gaps; + let mapped = []; + for (let gap of gaps) + if (!changes.touchesRange(gap.from, gap.to)) + mapped.push(new LineGap(changes.mapPos(gap.from), changes.mapPos(gap.to), gap.size, gap.displaySize)); + return mapped; + } + // Computes positions in the viewport where the start or end of a + // line should be hidden, trying to reuse existing line gaps when + // appropriate to avoid unneccesary redraws. + // Uses crude character-counting for the positioning and sizing, + // since actual DOM coordinates aren't always available and + // predictable. Relies on generous margins (see LG.Margin) to hide + // the artifacts this might produce from the user. + ensureLineGaps(current, mayMeasure) { + let wrapping = this.heightOracle.lineWrapping; + let margin = wrapping ? 10000 /* LG.MarginWrap */ : 2000 /* LG.Margin */, halfMargin = margin >> 1, doubleMargin = margin << 1; + // The non-wrapping logic won't work at all in predominantly right-to-left text. + if (this.defaultTextDirection != exports.Direction.LTR && !wrapping) + return []; + let gaps = []; + let addGap = (from, to, line, structure) => { + if (to - from < halfMargin) + return; + let sel = this.state.selection.main, avoid = [sel.from]; + if (!sel.empty) + avoid.push(sel.to); + for (let pos of avoid) { + if (pos > from && pos < to) { + addGap(from, pos - 10 /* LG.SelectionMargin */, line, structure); + addGap(pos + 10 /* LG.SelectionMargin */, to, line, structure); + return; + } + } + let gap = find(current, gap => gap.from >= line.from && gap.to <= line.to && + Math.abs(gap.from - from) < halfMargin && Math.abs(gap.to - to) < halfMargin && + !avoid.some(pos => gap.from < pos && gap.to > pos)); + if (!gap) { + // When scrolling down, snap gap ends to line starts to avoid shifts in wrapping + if (to < line.to && mayMeasure && wrapping && + mayMeasure.visibleRanges.some(r => r.from <= to && r.to >= to)) { + let lineStart = mayMeasure.moveToLineBoundary(state.EditorSelection.cursor(to), false, true).head; + if (lineStart > from) + to = lineStart; + } + let size = this.gapSize(line, from, to, structure); + let displaySize = wrapping || size < 2000000 /* VP.MaxHorizGap */ ? size : 2000000 /* VP.MaxHorizGap */; + gap = new LineGap(from, to, size, displaySize); + } + gaps.push(gap); + }; + let checkLine = (line) => { + if (line.length < doubleMargin || line.type != exports.BlockType.Text) + return; + let structure = lineStructure(line.from, line.to, this.stateDeco); + if (structure.total < doubleMargin) + return; + let target = this.scrollTarget ? this.scrollTarget.range.head : null; + let viewFrom, viewTo; + if (wrapping) { + let marginHeight = (margin / this.heightOracle.lineLength) * this.heightOracle.lineHeight; + let top, bot; + if (target != null) { + let targetFrac = findFraction(structure, target); + let spaceFrac = ((this.visibleBottom - this.visibleTop) / 2 + marginHeight) / line.height; + top = targetFrac - spaceFrac; + bot = targetFrac + spaceFrac; + } + else { + top = (this.visibleTop - line.top - marginHeight) / line.height; + bot = (this.visibleBottom - line.top + marginHeight) / line.height; + } + viewFrom = findPosition(structure, top); + viewTo = findPosition(structure, bot); + } + else { + let totalWidth = structure.total * this.heightOracle.charWidth; + let marginWidth = margin * this.heightOracle.charWidth; + let horizOffset = 0; + if (totalWidth > 2000000 /* VP.MaxHorizGap */) + for (let old of current) { + if (old.from >= line.from && old.from < line.to && old.size != old.displaySize && + old.from * this.heightOracle.charWidth + horizOffset < this.pixelViewport.left) + horizOffset = old.size - old.displaySize; + } + let pxLeft = this.pixelViewport.left + horizOffset, pxRight = this.pixelViewport.right + horizOffset; + let left, right; + if (target != null) { + let targetFrac = findFraction(structure, target); + let spaceFrac = ((pxRight - pxLeft) / 2 + marginWidth) / totalWidth; + left = targetFrac - spaceFrac; + right = targetFrac + spaceFrac; + } + else { + left = (pxLeft - marginWidth) / totalWidth; + right = (pxRight + marginWidth) / totalWidth; + } + viewFrom = findPosition(structure, left); + viewTo = findPosition(structure, right); + } + if (viewFrom > line.from) + addGap(line.from, viewFrom, line, structure); + if (viewTo < line.to) + addGap(viewTo, line.to, line, structure); + }; + for (let line of this.viewportLines) { + if (Array.isArray(line.type)) + line.type.forEach(checkLine); + else + checkLine(line); + } + return gaps; + } + gapSize(line, from, to, structure) { + let fraction = findFraction(structure, to) - findFraction(structure, from); + if (this.heightOracle.lineWrapping) { + return line.height * fraction; + } + else { + return structure.total * this.heightOracle.charWidth * fraction; + } + } + updateLineGaps(gaps) { + if (!LineGap.same(gaps, this.lineGaps)) { + this.lineGaps = gaps; + this.lineGapDeco = Decoration.set(gaps.map(gap => gap.draw(this, this.heightOracle.lineWrapping))); + } + } + computeVisibleRanges(changes) { + let deco = this.stateDeco; + if (this.lineGaps.length) + deco = deco.concat(this.lineGapDeco); + let ranges = []; + state.RangeSet.spans(deco, this.viewport.from, this.viewport.to, { + span(from, to) { ranges.push({ from, to }); }, + point() { } + }, 20); + let changed = 0; + if (ranges.length != this.visibleRanges.length) { + changed = 8 /* UpdateFlag.ViewportMoved */ | 4 /* UpdateFlag.Viewport */; + } + else { + for (let i = 0; i < ranges.length && !(changed & 8 /* UpdateFlag.ViewportMoved */); i++) { + let old = this.visibleRanges[i], nw = ranges[i]; + if (old.from != nw.from || old.to != nw.to) { + changed |= 4 /* UpdateFlag.Viewport */; + if (!(changes && changes.mapPos(old.from, -1) == nw.from && changes.mapPos(old.to, 1) == nw.to)) + changed |= 8 /* UpdateFlag.ViewportMoved */; + } + } + } + this.visibleRanges = ranges; + return changed; + } + lineBlockAt(pos) { + return (pos >= this.viewport.from && pos <= this.viewport.to && + this.viewportLines.find(b => b.from <= pos && b.to >= pos)) || + scaleBlock(this.heightMap.lineAt(pos, QueryType.ByPos, this.heightOracle, 0, 0), this.scaler); + } + lineBlockAtHeight(height) { + return (height >= this.viewportLines[0].top && height <= this.viewportLines[this.viewportLines.length - 1].bottom && + this.viewportLines.find(l => l.top <= height && l.bottom >= height)) || + scaleBlock(this.heightMap.lineAt(this.scaler.fromDOM(height), QueryType.ByHeight, this.heightOracle, 0, 0), this.scaler); + } + getScrollOffset() { + let base = this.scrollParent == this.view.scrollDOM ? this.scrollParent.scrollTop + : (this.scrollParent ? this.scrollParent.getBoundingClientRect().top : 0) - this.view.contentDOM.getBoundingClientRect().top; + return base * this.scaleY; + } + scrollAnchorAt(scrollOffset) { + let block = this.lineBlockAtHeight(scrollOffset + 8); + return block.from >= this.viewport.from || this.viewportLines[0].top - scrollOffset > 200 ? block : this.viewportLines[0]; + } + elementAtHeight(height) { + return scaleBlock(this.heightMap.blockAt(this.scaler.fromDOM(height), this.heightOracle, 0, 0), this.scaler); + } + get docHeight() { + return this.scaler.toDOM(this.heightMap.height); + } + get contentHeight() { + return this.docHeight + this.paddingTop + this.paddingBottom; + } +} +class Viewport { + constructor(from, to) { + this.from = from; + this.to = to; + } +} +function lineStructure(from, to, stateDeco) { + let ranges = [], pos = from, total = 0; + state.RangeSet.spans(stateDeco, from, to, { + span() { }, + point(from, to) { + if (from > pos) { + ranges.push({ from: pos, to: from }); + total += from - pos; + } + pos = to; + } + }, 20); // We're only interested in collapsed ranges of a significant size + if (pos < to) { + ranges.push({ from: pos, to }); + total += to - pos; + } + return { total, ranges }; +} +function findPosition({ total, ranges }, ratio) { + if (ratio <= 0) + return ranges[0].from; + if (ratio >= 1) + return ranges[ranges.length - 1].to; + let dist = Math.floor(total * ratio); + for (let i = 0;; i++) { + let { from, to } = ranges[i], size = to - from; + if (dist <= size) + return from + dist; + dist -= size; + } +} +function findFraction(structure, pos) { + let counted = 0; + for (let { from, to } of structure.ranges) { + if (pos <= to) { + counted += pos - from; + break; + } + counted += to - from; + } + return counted / structure.total; +} +function find(array, f) { + for (let val of array) + if (f(val)) + return val; + return undefined; +} +// Don't scale when the document height is within the range of what +// the DOM can handle. +const IdScaler = { + toDOM(n) { return n; }, + fromDOM(n) { return n; }, + scale: 1, + eq(other) { return other == this; } +}; +function staticDeco(state$1) { + let deco = state$1.facet(decorations).filter(d => typeof d != "function"); + let outer = state$1.facet(outerDecorations).filter(d => typeof d != "function"); + if (outer.length) + deco.push(state.RangeSet.join(outer)); + return deco; +} +// When the height is too big (> VP.MaxDOMHeight), scale down the +// regions outside the viewports so that the total height is +// VP.MaxDOMHeight. +class BigScaler { + constructor(oracle, heightMap, viewports) { + let vpHeight = 0, base = 0, domBase = 0; + this.viewports = viewports.map(({ from, to }) => { + let top = heightMap.lineAt(from, QueryType.ByPos, oracle, 0, 0).top; + let bottom = heightMap.lineAt(to, QueryType.ByPos, oracle, 0, 0).bottom; + vpHeight += bottom - top; + return { from, to, top, bottom, domTop: 0, domBottom: 0 }; + }); + this.scale = (7000000 /* VP.MaxDOMHeight */ - vpHeight) / (heightMap.height - vpHeight); + for (let obj of this.viewports) { + obj.domTop = domBase + (obj.top - base) * this.scale; + domBase = obj.domBottom = obj.domTop + (obj.bottom - obj.top); + base = obj.bottom; + } + } + toDOM(n) { + for (let i = 0, base = 0, domBase = 0;; i++) { + let vp = i < this.viewports.length ? this.viewports[i] : null; + if (!vp || n < vp.top) + return domBase + (n - base) * this.scale; + if (n <= vp.bottom) + return vp.domTop + (n - vp.top); + base = vp.bottom; + domBase = vp.domBottom; + } + } + fromDOM(n) { + for (let i = 0, base = 0, domBase = 0;; i++) { + let vp = i < this.viewports.length ? this.viewports[i] : null; + if (!vp || n < vp.domTop) + return base + (n - domBase) / this.scale; + if (n <= vp.domBottom) + return vp.top + (n - vp.domTop); + base = vp.bottom; + domBase = vp.domBottom; + } + } + eq(other) { + if (!(other instanceof BigScaler)) + return false; + return this.scale == other.scale && this.viewports.length == other.viewports.length && + this.viewports.every((vp, i) => vp.from == other.viewports[i].from && vp.to == other.viewports[i].to); + } +} +function scaleBlock(block, scaler) { + if (scaler.scale == 1) + return block; + let bTop = scaler.toDOM(block.top), bBottom = scaler.toDOM(block.bottom); + return new BlockInfo(block.from, block.length, bTop, bBottom - bTop, Array.isArray(block._content) ? block._content.map(b => scaleBlock(b, scaler)) : block._content); +} + +const theme = state.Facet.define({ combine: strs => strs.join(" ") }); +const darkTheme = state.Facet.define({ combine: values => values.indexOf(true) > -1 }); +const baseThemeID = styleMod.StyleModule.newName(), baseLightID = styleMod.StyleModule.newName(), baseDarkID = styleMod.StyleModule.newName(); +const lightDarkIDs = { "&light": "." + baseLightID, "&dark": "." + baseDarkID }; +function buildTheme(main, spec, scopes) { + return new styleMod.StyleModule(spec, { + finish(sel) { + return /&/.test(sel) ? sel.replace(/&\w*/, m => { + if (m == "&") + return main; + if (!scopes || !scopes[m]) + throw new RangeError(`Unsupported selector: ${m}`); + return scopes[m]; + }) : main + " " + sel; + } + }); +} +const baseTheme$1 = buildTheme("." + baseThemeID, { + "&": { + position: "relative !important", + boxSizing: "border-box", + "&.cm-focused": { + // Provide a simple default outline to make sure a focused + // editor is visually distinct. Can't leave the default behavior + // because that will apply to the content element, which is + // inside the scrollable container and doesn't include the + // gutters. We also can't use an 'auto' outline, since those + // are, for some reason, drawn behind the element content, which + // will cause things like the active line background to cover + // the outline (#297). + outline: "1px dotted #212121" + }, + display: "flex !important", + flexDirection: "column" + }, + ".cm-scroller": { + display: "flex !important", + alignItems: "flex-start !important", + fontFamily: "monospace", + lineHeight: 1.4, + height: "100%", + overflowX: "auto", + position: "relative", + zIndex: 0, + overflowAnchor: "none", + }, + ".cm-content": { + margin: 0, + flexGrow: 2, + flexShrink: 0, + display: "block", + whiteSpace: "pre", + wordWrap: "normal", // Issue #456 + boxSizing: "border-box", + minHeight: "100%", + padding: "4px 0", + outline: "none", + "&[contenteditable=true]": { + WebkitUserModify: "read-write-plaintext-only", + } + }, + ".cm-lineWrapping": { + whiteSpace_fallback: "pre-wrap", // For IE + whiteSpace: "break-spaces", + wordBreak: "break-word", // For Safari, which doesn't support overflow-wrap: anywhere + overflowWrap: "anywhere", + flexShrink: 1 + }, + "&light .cm-content": { caretColor: "black" }, + "&dark .cm-content": { caretColor: "white" }, + ".cm-line": { + display: "block", + padding: "0 2px 0 6px" + }, + ".cm-layer": { + userSelect: "none", // #1708 + position: "absolute", + left: 0, + top: 0, + contain: "size style", + "& > *": { + position: "absolute" + } + }, + "&light .cm-selectionBackground": { + background: "#d9d9d9" + }, + "&dark .cm-selectionBackground": { + background: "#222" + }, + "&light.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground": { + background: "#d7d4f0" + }, + "&dark.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground": { + background: "#233" + }, + ".cm-cursorLayer": { + pointerEvents: "none" + }, + "&.cm-focused > .cm-scroller > .cm-cursorLayer": { + animation: "steps(1) cm-blink 1.2s infinite" + }, + // Two animations defined so that we can switch between them to + // restart the animation without forcing another style + // recomputation. + "@keyframes cm-blink": { "0%": {}, "50%": { opacity: 0 }, "100%": {} }, + "@keyframes cm-blink2": { "0%": {}, "50%": { opacity: 0 }, "100%": {} }, + ".cm-cursor, .cm-dropCursor": { + borderLeft: "1.2px solid black", + marginLeft: "-0.6px", + pointerEvents: "none", + }, + ".cm-cursor": { + display: "none" + }, + "&dark .cm-cursor": { + borderLeftColor: "#ddd" + }, + ".cm-selectionHandle": { + backgroundColor: "currentColor", + width: "1.5px" + }, + ".cm-selectionHandle-start::before, .cm-selectionHandle-end::before": { + content: '""', + backgroundColor: "inherit", + borderRadius: "50%", + width: "8px", + height: "8px", + position: "absolute", + left: "-3.25px" + }, + ".cm-selectionHandle-start::before": { top: "-8px" }, + ".cm-selectionHandle-end::before": { bottom: "-8px" }, + ".cm-dropCursor": { + position: "absolute" + }, + "&.cm-focused > .cm-scroller > .cm-cursorLayer .cm-cursor": { + display: "block" + }, + ".cm-iso": { + unicodeBidi: "isolate" + }, + ".cm-announced": { + position: "fixed", + top: "-10000px" + }, + "@media print": { + ".cm-announced": { display: "none" } + }, + "&light .cm-activeLine": { backgroundColor: "#cceeff44" }, + "&dark .cm-activeLine": { backgroundColor: "#99eeff33" }, + "&light .cm-specialChar": { color: "red" }, + "&dark .cm-specialChar": { color: "#f78" }, + ".cm-gutters": { + flexShrink: 0, + display: "flex", + height: "100%", + boxSizing: "border-box", + zIndex: 200, + }, + ".cm-gutters-before": { insetInlineStart: 0 }, + ".cm-gutters-after": { insetInlineEnd: 0 }, + "&light .cm-gutters": { + backgroundColor: "#f5f5f5", + color: "#6c6c6c", + border: "0px solid #ddd", + "&.cm-gutters-before": { borderRightWidth: "1px" }, + "&.cm-gutters-after": { borderLeftWidth: "1px" }, + }, + "&dark .cm-gutters": { + backgroundColor: "#333338", + color: "#ccc" + }, + ".cm-gutter": { + display: "flex !important", // Necessary -- prevents margin collapsing + flexDirection: "column", + flexShrink: 0, + boxSizing: "border-box", + minHeight: "100%", + overflow: "hidden" + }, + ".cm-gutterElement": { + boxSizing: "border-box" + }, + ".cm-lineNumbers .cm-gutterElement": { + padding: "0 3px 0 5px", + minWidth: "20px", + textAlign: "right", + whiteSpace: "nowrap" + }, + "&light .cm-activeLineGutter": { + backgroundColor: "#e2f2ff" + }, + "&dark .cm-activeLineGutter": { + backgroundColor: "#222227" + }, + ".cm-panels": { + boxSizing: "border-box", + position: "sticky", + left: 0, + right: 0, + zIndex: 300 + }, + "&light .cm-panels": { + backgroundColor: "#f5f5f5", + color: "black" + }, + ".cm-panels-top": { top: "0" }, + ".cm-panels-bottom": { bottom: "0" }, + "&light .cm-panels-top": { + borderBottom: "1px solid #ddd" + }, + "&light .cm-panels-bottom": { + borderTop: "1px solid #ddd" + }, + "&dark .cm-panels": { + backgroundColor: "#333338", + color: "white" + }, + ".cm-dialog": { + padding: "2px 19px 4px 6px", + position: "relative", + "& label": { fontSize: "80%" }, + }, + ".cm-dialog-close": { + position: "absolute", + top: "3px", + right: "4px", + backgroundColor: "inherit", + border: "none", + font: "inherit", + fontSize: "14px", + padding: "0" + }, + ".cm-tab": { + display: "inline-block", + overflow: "hidden", + verticalAlign: "bottom" + }, + ".cm-widgetBuffer": { + verticalAlign: "text-top", + height: "1em", + width: 0, + display: "inline" + }, + ".cm-placeholder": { + color: "#888", + display: "inline-block", + verticalAlign: "top", + userSelect: "none" + }, + ".cm-highlightSpace": { + backgroundImage: "radial-gradient(circle at 50% 55%, #aaa 20%, transparent 5%)", + backgroundPosition: "center", + }, + ".cm-highlightTab": { + backgroundImage: `url('data:image/svg+xml,')`, + backgroundSize: "auto 100%", + backgroundPosition: "right 90%", + backgroundRepeat: "no-repeat" + }, + ".cm-trailingSpace": { + backgroundColor: "#ff332255" + }, + ".cm-button": { + verticalAlign: "middle", + color: "inherit", + fontSize: "70%", + padding: ".2em 1em", + borderRadius: "1px" + }, + "&light .cm-button": { + backgroundImage: "linear-gradient(#eff1f5, #d9d9df)", + border: "1px solid #888", + "&:active": { + backgroundImage: "linear-gradient(#b4b4b4, #d0d3d6)" + } + }, + "&dark .cm-button": { + backgroundImage: "linear-gradient(#393939, #111)", + border: "1px solid #888", + "&:active": { + backgroundImage: "linear-gradient(#111, #333)" + } + }, + ".cm-textfield": { + verticalAlign: "middle", + color: "inherit", + fontSize: "70%", + border: "1px solid silver", + padding: ".2em .5em" + }, + "&light .cm-textfield": { + backgroundColor: "white" + }, + "&dark .cm-textfield": { + border: "1px solid #555", + backgroundColor: "inherit" + } +}, lightDarkIDs); + +const observeOptions = { + childList: true, + characterData: true, + subtree: true, + attributes: true, + characterDataOldValue: true +}; +// IE11 has very broken mutation observers, so we also listen to +// DOMCharacterDataModified there +const useCharData = browser.ie && browser.ie_version <= 11; +/** +@internal +*/ +function isNativeScrollActivity(touchActive, lastTouchTime, lastWheelEvent, now) { + return touchActive || lastTouchTime > now - 2000 || lastWheelEvent > now - 200; +} +class DOMObserver { + constructor(view) { + this.view = view; + this.active = false; + this.editContext = null; + // The known selection. Kept in our own object, as opposed to just + // directly accessing the selection because: + // - Safari doesn't report the right selection in shadow DOM + // - Reading from the selection forces a DOM layout + // - This way, we can ignore selectionchange events if we have + // already seen the 'new' selection + this.selectionRange = new DOMSelectionState; + // Set when a selection change is detected, cleared on flush + this.selectionChanged = false; + this.delayedFlush = -1; + this.resizeTimeout = -1; + this.queue = []; + this.delayedAndroidKey = null; + this.flushingAndroidKey = -1; + this.lastChange = 0; + this.scrollTargets = []; + this.intersection = null; + this.resizeScroll = null; + this.intersecting = false; + this.gapIntersection = null; + this.gaps = []; + this.printQuery = null; + // Timeout for scheduling check of the parents that need scroll handlers + this.parentCheck = -1; + this.viewportBufferTimeout = -1; + this.dom = view.contentDOM; + this.observer = new MutationObserver(mutations => { + for (let mut of mutations) + this.queue.push(mut); + // IE11 will sometimes (on typing over a selection or + // backspacing out a single character text node) call the + // observer callback before actually updating the DOM. + // + // Unrelatedly, iOS Safari will, when ending a composition, + // sometimes first clear it, deliver the mutations, and then + // reinsert the finished text. CodeMirror's handling of the + // deletion will prevent the reinsertion from happening, + // breaking composition. + if ((browser.ie && browser.ie_version <= 11 || browser.ios && view.composing) && + mutations.some(m => m.type == "childList" && m.removedNodes.length || + m.type == "characterData" && m.oldValue.length > m.target.nodeValue.length)) + this.flushSoon(); + else + this.flush(); + }); + if (window.EditContext && browser.android && view.constructor.EDIT_CONTEXT !== false && + // Chrome <126 doesn't support inverted selections in edit context (#1392) + !(browser.chrome && browser.chrome_version < 126)) { + this.editContext = new EditContextManager(view); + if (view.state.facet(editable)) + view.contentDOM.editContext = this.editContext.editContext; + } + if (useCharData) + this.onCharData = (event) => { + this.queue.push({ target: event.target, + type: "characterData", + oldValue: event.prevValue }); + this.flushSoon(); + }; + this.onSelectionChange = this.onSelectionChange.bind(this); + this.onResize = this.onResize.bind(this); + this.onPrint = this.onPrint.bind(this); + this.onScroll = this.onScroll.bind(this); + if (window.matchMedia) + this.printQuery = window.matchMedia("print"); + if (typeof ResizeObserver == "function") { + this.resizeScroll = new ResizeObserver(() => { + var _a; + if (((_a = this.view.docView) === null || _a === void 0 ? void 0 : _a.lastUpdate) < Date.now() - 75) + this.onResize(); + }); + this.resizeScroll.observe(view.scrollDOM); + } + this.addWindowListeners(this.win = view.win); + this.start(); + if (typeof IntersectionObserver == "function") { + this.intersection = new IntersectionObserver(entries => { + if (this.parentCheck < 0) + this.parentCheck = setTimeout(this.listenForScroll.bind(this), 1000); + if (entries.length > 0 && (entries[entries.length - 1].intersectionRatio > 0) != this.intersecting) { + this.intersecting = !this.intersecting; + if (this.intersecting != this.view.inView) + this.onScrollChanged(document.createEvent("Event")); + } + }, { threshold: [0, .001] }); + this.intersection.observe(this.dom); + this.gapIntersection = new IntersectionObserver(entries => { + if (entries.length > 0 && entries[entries.length - 1].intersectionRatio > 0) + this.onScrollChanged(document.createEvent("Event")); + }, {}); + } + this.listenForScroll(); + this.readSelectionRange(); + } + onScrollChanged(e) { + var _a, _b; + this.view.inputState.runHandlers("scroll", e); + if ((_a = this.view.controlledTouchScrollController) === null || _a === void 0 ? void 0 : _a.onScrollChanged()) + return true; + let input = this.view.inputState, now = Date.now(); + let nativeScroll = ((_b = this.view.controlledTouchScrollController) === null || _b === void 0 ? void 0 : _b.renderCritical) || + isNativeScrollActivity(input.touchActive, input.lastTouchTime, input.lastWheelEvent, now); + let settleDelay = nativeScroll ? this.view.viewState.noteScrollActivity() : null; + if (settleDelay != null) { + clearTimeout(this.viewportBufferTimeout); + this.viewportBufferTimeout = setTimeout(() => { + this.viewportBufferTimeout = -1; + this.view.requestMeasure(); + }, settleDelay + 1); + } + if (this.intersecting) + this.view.measure(); + return false; + } + onScroll(e) { + if (this.intersecting) + this.flush(false); + let controlled = this.onScrollChanged(e); + if (this.editContext && !controlled) + this.view.requestMeasure(this.editContext.measureReq); + } + onResize() { + var _a; + (_a = this.view.controlledTouchScrollController) === null || _a === void 0 ? void 0 : _a.onResize(); + if (this.resizeTimeout < 0) + this.resizeTimeout = setTimeout(() => { + this.resizeTimeout = -1; + this.view.requestMeasure(); + }, 50); + } + onPrint(event) { + var _a; + if ((event.type == "change" || !event.type) && !event.matches) + return; + (_a = this.view.controlledTouchScrollController) === null || _a === void 0 ? void 0 : _a.cancel(); + this.view.viewState.printing = true; + this.view.measure(); + setTimeout(() => { + this.view.viewState.printing = false; + this.view.requestMeasure(); + }, 500); + } + updateGaps(gaps) { + if (this.gapIntersection && (gaps.length != this.gaps.length || this.gaps.some((g, i) => g != gaps[i]))) { + this.gapIntersection.disconnect(); + for (let gap of gaps) + this.gapIntersection.observe(gap); + this.gaps = gaps; + } + } + onSelectionChange(event) { + let wasChanged = this.selectionChanged; + if (!this.readSelectionRange() || this.delayedAndroidKey) + return; + let { view } = this, sel = this.selectionRange; + if (view.state.facet(editable) ? view.root.activeElement != this.dom : !hasSelection(this.dom, sel)) + return; + let context = sel.anchorNode && view.docView.tile.nearest(sel.anchorNode); + if (context && context.isWidget() && context.widget.ignoreEvent(event)) { + if (!wasChanged) + this.selectionChanged = false; + return; + } + // Deletions on IE11 fire their events in the wrong order, giving + // us a selection change event before the DOM changes are + // reported. + // Chrome Android has a similar issue when backspacing out a + // selection (#645). + if ((browser.ie && browser.ie_version <= 11 || browser.android && browser.chrome) && !view.state.selection.main.empty && + // (Selection.isCollapsed isn't reliable on IE) + sel.focusNode && isEquivalentPosition(sel.focusNode, sel.focusOffset, sel.anchorNode, sel.anchorOffset)) + this.flushSoon(); + else + this.flush(false); + } + readSelectionRange() { + let { view } = this; + // The Selection object is broken in shadow roots in Safari. See + // issue #414 + let selection = getSelection(view.root); + if (!selection) + return false; + let range = browser.safari && view.root.nodeType == 11 && + view.root.activeElement == this.dom && + safariSelectionRangeHack(this.view, selection) || selection; + if (!range || this.selectionRange.eq(range)) + return false; + let local = hasSelection(this.dom, range); + // Detect the situation where the browser has, on focus, moved the + // selection to the start of the content element. Reset it to the + // position from the editor state. + if (local && !this.selectionChanged && + view.inputState.lastFocusTime > Date.now() - 200 && + view.inputState.lastTouchTime < Date.now() - 300 && + atElementStart(this.dom, range)) { + this.view.inputState.lastFocusTime = 0; + view.docView.updateSelection(); + return false; + } + this.selectionRange.setRange(range); + if (local) + this.selectionChanged = true; + return true; + } + setSelectionRange(anchor, head) { + this.selectionRange.set(anchor.node, anchor.offset, head.node, head.offset); + this.selectionChanged = false; + } + clearSelectionRange() { + this.selectionRange.set(null, 0, null, 0); + } + listenForScroll() { + this.parentCheck = -1; + let i = 0, changed = null; + for (let dom = this.dom; dom;) { + if (dom.nodeType == 1) { + if (!changed && i < this.scrollTargets.length && this.scrollTargets[i] == dom) + i++; + else if (!changed) + changed = this.scrollTargets.slice(0, i); + if (changed) + changed.push(dom); + dom = dom.assignedSlot || dom.parentNode; + } + else if (dom.nodeType == 11) { // Shadow root + dom = dom.host; + } + else { + break; + } + } + if (i < this.scrollTargets.length && !changed) + changed = this.scrollTargets.slice(0, i); + if (changed) { + for (let dom of this.scrollTargets) + dom.removeEventListener("scroll", this.onScroll); + for (let dom of this.scrollTargets = changed) + dom.addEventListener("scroll", this.onScroll); + } + } + ignore(f) { + if (!this.active) + return f(); + try { + this.stop(); + return f(); + } + finally { + this.start(); + this.clear(); + } + } + start() { + if (this.active) + return; + this.observer.observe(this.dom, observeOptions); + if (useCharData) + this.dom.addEventListener("DOMCharacterDataModified", this.onCharData); + this.active = true; + } + stop() { + if (!this.active) + return; + this.active = false; + this.observer.disconnect(); + if (useCharData) + this.dom.removeEventListener("DOMCharacterDataModified", this.onCharData); + } + // Throw away any pending changes + clear() { + this.processRecords(); + this.queue.length = 0; + this.selectionChanged = false; + } + // Chrome Android, especially in combination with GBoard, not only + // doesn't reliably fire regular key events, but also often + // surrounds the effect of enter or backspace with a bunch of + // composition events that, when interrupted, cause text duplication + // or other kinds of corruption. This hack makes the editor back off + // from handling DOM changes for a moment when such a key is + // detected (via beforeinput or keydown), and then tries to flush + // them or, if that has no effect, dispatches the given key. + delayAndroidKey(key, keyCode) { + var _a; + if (!this.delayedAndroidKey) { + let flush = () => { + let key = this.delayedAndroidKey; + if (key) { + this.clearDelayedAndroidKey(); + this.view.inputState.lastKeyCode = key.keyCode; + this.view.inputState.lastKeyTime = Date.now(); + let flushed = this.flush(); + if (!flushed && key.force) + dispatchKey(this.dom, key.key, key.keyCode); + } + }; + this.flushingAndroidKey = this.view.win.requestAnimationFrame(flush); + } + // Since backspace beforeinput is sometimes signalled spuriously, + // Enter always takes precedence. + if (!this.delayedAndroidKey || key == "Enter") + this.delayedAndroidKey = { + key, keyCode, + // Only run the key handler when no changes are detected if + // this isn't coming right after another change, in which case + // it is probably part of a weird chain of updates, and should + // be ignored if it returns the DOM to its previous state. + force: this.lastChange < Date.now() - 50 || !!((_a = this.delayedAndroidKey) === null || _a === void 0 ? void 0 : _a.force) + }; + } + clearDelayedAndroidKey() { + this.win.cancelAnimationFrame(this.flushingAndroidKey); + this.delayedAndroidKey = null; + this.flushingAndroidKey = -1; + } + flushSoon() { + if (this.delayedFlush < 0) + this.delayedFlush = this.view.win.requestAnimationFrame(() => { this.delayedFlush = -1; this.flush(); }); + } + forceFlush() { + if (this.delayedFlush >= 0) { + this.view.win.cancelAnimationFrame(this.delayedFlush); + this.delayedFlush = -1; + } + this.flush(); + } + pendingRecords() { + for (let mut of this.observer.takeRecords()) + this.queue.push(mut); + return this.queue; + } + processRecords() { + let records = this.pendingRecords(); + if (records.length) + this.queue = []; + let from = -1, to = -1, typeOver = false; + for (let record of records) { + let range = this.readMutation(record); + if (!range) + continue; + if (range.typeOver) + typeOver = true; + if (from == -1) { + ({ from, to } = range); + } + else { + from = Math.min(range.from, from); + to = Math.max(range.to, to); + } + } + return { from, to, typeOver }; + } + readChange() { + let { from, to, typeOver } = this.processRecords(); + let newSel = this.selectionChanged && hasSelection(this.dom, this.selectionRange); + if (from < 0 && !newSel) + return null; + if (from > -1) + this.lastChange = Date.now(); + this.view.inputState.lastFocusTime = 0; + this.selectionChanged = false; + let change = new DOMChange(this.view, from, to, typeOver); + this.view.docView.domChanged = { newSel: change.newSel ? change.newSel.main : null }; + return change; + } + // Apply pending changes, if any + flush(readSelection = true) { + // Completely hold off flushing when pending keys are set—the code + // managing those will make sure processRecords is called and the + // view is resynchronized after + if (this.delayedFlush >= 0 || this.delayedAndroidKey) + return false; + if (readSelection) + this.readSelectionRange(); + let domChange = this.readChange(); + if (!domChange) { + this.view.requestMeasure(); + return false; + } + let startState = this.view.state; + let handled = applyDOMChange(this.view, domChange); + // The view wasn't updated but DOM/selection changes were seen. Reset the view. + if (this.view.state == startState && + (domChange.domChanged || domChange.newSel && !sameSelPos(this.view.state.selection, domChange.newSel.main))) + this.view.update([]); + return handled; + } + readMutation(rec) { + let tile = this.view.docView.tile.nearest(rec.target); + if (!tile || tile.isWidget()) + return null; + tile.markDirty(rec.type == "attributes"); + if (rec.type == "childList") { + let childBefore = findChild(tile, rec.previousSibling || rec.target.previousSibling, -1); + let childAfter = findChild(tile, rec.nextSibling || rec.target.nextSibling, 1); + return { from: childBefore ? tile.posAfter(childBefore) : tile.posAtStart, + to: childAfter ? tile.posBefore(childAfter) : tile.posAtEnd, typeOver: false }; + } + else if (rec.type == "characterData") { + return { from: tile.posAtStart, to: tile.posAtEnd, typeOver: rec.target.nodeValue == rec.oldValue }; + } + else { + return null; + } + } + setWindow(win) { + if (win != this.win) { + this.removeWindowListeners(this.win); + this.win = win; + this.addWindowListeners(this.win); + } + } + addWindowListeners(win) { + win.addEventListener("resize", this.onResize); + if (this.printQuery) { + if (this.printQuery.addEventListener) + this.printQuery.addEventListener("change", this.onPrint); + else + this.printQuery.addListener(this.onPrint); + } + else + win.addEventListener("beforeprint", this.onPrint); + win.addEventListener("scroll", this.onScroll); + win.document.addEventListener("selectionchange", this.onSelectionChange); + } + removeWindowListeners(win) { + win.removeEventListener("scroll", this.onScroll); + win.removeEventListener("resize", this.onResize); + if (this.printQuery) { + if (this.printQuery.removeEventListener) + this.printQuery.removeEventListener("change", this.onPrint); + else + this.printQuery.removeListener(this.onPrint); + } + else + win.removeEventListener("beforeprint", this.onPrint); + win.document.removeEventListener("selectionchange", this.onSelectionChange); + } + update(update) { + if (this.editContext) { + this.editContext.update(update); + if (update.startState.facet(editable) != update.state.facet(editable)) + update.view.contentDOM.editContext = update.state.facet(editable) ? this.editContext.editContext : null; + } + } + destroy() { + var _a, _b, _c; + this.stop(); + (_a = this.intersection) === null || _a === void 0 ? void 0 : _a.disconnect(); + (_b = this.gapIntersection) === null || _b === void 0 ? void 0 : _b.disconnect(); + (_c = this.resizeScroll) === null || _c === void 0 ? void 0 : _c.disconnect(); + for (let dom of this.scrollTargets) + dom.removeEventListener("scroll", this.onScroll); + this.removeWindowListeners(this.win); + clearTimeout(this.parentCheck); + clearTimeout(this.resizeTimeout); + clearTimeout(this.viewportBufferTimeout); + this.win.cancelAnimationFrame(this.delayedFlush); + this.win.cancelAnimationFrame(this.flushingAndroidKey); + if (this.editContext) { + this.view.contentDOM.editContext = null; + this.editContext.destroy(); + } + } +} +function findChild(tile, dom, dir) { + while (dom) { + let curTile = Tile.get(dom); + if (curTile && curTile.parent == tile) + return curTile; + let parent = dom.parentNode; + dom = parent != tile.dom ? parent : dir > 0 ? dom.nextSibling : dom.previousSibling; + } + return null; +} +function buildSelectionRangeFromRange(view, range) { + let anchorNode = range.startContainer, anchorOffset = range.startOffset; + let focusNode = range.endContainer, focusOffset = range.endOffset; + let curAnchor = view.docView.domAtPos(view.state.selection.main.anchor, 1); + // Since such a range doesn't distinguish between anchor and head, + // use a heuristic that flips it around if its end matches the + // current anchor. + if (isEquivalentPosition(curAnchor.node, curAnchor.offset, focusNode, focusOffset)) + [anchorNode, anchorOffset, focusNode, focusOffset] = [focusNode, focusOffset, anchorNode, anchorOffset]; + return { anchorNode, anchorOffset, focusNode, focusOffset }; +} +// Used to work around a Safari Selection/shadow DOM bug (#414) +function safariSelectionRangeHack(view, selection) { + if (selection.getComposedRanges) { + let range = selection.getComposedRanges(view.root)[0]; + if (range) + return buildSelectionRangeFromRange(view, range); + } + let found = null; + // Because Safari (at least in 2018-2021) doesn't provide regular + // access to the selection inside a shadowroot, we have to perform a + // ridiculous hack to get at it—using `execCommand` to trigger a + // `beforeInput` event so that we can read the target range from the + // event. + function read(event) { + event.preventDefault(); + event.stopImmediatePropagation(); + found = event.getTargetRanges()[0]; + } + view.contentDOM.addEventListener("beforeinput", read, true); + view.dom.ownerDocument.execCommand("indent"); + view.contentDOM.removeEventListener("beforeinput", read, true); + return found ? buildSelectionRangeFromRange(view, found) : null; +} +class EditContextManager { + constructor(view) { + // The document window for which the text in the context is + // maintained. For large documents, this may be smaller than the + // editor document. This window always includes the selection head. + this.from = 0; + this.to = 0; + // When applying a transaction, this is used to compare the change + // made to the context content to the change in the transaction in + // order to make the minimal changes to the context (since touching + // that sometimes breaks series of multiple edits made for a single + // user action on some Android keyboards) + this.pendingContextChange = null; + this.handlers = Object.create(null); + // Kludge to work around the fact that EditContext does not respond + // well to having its content updated during a composition (see #1472) + this.composing = null; + this.resetRange(view.state); + let context = this.editContext = new window.EditContext({ + text: view.state.doc.sliceString(this.from, this.to), + selectionStart: this.toContextPos(Math.max(this.from, Math.min(this.to, view.state.selection.main.anchor))), + selectionEnd: this.toContextPos(view.state.selection.main.head) + }); + this.handlers.textupdate = e => { + let main = view.state.selection.main, { anchor, head } = main; + let from = this.toEditorPos(e.updateRangeStart), to = this.toEditorPos(e.updateRangeEnd); + if (view.inputState.composing >= 0 && !this.composing) + this.composing = { contextBase: e.updateRangeStart, editorBase: from, drifted: false }; + let deletes = to - from > e.text.length; + // If the window doesn't include the anchor, assume changes + // adjacent to a side go up to the anchor. + if (from == this.from && anchor < this.from) + from = anchor; + else if (to == this.to && anchor > this.to) + to = anchor; + let diff = findDiff(view.state.sliceDoc(from, to), e.text, (deletes ? main.from : main.to) - from, deletes ? "end" : null); + // Edit contexts sometimes fire empty changes + if (!diff) { + let newSel = state.EditorSelection.single(this.toEditorPos(e.selectionStart), this.toEditorPos(e.selectionEnd)); + if (!sameSelPos(newSel, main)) + view.dispatch({ selection: newSel, userEvent: "select" }); + return; + } + let change = { from: diff.from + from, to: diff.toA + from, + insert: state.Text.of(e.text.slice(diff.from, diff.toB).split("\n")) }; + if ((browser.mac || browser.android) && change.from == head - 1 && + /^\. ?$/.test(e.text) && view.contentDOM.getAttribute("autocorrect") == "off") + change = { from, to, insert: state.Text.of([e.text.replace(".", " ")]) }; + this.pendingContextChange = change; + if (!view.state.readOnly) { + let newLen = this.to - this.from + (change.to - change.from + change.insert.length); + applyDOMChangeInner(view, change, state.EditorSelection.single(this.toEditorPos(e.selectionStart, newLen), this.toEditorPos(e.selectionEnd, newLen))); + } + // If the transaction didn't flush our change, revert it so + // that the context is in sync with the editor state again. + if (this.pendingContextChange) { + this.revertPending(view.state); + this.setSelection(view.state); + } + // Work around missed compositionend events. See https://discuss.codemirror.net/t/a/9514 + if (change.from < change.to && !change.insert.length && view.inputState.composing >= 0 && + !/[\\p{Alphabetic}\\p{Number}_]/.test(context.text.slice(Math.max(0, e.updateRangeStart - 1), Math.min(context.text.length, e.updateRangeStart + 1)))) + this.handlers.compositionend(e); + }; + this.handlers.characterboundsupdate = e => { + let rects = [], prev = null; + for (let i = this.toEditorPos(e.rangeStart), end = this.toEditorPos(e.rangeEnd); i < end; i++) { + let rect = view.coordsForChar(i); + prev = (rect && new DOMRect(rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top)) + || prev || new DOMRect; + rects.push(prev); + } + context.updateCharacterBounds(e.rangeStart, rects); + }; + this.handlers.textformatupdate = e => { + let deco = []; + for (let format of e.getTextFormats()) { + let lineStyle = format.underlineStyle, thickness = format.underlineThickness; + if (!/none/i.test(lineStyle) && !/none/i.test(thickness)) { + let from = this.toEditorPos(format.rangeStart), to = this.toEditorPos(format.rangeEnd); + if (from < to) { + // These values changed from capitalized custom strings to lower-case CSS keywords in 2025 + let style = `text-decoration: underline ${/^[a-z]/.test(lineStyle) ? lineStyle + " " : lineStyle == "Dashed" ? "dashed " : lineStyle == "Squiggle" ? "wavy " : ""}${/thin/i.test(thickness) ? 1 : 2}px`; + deco.push(Decoration.mark({ attributes: { style } }).range(from, to)); + } + } + } + view.dispatch({ effects: setEditContextFormatting.of(Decoration.set(deco)) }); + }; + this.handlers.compositionstart = () => { + if (view.inputState.composing < 0) { + view.inputState.composing = 0; + view.inputState.compositionFirstChange = true; + } + }; + this.handlers.compositionend = () => { + view.inputState.composing = -1; + view.inputState.compositionFirstChange = null; + if (this.composing) { + let { drifted } = this.composing; + this.composing = null; + if (drifted) + this.reset(view.state); + } + }; + for (let event in this.handlers) + context.addEventListener(event, this.handlers[event]); + this.measureReq = { read: view => { + let sel = getSelection(view.root); + if (sel && sel.rangeCount) + this.editContext.updateSelectionBounds(sel.getRangeAt(0).getBoundingClientRect()); + } }; + } + applyEdits(update) { + let off = 0, abort = false, pending = this.pendingContextChange; + update.changes.iterChanges((fromA, toA, _fromB, _toB, insert) => { + if (abort) + return; + let dLen = insert.length - (toA - fromA); + if (pending && toA >= pending.to) { + if (pending.from == fromA && pending.to == toA && pending.insert.eq(insert)) { + pending = this.pendingContextChange = null; // Match + off += dLen; + this.to += dLen; + return; + } + else { // Mismatch, revert + pending = null; + this.revertPending(update.state); + } + } + fromA += off; + toA += off; + if (toA <= this.from) { // Before the window + this.from += dLen; + this.to += dLen; + } + else if (fromA < this.to) { // Overlaps with window + if (fromA < this.from || toA > this.to || (this.to - this.from) + insert.length > 30000 /* CxVp.MaxSize */) { + abort = true; + return; + } + this.editContext.updateText(this.toContextPos(fromA), this.toContextPos(toA), insert.toString()); + this.to += dLen; + } + off += dLen; + }); + if (pending && !abort) + this.revertPending(update.state); + return !abort; + } + update(update) { + let reverted = this.pendingContextChange, startSel = update.startState.selection.main; + if (this.composing && + (this.composing.drifted || + (!update.changes.touchesRange(startSel.from, startSel.to) && + update.transactions.some(tr => !tr.isUserEvent("input.type") && tr.changes.touchesRange(this.from, this.to))))) { + this.composing.drifted = true; + this.composing.editorBase = update.changes.mapPos(this.composing.editorBase); + } + else if (!this.applyEdits(update) || !this.rangeIsValid(update.state)) { + this.pendingContextChange = null; + this.reset(update.state); + } + else if (update.docChanged || update.selectionSet || reverted) { + this.setSelection(update.state); + } + if (update.geometryChanged || update.docChanged || update.selectionSet) + update.view.requestMeasure(this.measureReq); + } + resetRange(state) { + let { head } = state.selection.main; + this.from = Math.max(0, head - 10000 /* CxVp.Margin */); + this.to = Math.min(state.doc.length, head + 10000 /* CxVp.Margin */); + } + reset(state) { + this.resetRange(state); + this.editContext.updateText(0, this.editContext.text.length, state.doc.sliceString(this.from, this.to)); + this.setSelection(state); + } + revertPending(state) { + let pending = this.pendingContextChange; + this.pendingContextChange = null; + this.editContext.updateText(this.toContextPos(pending.from), this.toContextPos(pending.from + pending.insert.length), state.doc.sliceString(pending.from, pending.to)); + } + setSelection(state) { + let { main } = state.selection; + let start = this.toContextPos(Math.max(this.from, Math.min(this.to, main.anchor))); + let end = this.toContextPos(main.head); + if (this.editContext.selectionStart != start || this.editContext.selectionEnd != end) + this.editContext.updateSelection(start, end); + } + rangeIsValid(state) { + let { head } = state.selection.main; + return !(this.from > 0 && head - this.from < 500 /* CxVp.MinMargin */ || + this.to < state.doc.length && this.to - head < 500 /* CxVp.MinMargin */ || + this.to - this.from > 10000 /* CxVp.Margin */ * 3); + } + toEditorPos(contextPos, clipLen = this.to - this.from) { + contextPos = Math.min(contextPos, clipLen); + let c = this.composing; + return c && c.drifted ? c.editorBase + (contextPos - c.contextBase) : contextPos + this.from; + } + toContextPos(editorPos) { + let c = this.composing; + return c && c.drifted ? c.contextBase + (editorPos - c.editorBase) : editorPos - this.from; + } + destroy() { + for (let event in this.handlers) + this.editContext.removeEventListener(event, this.handlers[event]); + } +} + +/* + * Portions derived from Android Open Source Project OverScroller.java. + * Copyright (C) 2010 The Android Open Source Project + * Licensed under the Apache License, Version 2.0. + * Ported to TypeScript and modified for Acode's render-gated scrolling. + */ +const DecelerationRate = Math.log(.78) / Math.log(.9); +const Inflexion = .35; +const StartTension = .5; +const EndTension = 1; +const P1 = StartTension * Inflexion; +const P2 = 1 - EndTension * (1 - Inflexion); +const SampleCount = 100; +const FlingFriction = .015; +const PhysicalCoefficient = 9.80665 * 39.37 * 160 * .84; +const AndroidMinimumFlingVelocity = 50; +const AndroidMaximumFlingVelocity = 8000; +const SplinePosition = (() => { + let result = new Array(SampleCount + 1), xMin = 0; + for (let i = 0; i < SampleCount; i++) { + let alpha = i / SampleCount, xMax = 1, x = 0, coefficient = 0; + for (;;) { + x = xMin + (xMax - xMin) / 2; + coefficient = 3 * x * (1 - x); + let position = coefficient * ((1 - x) * P1 + x * P2) + x * x * x; + if (Math.abs(position - alpha) < 1e-5) + break; + if (position > alpha) + xMax = x; + else + xMin = x; + } + result[i] = coefficient * ((1 - x) * StartTension + x) + x * x * x; + } + result[SampleCount] = 1; + return result; +})(); +/** +Estimate velocity in CSS pixels per second from positions that were +actually committed to the scroller. Linear regression is less sensitive +to uneven touch-event delivery than using only the first and last sample. +*/ +function estimateCommittedVelocity(samples, now, windowMs = 80, fallbackWindowMs = 160) { + let recent = samples.filter(sample => sample.at >= now - windowMs); + if (recent.length < 2) + recent = samples.filter(sample => sample.at >= now - fallbackWindowMs); + if (recent.length < 2) + return 0; + let origin = recent[recent.length - 1].at, meanTime = 0, meanTop = 0; + for (let sample of recent) { + meanTime += sample.at - origin; + meanTop += sample.top; + } + meanTime /= recent.length; + meanTop /= recent.length; + let covariance = 0, variance = 0; + for (let sample of recent) { + let time = sample.at - origin - meanTime; + covariance += time * (sample.top - meanTop); + variance += time * time; + } + return variance > 0 ? covariance / variance * 1000 : 0; +} +function createAndroidSplineFling(velocity) { + if (!Number.isFinite(velocity) || Math.abs(velocity) < AndroidMinimumFlingVelocity) + return null; + let initialVelocity = Math.round(Math.max(-AndroidMaximumFlingVelocity, Math.min(AndroidMaximumFlingVelocity, velocity))); + let deceleration = Math.log(Inflexion * Math.abs(initialVelocity) / + (FlingFriction * PhysicalCoefficient)); + let decelerationMinusOne = DecelerationRate - 1; + let duration = Math.trunc(1000 * Math.exp(deceleration / decelerationMinusOne)); + let unsignedDistance = FlingFriction * PhysicalCoefficient * + Math.exp(DecelerationRate / decelerationMinusOne * deceleration); + return { + initialVelocity, + distance: Math.trunc(unsignedDistance) * Math.sign(initialVelocity), + duration + }; +} +function sampleAndroidSpline(fling, elapsed) { + let time = Math.max(0, Math.min(fling.duration, elapsed)); + let progress = fling.duration ? time / fling.duration : 1; + let index = Math.floor(SampleCount * progress); + let distanceCoefficient = 1, velocityCoefficient = 0; + if (index < SampleCount) { + let lowerTime = index / SampleCount, upperTime = (index + 1) / SampleCount; + let lowerDistance = SplinePosition[index], upperDistance = SplinePosition[index + 1]; + velocityCoefficient = (upperDistance - lowerDistance) / (upperTime - lowerTime); + distanceCoefficient = lowerDistance + (progress - lowerTime) * velocityCoefficient; + } + return { + distance: distanceCoefficient * fling.distance, + velocity: fling.duration ? velocityCoefficient * fling.distance / fling.duration * 1000 : 0, + done: elapsed >= fling.duration + }; +} + +var _a; +const GestureThreshold = 6; +const AxisRatio = 1.25; +const MaximumMomentumFrame = 32; +const MaximumBlockedTime = 120; +const ReleaseMovementFreshness = 100; +const CoverageGuard = 2; +const InitialPreparationScreens = .25; +const MinimumPreparationScreens = .125; +const MaximumPreparationScreens = .5; +const PreparationBudget = 8; +const SlowPreparation = 16; +const hasPendingInput = typeof navigator != "undefined" && ((_a = navigator.scheduling) === null || _a === void 0 ? void 0 : _a.isInputPending) + ? () => navigator.scheduling.isInputPending() : () => false; +/** +@internal +*/ +function claimControlledTouchGesture(deltaX, deltaY, threshold = GestureThreshold, axisRatio = AxisRatio) { + let x = Math.abs(deltaX), y = Math.abs(deltaY); + if (Math.max(x, y) < threshold) + return "pending"; + // Native pan-x cannot provide vertical movement. Give ambiguous diagonals + // to the vertical controller, handing off only clearly horizontal swipes. + return x > y * axisRatio ? "horizontal" : "vertical"; +} +/** +@internal +*/ +class ControlledTouchScroll { + constructor(view, supported = browser.android && browser.chrome) { + this.view = view; + this.supported = supported; + this.config = null; + this.enabled = false; + this.destroyed = false; + this.previousTouchAction = ""; + this.resizeWindow = null; + this.touchScrolled = false; + this.tracking = false; + this.claimed = false; + this.didScroll = false; + this.endRequested = false; + this.endRequestedAt = 0; + this.startX = 0; + this.startY = 0; + this.lastX = 0; + this.lastY = 0; + this.lastScrollLeft = 0; + this.controlledTop = null; + this.lastTouchMovement = 0; + this.pendingDelta = 0; + this.direction = 0; + this.movementFrame = -1; + this.corridorFrom = 0; + this.corridorTo = 0; + this.corridorMode = "none"; + this.idleTargetFrom = 0; + this.idleTargetTo = 0; + this.idlePrewarmReady = false; + this.prewarmTimer = -1; + this.preparing = false; + this.preparationStarted = 0; + this.preparationDuration = 0; + this.preparationChunkScreens = InitialPreparationScreens; + this.fastPreparations = 0; + this.generation = 0; + this.highlightCoverageManaged = false; + this.highlightCoverageReady = false; + this.highlightFrom = 0; + this.highlightTo = 0; + this.highlightRefreshTimer = -1; + this.gestureStarted = 0; + this.firstMovementDelay = 0; + this.momentum = false; + this.momentumFrame = -1; + this.fling = null; + this.momentumStarted = 0; + this.momentumElapsed = 0; + this.momentumDistance = 0; + this.momentumStartTop = 0; + this.lastMomentumCommit = 0; + this.releaseVelocity = 0; + this.expectedMomentumDistance = 0; + this.expectedMomentumDuration = 0; + this.actualMomentumDuration = 0; + this.currentMomentumVelocity = 0; + this.committedMomentumDistance = 0; + this.momentumRenderLimited = false; + this.momentumTermination = null; + this.settleTimer = -1; + this.samples = []; + this.onTouchStart = (event) => { + var _a, _b, _c, _d; + let target = event.target; + let nonEditable = (_a = target === null || target === void 0 ? void 0 : target.closest) === null || _a === void 0 ? void 0 : _a.call(target, "[contenteditable=false]"); + if (!this.enabled || event.defaultPrevented || event.touches.length != 1 || + target != this.view.scrollDOM && (!target || !this.view.contentDOM.contains(target) || + ((_b = target.closest) === null || _b === void 0 ? void 0 : _b.call(target, "input, textarea, select, button, a")) || + nonEditable && nonEditable != this.view.contentDOM)) { + this.cancel(); + return; + } + this.stopMomentum("cancelled"); + this.clearHighlightCoverageRefresh(); + this.clearPrewarmTimer(); + let touch = event.touches[0]; + this.touchScrolled = false; + this.tracking = true; + this.claimed = this.didScroll = this.endRequested = false; + this.endRequestedAt = 0; + this.startX = this.lastX = touch.clientX; + this.startY = this.lastY = touch.clientY; + this.lastTouchMovement = this.now(); + this.gestureStarted = this.lastTouchMovement; + this.firstMovementDelay = 0; + this.lastScrollLeft = this.view.scrollDOM.scrollLeft; + this.controlledTop = this.view.scrollDOM.scrollTop; + this.pendingDelta = 0; + this.direction = 0; + this.samples = [{ at: ((_c = this.config) === null || _c === void 0 ? void 0 : _c.waitForRendering) ? this.now() : event.timeStamp, + top: ((_d = this.config) === null || _d === void 0 ? void 0 : _d.waitForRendering) ? this.view.scrollDOM.scrollTop : 0 }]; + this.releaseVelocity = 0; + this.expectedMomentumDistance = this.expectedMomentumDuration = 0; + this.actualMomentumDuration = 0; + this.currentMomentumVelocity = this.committedMomentumDistance = 0; + this.momentumTermination = null; + this.clearSettleTimer(); + }; + this.onTouchMove = (event) => { + if (!this.tracking) + return; + if (event.touches.length != 1) { + this.cancel(); + return; + } + let touch = event.touches[0], justClaimed = false; + if (!this.claimed) { + let claim = claimControlledTouchGesture(touch.clientX - this.startX, touch.clientY - this.startY); + if (claim == "pending") + return; + if (claim == "horizontal") { + this.tracking = false; + if (this.corridorMode != "idle") + this.scheduleSettle(this.config.settleDelay); + return; + } + this.touchScrolled = true; + this.claimed = justClaimed = true; + this.activateCorridor(); + } + if (event.cancelable) + event.preventDefault(); + let delta = (justClaimed ? this.startY : this.lastY) - touch.clientY; + if (Math.abs(delta) > .1) { + this.lastTouchMovement = this.now(); + let direction = delta < 0 ? -1 : 1; + if (!this.config.waitForRendering && this.direction && direction != this.direction) + this.samples = this.samples.slice(-1); + this.direction = direction; + this.pendingDelta += delta; + if (!this.config.waitForRendering) { + // Use physical touch samples, independent of when DOM work completes. + this.recordSample(this.startY - touch.clientY, event.timeStamp); + } + let bounds = this.view.viewState.controlledScrollBounds(); + let target = this.view.scrollDOM.scrollTop + this.pendingDelta; + if (!this.config.waitForRendering || justClaimed && target >= bounds.from && target <= bounds.to) + this.applyMovement(); + else + this.scheduleMovement(); + } + this.lastX = touch.clientX; + this.lastY = touch.clientY; + }; + this.onTouchEnd = (event) => { + var _a, _b, _c; + // Cancellation may release the scroll anchor, but must not turn a drag + // into a native tap on the contenteditable (which reopens the keyboard). + if (this.touchScrolled && event.cancelable) + event.preventDefault(); + this.touchScrolled = false; + if (!this.tracking) + return; + if (!this.claimed) { + this.tracking = false; + this.samples = []; + // A tap may focus the editor and scroll its caret into view. No pinned + // render corridor should survive to compete with that native action. + this.releaseCorridor(); + this.scheduleIdlePrewarm((_b = (_a = this.config) === null || _a === void 0 ? void 0 : _a.settleDelay) !== null && _b !== void 0 ? _b : 0); + return; + } + if (this.claimed && event.cancelable) + event.preventDefault(); + this.tracking = false; + this.endRequested = true; + this.endRequestedAt = this.now(); + if ((_c = this.config) === null || _c === void 0 ? void 0 : _c.waitForRendering) + this.scheduleMovement(); + else + this.finishGesture(); + }; + this.onTouchCancel = () => this.cancel(); + this.onScrollEnd = () => { + var _a, _b; + if (!this.tracking && !this.claimed && !this.momentum && !this.preparing && + this.view.viewState.controlledScrollActive) + this.scheduleSettle((_b = (_a = this.config) === null || _a === void 0 ? void 0 : _a.settleDelay) !== null && _b !== void 0 ? _b : 0); + }; + this.onCompositionStart = () => this.cancel(); + this.onFocusChange = () => { + // Focusing on touchstart must not swallow the remainder of an unclaimed + // gesture. Such a gesture has not acquired the scroll anchor yet. + if (this.tracking && !this.claimed) + this.releaseCorridor(); + else + this.cancel(); + }; + /** + @internal Shared by window, visualViewport, and ResizeObserver callbacks. + */ + this.onResize = () => { + if (!this.tracking) { + this.cancel(); + return; + } + // Keyboard animation can resize the viewport several times while a finger + // is down. Release stale geometry/velocity, keeping the touch identity and + // its tap-vs-drag decision until the actual end/cancel event. + this.stopMovementFrame(); + this.stopMomentum("cancelled"); + this.pendingDelta = 0; + this.samples = []; + this.releaseCorridor(); + this.controlledTop = this.view.scrollDOM.scrollTop; + this.lastScrollLeft = this.view.scrollDOM.scrollLeft; + if (this.claimed) + this.activateCorridor(); + }; + this.applyMovement = () => { + var _a; + this.movementFrame = -1; + if (!this.claimed) + return; + let delta = this.pendingDelta; + this.pendingDelta = 0; + if (Math.abs(delta) > .1) { + let scroller = this.view.scrollDOM, bounds = this.movementBounds(); + let target = Math.max(bounds.from, Math.min(bounds.to, scroller.scrollTop + delta)); + let previous = scroller.scrollTop; + if (Math.abs(target - previous) > .1) { + scroller.scrollTop = target; + let committed = scroller.scrollTop; + if (Math.abs(committed - previous) > .1) { + this.didScroll = true; + if (!this.firstMovementDelay) + this.firstMovementDelay = Math.max(0, this.now() - this.gestureStarted); + this.controlledTop = committed; + if ((_a = this.config) === null || _a === void 0 ? void 0 : _a.waitForRendering) + this.recordSample(committed); + } + } + } + this.maybeExtendCorridor(); + if (this.endRequested) + this.finishGesture(); + }; + this.startIdlePrewarm = () => { + var _a, _b, _c; + this.clearPrewarmTimer(); + if (!((_a = this.config) === null || _a === void 0 ? void 0 : _a.waitForRendering)) + return; + if (!this.enabled || this.destroyed || this.tracking || this.claimed || this.momentum || this.preparing) + return; + if (hasPendingInput()) { + this.scheduleIdlePrewarm(16); + return; + } + let scroller = this.view.scrollDOM, height = scroller.clientHeight; + if (height <= 0) { + this.scheduleIdlePrewarm((_c = (_b = this.config) === null || _b === void 0 ? void 0 : _b.settleDelay) !== null && _c !== void 0 ? _c : 0); + return; + } + let top = scroller.scrollTop; + let retainCurrent = this.corridorMode != "none" && this.view.viewState.controlledScrollActive; + this.corridorMode = "idle"; + this.idlePrewarmReady = false; + this.idleTargetFrom = Math.max(0, top - height); + this.idleTargetTo = Math.min(scroller.scrollHeight, top + height * 2); + if (!retainCurrent) { + this.corridorFrom = top; + this.corridorTo = top + height; + this.setCorridor(false); + } + this.continueIdlePrewarm(); + }; + this.applyMomentum = () => { + var _a, _b, _c, _d; + this.momentumFrame = -1; + let fling = this.fling; + if (!this.momentum || !fling) + return; + let now = this.now(), elapsed = Math.max(0, now - this.momentumStarted); + let previousSample = sampleAndroidSpline(fling, this.momentumElapsed); + let sample = sampleAndroidSpline(fling, elapsed); + let rawDelta = sample.distance - this.momentumDistance; + let frameTime = Math.max(0, elapsed - this.momentumElapsed); + // Only the coverage-gated mode can run out of safe rendered movement. + // A busy main thread must not cancel an unrestricted fling: sample its + // elapsed physical time so it resumes with the correct decayed velocity. + if (((_a = this.config) === null || _a === void 0 ? void 0 : _a.waitForRendering) && frameTime >= MaximumBlockedTime) { + this.finishMomentum("render-limited"); + return; + } + let maximumStep = Math.abs(previousSample.velocity) * Math.min(MaximumMomentumFrame, frameTime) / 1000; + let delta = ((_b = this.config) === null || _b === void 0 ? void 0 : _b.waitForRendering) + ? Math.sign(rawDelta) * Math.min(Math.abs(rawDelta), maximumStep) : rawDelta; + if (((_c = this.config) === null || _c === void 0 ? void 0 : _c.waitForRendering) && frameTime > MaximumMomentumFrame + .5) + this.momentumRenderLimited = true; + this.momentumElapsed = elapsed; + this.momentumDistance = sample.distance; + this.currentMomentumVelocity = sample.velocity; + let scroller = this.view.scrollDOM, bounds = this.movementBounds(); + let previous = scroller.scrollTop; + let target = Math.max(bounds.from, Math.min(bounds.to, previous + delta)); + let maximum = Math.max(0, scroller.scrollHeight - scroller.clientHeight); + if (Math.abs(target - previous) + .1 < Math.abs(delta) && + !(this.direction < 0 ? previous <= .5 : previous >= maximum - .5)) + this.momentumRenderLimited = true; + if (Math.abs(target - previous) > .1) { + scroller.scrollTop = target; + let committed = scroller.scrollTop; + if (Math.abs(committed - previous) > .1) { + this.lastMomentumCommit = now; + this.controlledTop = committed; + this.committedMomentumDistance = committed - this.momentumStartTop; + } + } + this.maybeExtendCorridor(); + let atDocumentEdge = this.direction < 0 ? scroller.scrollTop <= .5 : scroller.scrollTop >= maximum - .5; + if (atDocumentEdge) + this.finishMomentum("edge"); + else if (sample.done || Math.abs(sample.velocity) < AndroidMinimumFlingVelocity) + this.finishMomentum(this.momentumRenderLimited ? "render-limited" : "completed"); + else if (((_d = this.config) === null || _d === void 0 ? void 0 : _d.waitForRendering) && now - this.lastMomentumCommit >= MaximumBlockedTime) + this.finishMomentum("render-limited"); + else + this.scheduleMomentum(); + }; + this.settle = () => { + this.clearSettleTimer(); + this.stopMomentum(); + this.didScroll = false; + this.samples = []; + this.scheduleIdlePrewarm(0); + }; + this.syncConfig(); + } + /** + @internal + */ + get renderCritical() { + return this.claimed || this.momentum || + (this.corridorMode == "active" && this.preparing) || + this.movementFrame > -1 || this.momentumFrame > -1; + } + beforeUpdate(transactions) { + // Navigation and native selection must release the anchor before the + // transaction is measured, even when the requested offset is in coverage. + if (transactions.some(tr => tr.docChanged || tr.reconfigured || tr.selection || + tr.scrollIntoView || tr.effects.some(effect => effect.is(scrollIntoView)))) + this.cancel(); + } + update(update) { + if (update.transactions.some(tr => tr.reconfigured)) + this.syncConfig(); + } + setState() { + this.cancel(); + this.syncConfig(); + } + setWindow() { + if (!this.enabled) + return; + this.unbindResize(); + this.bindResize(); + } + onScrollChanged() { + var _a, _b, _c; + if (!this.enabled) + return false; + if (!((_a = this.config) === null || _a === void 0 ? void 0 : _a.waitForRendering)) { + if (this.ownsScroll && (this.controlledTop != null && + Math.abs(this.view.scrollDOM.scrollTop - this.controlledTop) > 1 || + Math.abs(this.view.scrollDOM.scrollLeft - this.lastScrollLeft) > 1)) + this.cancel(); + // Let the normal viewport measurement/rendering path follow each scroll. + return false; + } + if (!this.ownsScroll) { + let top = this.view.scrollDOM.scrollTop; + if (this.corridorMode == "active" && this.didScroll && this.controlledTop != null && + Math.abs(top - this.controlledTop) <= 1 && + Math.abs(this.view.scrollDOM.scrollLeft - this.lastScrollLeft) <= 1) + return true; + this.invalidateHighlightCoverage(); + this.scheduleHighlightCoverageRefresh(); + if (this.corridorMode != "none") { + this.releaseCorridor(false); + this.scheduleIdlePrewarm((_c = (_b = this.config) === null || _b === void 0 ? void 0 : _b.settleDelay) !== null && _c !== void 0 ? _c : 0); + } + return false; + } + let bounds = this.view.viewState.controlledScrollBounds(); + let top = this.view.scrollDOM.scrollTop; + if (top < bounds.from - CoverageGuard || top > bounds.to + CoverageGuard || + this.controlledTop != null && Math.abs(top - this.controlledTop) > 1 || + Math.abs(this.view.scrollDOM.scrollLeft - this.lastScrollLeft) > 1) { + this.cancel(); + return false; + } + return true; + } + debugSnapshot() { + var _a, _b; + return { + waitForRendering: (_b = (_a = this.config) === null || _a === void 0 ? void 0 : _a.waitForRendering) !== null && _b !== void 0 ? _b : true, + active: this.momentum, + releaseVelocity: this.releaseVelocity, + expectedDistance: this.expectedMomentumDistance, + expectedDuration: this.expectedMomentumDuration, + actualDuration: this.actualMomentumDuration, + currentVelocity: this.currentMomentumVelocity, + committedDistance: this.committedMomentumDistance, + termination: this.momentumTermination, + corridorFrom: this.corridorFrom, + corridorTo: this.corridorTo, + idlePrewarmReady: this.idlePrewarmReady, + preparationDuration: this.preparationDuration, + preparationChunkScreens: this.preparationChunkScreens, + firstMovementDelay: this.firstMovementDelay, + remainingSafeDistance: this.remainingSafeDistance() + }; + } + /** + Keep CodeMirror's height-map anchor corrections inside DOM that is + already present. Wrapped-line measurement can otherwise turn a small + controlled movement into a very large scrollTop correction. + */ + clampToRenderedOffset(target) { + var _a; + if (!this.enabled || !this.ownsScroll) + return target; + let bounds = this.movementBounds(); + let pinned = (_a = this.controlledTop) !== null && _a !== void 0 ? _a : target; + return Math.max(bounds.from, Math.min(bounds.to, pinned)); + } + /** + @internal Called by the language highlighter after a complete visual + replacement has been published for the current rendered viewport. + */ + onHighlightCoverageReady() { + if (!this.enabled || this.destroyed) + return; + this.clearHighlightCoverageRefresh(); + let bounds = this.view.viewState.controlledScrollBounds(); + this.highlightCoverageManaged = this.highlightCoverageReady = true; + this.highlightFrom = bounds.from; + this.highlightTo = bounds.to; + if (this.corridorMode == "idle") + this.scheduleIdleContinuation(); + } + /** + @internal Drop visual coverage on language/style/parser invalidation. + */ + onHighlightCoverageInvalidated() { + if (!this.enabled || this.destroyed) + return; + this.invalidateHighlightCoverage(); + } + /** + @internal Plain text and removed highlighters have no coverage to await. + */ + onHighlightCoverageRemoved() { + this.highlightCoverageManaged = this.highlightCoverageReady = false; + this.clearHighlightCoverageRefresh(); + if (this.corridorMode == "idle") + this.scheduleIdleContinuation(); + } + cancel() { + var _a, _b; + if (this.destroyed) + return; + this.clearHighlightCoverageRefresh(); + this.clearPrewarmTimer(); + this.stopMovementFrame(); + this.stopMomentum("cancelled"); + this.tracking = this.claimed = this.didScroll = this.endRequested = false; + this.pendingDelta = 0; + this.direction = 0; + this.samples = []; + this.releaseCorridor(); + if (this.enabled) + this.scheduleIdlePrewarm((_b = (_a = this.config) === null || _a === void 0 ? void 0 : _a.settleDelay) !== null && _b !== void 0 ? _b : 0); + } + destroy() { + if (this.destroyed) + return; + this.cancel(); + this.destroyed = true; + this.disable(); + } + get ownsScroll() { + return this.claimed || this.momentum || + this.corridorMode == "active" && this.preparing; + } + now() { + var _a, _b; + return (_b = (_a = this.view.win.performance) === null || _a === void 0 ? void 0 : _a.now()) !== null && _b !== void 0 ? _b : Date.now(); + } + syncConfig() { + if (this.destroyed) + return; + this.config = this.view.state.facet(controlledTouchScroll); + let shouldEnable = !!this.config && this.supported; + if (shouldEnable == this.enabled) + return; + if (shouldEnable) + this.enable(); + else { + this.cancel(); + this.disable(); + } + } + enable() { + var _a, _b; + if (this.enabled) + return; + this.enabled = true; + this.previousTouchAction = this.view.scrollDOM.style.touchAction; + this.view.scrollDOM.style.touchAction = "pan-x"; + this.view.scrollDOM.addEventListener("touchstart", this.onTouchStart, { passive: false }); + this.view.scrollDOM.addEventListener("touchmove", this.onTouchMove, { passive: false }); + this.view.scrollDOM.addEventListener("touchend", this.onTouchEnd, { passive: false }); + this.view.scrollDOM.addEventListener("touchcancel", this.onTouchCancel); + this.view.scrollDOM.addEventListener("scrollend", this.onScrollEnd); + this.view.contentDOM.addEventListener("compositionstart", this.onCompositionStart); + this.view.contentDOM.addEventListener("focus", this.onFocusChange); + this.view.contentDOM.addEventListener("blur", this.onFocusChange); + this.view.scrollDOM.addEventListener("contextmenu", this.onTouchCancel); + this.bindResize(); + this.scheduleIdlePrewarm((_b = (_a = this.config) === null || _a === void 0 ? void 0 : _a.settleDelay) !== null && _b !== void 0 ? _b : 0); + } + disable() { + if (!this.enabled) + return; + this.enabled = false; + this.view.scrollDOM.removeEventListener("touchstart", this.onTouchStart); + this.view.scrollDOM.removeEventListener("touchmove", this.onTouchMove); + this.view.scrollDOM.removeEventListener("touchend", this.onTouchEnd); + this.view.scrollDOM.removeEventListener("touchcancel", this.onTouchCancel); + this.view.scrollDOM.removeEventListener("scrollend", this.onScrollEnd); + this.view.contentDOM.removeEventListener("compositionstart", this.onCompositionStart); + this.view.contentDOM.removeEventListener("focus", this.onFocusChange); + this.view.contentDOM.removeEventListener("blur", this.onFocusChange); + this.view.scrollDOM.removeEventListener("contextmenu", this.onTouchCancel); + this.unbindResize(); + this.clearPrewarmTimer(); + this.view.scrollDOM.style.touchAction = this.previousTouchAction; + } + bindResize() { + var _a; + this.resizeWindow = this.view.win; + this.resizeWindow.addEventListener("resize", this.onResize); + (_a = this.resizeWindow.visualViewport) === null || _a === void 0 ? void 0 : _a.addEventListener("resize", this.onResize); + this.resizeWindow.addEventListener("blur", this.onTouchCancel); + } + unbindResize() { + var _a, _b, _c, _d; + (_a = this.resizeWindow) === null || _a === void 0 ? void 0 : _a.removeEventListener("resize", this.onResize); + (_c = (_b = this.resizeWindow) === null || _b === void 0 ? void 0 : _b.visualViewport) === null || _c === void 0 ? void 0 : _c.removeEventListener("resize", this.onResize); + (_d = this.resizeWindow) === null || _d === void 0 ? void 0 : _d.removeEventListener("blur", this.onTouchCancel); + this.resizeWindow = null; + } + activateCorridor() { + var _a; + if (!((_a = this.config) === null || _a === void 0 ? void 0 : _a.waitForRendering)) + return; + this.corridorMode = "active"; + this.idlePrewarmReady = false; + if (this.view.viewState.controlledScrollActive) + return; + let top = this.view.scrollDOM.scrollTop, height = this.view.scrollDOM.clientHeight; + this.corridorFrom = top; + this.corridorTo = top + height; + } + setCorridor(prepare = true) { + let scale = this.view.viewState.scaleY || 1; + let max = (this.view.scrollDOM.scrollHeight + this.view.scrollDOM.clientHeight) * scale; + let changed = this.view.viewState.setControlledScrollCorridor(Math.max(0, this.corridorFrom * scale), Math.min(max, this.corridorTo * scale)); + if (changed && prepare) + this.requestPreparation(); + } + requestPreparation() { + if (this.preparing || this.destroyed) + return; + this.preparing = true; + this.preparationStarted = this.now(); + let generation = ++this.generation; + this.view.requestMeasure({ + key: this, + read: () => generation, + write: measuredGeneration => { + if (this.destroyed || measuredGeneration != this.generation) + return; + this.preparing = false; + this.recordPreparation(this.now() - this.preparationStarted); + if (this.corridorMode == "idle") + this.scheduleIdleContinuation(); + if (this.momentum) + this.scheduleMomentum(); + } + }); + } + scheduleMovement() { + if (this.movementFrame > -1 || this.destroyed) + return; + this.movementFrame = this.view.win.requestAnimationFrame(this.applyMovement); + } + stopMovementFrame() { + if (this.movementFrame > -1) + this.view.win.cancelAnimationFrame(this.movementFrame); + this.movementFrame = -1; + } + recordSample(top, now = this.now()) { + this.samples.push({ at: now, top }); + while (this.samples.length > 2 && this.samples[0].at < now - 80) + this.samples.shift(); + } + remainingSafeDistance() { + if (!this.enabled || !this.direction) + return 0; + let bounds = this.movementBounds(); + let top = this.view.scrollDOM.scrollTop; + return Math.max(0, this.direction > 0 ? bounds.to - top : top - bounds.from); + } + movementBounds() { + var _a; + if (!((_a = this.config) === null || _a === void 0 ? void 0 : _a.waitForRendering)) + return { + from: 0, + to: Math.max(0, this.view.scrollDOM.scrollHeight - this.view.scrollDOM.clientHeight) + }; + let bounds = this.view.viewState.controlledScrollBounds(); + if (!this.highlightCoverageManaged) + return bounds; + if (!this.highlightCoverageReady) { + let top = this.view.scrollDOM.scrollTop; + return { from: top, to: top }; + } + let from = Math.max(bounds.from, this.highlightFrom); + let to = Math.min(bounds.to, this.highlightTo); + if (to < from) { + let top = this.view.scrollDOM.scrollTop; + return { from: top, to: top }; + } + return { from, to }; + } + invalidateHighlightCoverage() { + if (!this.highlightCoverageManaged) + return; + let top = this.view.scrollDOM.scrollTop; + this.highlightCoverageReady = false; + this.highlightFrom = this.highlightTo = top; + } + scheduleHighlightCoverageRefresh() { + var _a, _b; + if (!this.highlightCoverageManaged || this.destroyed) + return; + this.clearHighlightCoverageRefresh(); + this.highlightRefreshTimer = setTimeout(() => { + this.highlightRefreshTimer = -1; + if (!this.destroyed && !this.tracking && !this.claimed && !this.momentum) + this.view.dispatch({}); + }, ((_b = (_a = this.config) === null || _a === void 0 ? void 0 : _a.settleDelay) !== null && _b !== void 0 ? _b : 0) + 1); + } + clearHighlightCoverageRefresh() { + if (this.highlightRefreshTimer > -1) + clearTimeout(this.highlightRefreshTimer); + this.highlightRefreshTimer = -1; + } + recordPreparation(duration) { + this.preparationDuration = Math.max(0, duration); + if (duration > SlowPreparation) { + this.preparationChunkScreens = Math.max(MinimumPreparationScreens, this.preparationChunkScreens / 2); + this.fastPreparations = 0; + } + else if (duration <= PreparationBudget) { + if (++this.fastPreparations >= 2) { + this.preparationChunkScreens = Math.min(MaximumPreparationScreens, this.preparationChunkScreens * 1.5); + this.fastPreparations = 0; + } + } + else { + this.fastPreparations = 0; + } + } + scheduleIdlePrewarm(delay) { + var _a; + if (!this.enabled || this.destroyed || !((_a = this.config) === null || _a === void 0 ? void 0 : _a.waitForRendering)) + return; + this.clearPrewarmTimer(); + this.prewarmTimer = setTimeout(this.startIdlePrewarm, Math.max(0, delay) + 1); + } + clearPrewarmTimer() { + if (this.prewarmTimer > -1) + clearTimeout(this.prewarmTimer); + this.prewarmTimer = -1; + } + scheduleIdleContinuation() { + if (this.prewarmTimer > -1 || this.destroyed || this.corridorMode != "idle") + return; + this.prewarmTimer = setTimeout(() => { + this.clearPrewarmTimer(); + this.continueIdlePrewarm(); + }, 16); + } + continueIdlePrewarm() { + if (this.corridorMode != "idle" || this.destroyed || this.tracking || + this.claimed || this.momentum || this.preparing) + return; + if (this.corridorFrom <= this.idleTargetFrom + 1 && + this.corridorTo >= this.idleTargetTo - 1) { + if (Math.abs(this.corridorFrom - this.idleTargetFrom) > 1 || + Math.abs(this.corridorTo - this.idleTargetTo) > 1) { + this.corridorFrom = this.idleTargetFrom; + this.corridorTo = this.idleTargetTo; + this.setCorridor(); + return; + } + let bounds = this.view.viewState.controlledScrollBounds(); + this.idlePrewarmReady = !this.highlightCoverageManaged || + this.highlightCoverageReady && this.highlightFrom <= bounds.from + CoverageGuard && + this.highlightTo >= bounds.to - CoverageGuard; + if (!this.idlePrewarmReady) + return; + return; + } + if (hasPendingInput()) { + this.scheduleIdleContinuation(); + return; + } + let chunk = this.view.scrollDOM.clientHeight * this.preparationChunkScreens; + this.corridorFrom = Math.max(this.idleTargetFrom, this.corridorFrom - chunk); + this.corridorTo = Math.min(this.idleTargetTo, this.corridorTo + chunk); + this.setCorridor(); + } + maybeExtendCorridor() { + var _a; + if (!((_a = this.config) === null || _a === void 0 ? void 0 : _a.waitForRendering) || this.preparing || hasPendingInput()) + return; + let scroller = this.view.scrollDOM, height = scroller.clientHeight; + if (this.remainingSafeDistance() > height) + return; + let chunk = height * this.preparationChunkScreens; + if (this.direction > 0) { + let maximum = scroller.scrollTop + height * (this.config.maxAhead + 1); + let next = Math.min(maximum, this.corridorTo + chunk); + if (next <= this.corridorTo + 1) + return; + this.corridorTo = next; + this.corridorFrom = Math.max(this.corridorFrom, scroller.scrollTop - height); + } + else { + let minimum = Math.max(0, scroller.scrollTop - height * this.config.maxAhead); + let next = Math.max(minimum, this.corridorFrom - chunk); + if (next >= this.corridorFrom - 1) + return; + this.corridorFrom = next; + this.corridorTo = Math.min(this.corridorTo, scroller.scrollTop + height * 2); + } + this.setCorridor(); + } + finishGesture() { + var _a, _b; + this.endRequested = false; + this.claimed = false; + if (!this.didScroll || !this.config) { + this.scheduleSettle((_b = (_a = this.config) === null || _a === void 0 ? void 0 : _a.settleDelay) !== null && _b !== void 0 ? _b : 0); + return; + } + let scroller = this.view.scrollDOM, now = this.now(); + let latestSample = this.samples[this.samples.length - 1]; + let velocity = this.endRequestedAt - this.lastTouchMovement <= ReleaseMovementFreshness && latestSample + ? estimateCommittedVelocity(this.samples, latestSample.at) + : 0; + let fling = createAndroidSplineFling(velocity); + if (!fling) { + this.scheduleSettle(this.config.settleDelay); + return; + } + let direction = Math.sign(fling.distance); + this.direction = direction; + this.activateCorridor(); + this.clearSettleTimer(); + this.momentum = true; + this.fling = fling; + this.momentumStarted = this.lastMomentumCommit = now; + this.momentumElapsed = this.momentumDistance = 0; + this.momentumStartTop = scroller.scrollTop; + this.releaseVelocity = fling.initialVelocity; + this.expectedMomentumDistance = fling.distance; + this.expectedMomentumDuration = fling.duration; + this.actualMomentumDuration = 0; + this.currentMomentumVelocity = fling.initialVelocity; + this.committedMomentumDistance = 0; + this.momentumRenderLimited = false; + this.momentumTermination = null; + this.scheduleMomentum(); + } + scheduleMomentum() { + if (!this.momentum || this.momentumFrame > -1 || this.destroyed) + return; + this.momentumFrame = this.view.win.requestAnimationFrame(this.applyMomentum); + } + finishMomentum(reason) { + var _a, _b; + this.stopMomentum(reason); + this.scheduleSettle((_b = (_a = this.config) === null || _a === void 0 ? void 0 : _a.settleDelay) !== null && _b !== void 0 ? _b : 0); + } + stopMomentum(reason) { + if (this.momentumFrame > -1) + this.view.win.cancelAnimationFrame(this.momentumFrame); + this.momentumFrame = -1; + if (this.momentum && reason) { + this.momentumTermination = reason; + this.actualMomentumDuration = Math.max(0, this.now() - this.momentumStarted); + } + this.momentum = false; + this.fling = null; + this.momentumElapsed = this.momentumDistance = 0; + this.currentMomentumVelocity = 0; + this.clearSettleTimer(); + } + scheduleSettle(delay) { + this.clearSettleTimer(); + this.settleTimer = setTimeout(this.settle, delay + 1); + } + clearSettleTimer() { + if (this.settleTimer > -1) + clearTimeout(this.settleTimer); + this.settleTimer = -1; + } + releaseCorridor(requestMeasure = true) { + this.clearSettleTimer(); + this.preparing = false; + this.controlledTop = null; + this.corridorMode = "none"; + this.idlePrewarmReady = false; + this.idleTargetFrom = this.idleTargetTo = 0; + this.generation++; + if (this.view.viewState.clearControlledScrollCorridor() && requestMeasure) + this.view.requestMeasure(); + } +} + +// The editor's update state machine looks something like this: +// +// Idle → Updating ⇆ Idle (unchecked) → Measuring → Idle +// ↑ ↓ +// Updating (measure) +// +// The difference between 'Idle' and 'Idle (unchecked)' lies in +// whether a layout check has been scheduled. A regular update through +// the `update` method updates the DOM in a write-only fashion, and +// relies on a check (scheduled with `requestAnimationFrame`) to make +// sure everything is where it should be and the viewport covers the +// visible code. That check continues to measure and then optionally +// update until it reaches a coherent state. +/** +An editor view represents the editor's user interface. It holds +the editable DOM surface, and possibly other elements such as the +line number gutter. It handles events and dispatches state +transactions for editing actions. +*/ +class EditorView { + /** + The current editor state. + */ + get state() { return this.viewState.state; } + /** + To be able to display large documents without consuming too much + memory or overloading the browser, CodeMirror only draws the + code that is visible (plus a margin around it) to the DOM. This + property tells you the extent of the current drawn viewport, in + document positions. + */ + get viewport() { return this.viewState.viewport; } + /** + When there are, for example, large collapsed ranges in the + viewport, its size can be a lot bigger than the actual visible + content. Thus, if you are doing something like styling the + content in the viewport, it is preferable to only do so for + these ranges, which are the subset of the viewport that is + actually drawn. + */ + get visibleRanges() { return this.viewState.visibleRanges; } + /** + Returns false when the editor is entirely scrolled out of view + or otherwise hidden. + */ + get inView() { return this.viewState.inView; } + /** + Indicates whether the user is currently composing text via + [IME](https://en.wikipedia.org/wiki/Input_method), and at least + one change has been made in the current composition. + */ + get composing() { return !!this.inputState && this.inputState.composing > 0; } + /** + Indicates whether the user is currently in composing state. Note + that on some platforms, like Android, this will be the case a + lot, since just putting the cursor on a word starts a + composition there. + */ + get compositionStarted() { return !!this.inputState && this.inputState.composing >= 0; } + /** + The document or shadow root that the view lives in. + */ + get root() { return this._root; } + /** + @internal + */ + get win() { return this.dom.ownerDocument.defaultView || window; } + /** + Construct a new view. You'll want to either provide a `parent` + option, or put `view.dom` into your document after creating a + view, so that the user can see the editor. + */ + constructor(config = {}) { + var _a; + this.plugins = []; + this.pluginMap = new Map; + this.editorAttrs = {}; + this.contentAttrs = {}; + this.bidiCache = []; + this.destroyed = false; + /** + @internal + */ + this.updateState = 2 /* UpdateState.Updating */; + /** + @internal + */ + this.measureScheduled = -1; + /** + @internal + */ + this.measureRequests = []; + this.contentDOM = document.createElement("div"); + this.scrollDOM = document.createElement("div"); + this.scrollDOM.tabIndex = -1; + this.scrollDOM.className = "cm-scroller"; + this.scrollDOM.appendChild(this.contentDOM); + this.announceDOM = document.createElement("div"); + this.announceDOM.className = "cm-announced"; + this.announceDOM.setAttribute("aria-live", "polite"); + this.dom = document.createElement("div"); + this.dom.appendChild(this.announceDOM); + this.dom.appendChild(this.scrollDOM); + if (config.parent) + config.parent.appendChild(this.dom); + let { dispatch } = config; + this.dispatchTransactions = config.dispatchTransactions || + (dispatch && ((trs) => trs.forEach(tr => dispatch(tr, this)))) || + ((trs) => this.update(trs)); + this.dispatch = this.dispatch.bind(this); + this._root = (config.root || getRoot(config.parent) || document); + this.viewState = new ViewState(this, config.state || state.EditorState.create(config)); + if (config.scrollTo && config.scrollTo.is(scrollIntoView)) + this.viewState.scrollTarget = config.scrollTo.value.clip(this.viewState.state); + this.plugins = this.state.facet(viewPlugin).map(spec => new PluginInstance(spec)); + for (let plugin of this.plugins) + plugin.update(this); + this.observer = new DOMObserver(this); + this.inputState = new InputState(this); + this.inputState.ensureHandlers(this.plugins); + this.docView = new DocView(this); + this.mountStyles(); + this.updateAttrs(); + this.updateState = 0 /* UpdateState.Idle */; + this.controlledTouchScrollController = new ControlledTouchScroll(this); + this.requestMeasure(); + if ((_a = document.fonts) === null || _a === void 0 ? void 0 : _a.ready) + document.fonts.ready.then(() => { + this.viewState.mustMeasureContent = "refresh"; + this.requestMeasure(); + }); + } + dispatch(...input) { + let trs = input.length == 1 && input[0] instanceof state.Transaction ? input + : input.length == 1 && Array.isArray(input[0]) ? input[0] + : [this.state.update(...input)]; + this.dispatchTransactions(trs, this); + } + /** + Update the view for the given array of transactions. This will + update the visible document and selection to match the state + produced by the transactions, and notify view plugins of the + change. You should usually call + [`dispatch`](https://codemirror.net/6/docs/ref/#view.EditorView.dispatch) instead, which uses this + as a primitive. + */ + update(transactions) { + if (this.updateState != 0 /* UpdateState.Idle */) + throw new Error("Calls to EditorView.update are not allowed while an update is in progress"); + let redrawn = false, attrsChanged = false, update; + let state$1 = this.state; + for (let tr of transactions) { + if (tr.startState != state$1) + throw new RangeError("Trying to update state with a transaction that doesn't start from the previous state."); + state$1 = tr.state; + } + if (this.destroyed) { + this.viewState.state = state$1; + return; + } + let focus = this.hasFocus, focusFlag = 0, dispatchFocus = null; + if (transactions.some(tr => tr.annotation(isFocusChange))) { + this.inputState.notifiedFocused = focus; + // If a focus-change transaction is being dispatched, set this update flag. + focusFlag = 1 /* UpdateFlag.Focus */; + } + else if (focus != this.inputState.notifiedFocused) { + this.inputState.notifiedFocused = focus; + // Schedule a separate focus transaction if necessary, otherwise + // add a flag to this update + dispatchFocus = focusChangeTransaction(state$1, focus); + if (!dispatchFocus) + focusFlag = 1 /* UpdateFlag.Focus */; + } + // If there was a pending DOM change, eagerly read it and try to + // apply it after the given transactions. + let pendingKey = this.observer.delayedAndroidKey, domChange = null; + if (pendingKey) { + this.observer.clearDelayedAndroidKey(); + domChange = this.observer.readChange(); + // Only try to apply DOM changes if the transactions didn't + // change the doc or selection. + if (domChange && !this.state.doc.eq(state$1.doc) || !this.state.selection.eq(state$1.selection)) + domChange = null; + } + else { + this.observer.clear(); + } + // When the phrases change, redraw the editor + if (state$1.facet(state.EditorState.phrases) != this.state.facet(state.EditorState.phrases)) + return this.setState(state$1); + update = ViewUpdate.create(this, state$1, transactions); + update.flags |= focusFlag; + this.controlledTouchScrollController.beforeUpdate(transactions); + let scrollTarget = this.viewState.scrollTarget; + try { + this.updateState = 2 /* UpdateState.Updating */; + for (let tr of transactions) { + if (scrollTarget) + scrollTarget = scrollTarget.map(tr.changes); + if (tr.scrollIntoView) { + let { main } = tr.state.selection; + let { x, y } = this.state.facet(EditorView.cursorScrollMargin); + scrollTarget = new ScrollTarget(main.empty ? main : state.EditorSelection.cursor(main.head, main.head > main.anchor ? -1 : 1), "nearest", "nearest", y, x); + } + for (let e of tr.effects) + if (e.is(scrollIntoView)) + scrollTarget = e.value.clip(this.state); + } + this.viewState.update(update, scrollTarget); + this.bidiCache = CachedOrder.update(this.bidiCache, update.changes); + if (!update.empty) { + this.updatePlugins(update); + this.inputState.update(update); + } + redrawn = this.docView.update(update); + if (this.state.facet(styleModule) != this.styleModules) + this.mountStyles(); + attrsChanged = this.updateAttrs(); + this.showAnnouncements(transactions); + this.docView.updateSelection(redrawn, transactions.some(tr => tr.isUserEvent("select.pointer"))); + } + finally { + this.updateState = 0 /* UpdateState.Idle */; + } + this.controlledTouchScrollController.update(update); + if (update.startState.facet(theme) != update.state.facet(theme)) + this.viewState.mustMeasureContent = true; + if (redrawn || attrsChanged || scrollTarget || this.viewState.mustEnforceCursorAssoc || this.viewState.mustMeasureContent) + this.requestMeasure(); + if (redrawn) + this.docViewUpdate(); + if (!update.empty) + for (let listener of this.state.facet(updateListener)) { + try { + listener(update); + } + catch (e) { + logException(this.state, e, "update listener"); + } + } + if (dispatchFocus || domChange) + Promise.resolve().then(() => { + if (dispatchFocus && this.state == dispatchFocus.startState) + this.dispatch(dispatchFocus); + if (domChange) { + if (!applyDOMChange(this, domChange) && pendingKey.force) + dispatchKey(this.contentDOM, pendingKey.key, pendingKey.keyCode); + } + }); + } + /** + Reset the view to the given state. (This will cause the entire + document to be redrawn and all view plugins to be reinitialized, + so you should probably only use it when the new state isn't + derived from the old state. Otherwise, use + [`dispatch`](https://codemirror.net/6/docs/ref/#view.EditorView.dispatch) instead.) + */ + setState(newState) { + if (this.updateState != 0 /* UpdateState.Idle */) + throw new Error("Calls to EditorView.setState are not allowed while an update is in progress"); + if (this.destroyed) { + this.viewState.state = newState; + return; + } + this.controlledTouchScrollController.cancel(); + this.updateState = 2 /* UpdateState.Updating */; + let hadFocus = this.hasFocus; + try { + for (let plugin of this.plugins) + plugin.destroy(this); + this.viewState = new ViewState(this, newState); + this.plugins = newState.facet(viewPlugin).map(spec => new PluginInstance(spec)); + this.pluginMap.clear(); + for (let plugin of this.plugins) + plugin.update(this); + this.docView.destroy(); + this.docView = new DocView(this); + this.inputState.ensureHandlers(this.plugins); + this.mountStyles(); + this.updateAttrs(); + this.bidiCache = []; + } + finally { + this.updateState = 0 /* UpdateState.Idle */; + } + this.controlledTouchScrollController.setState(); + if (hadFocus) + this.focus(); + this.requestMeasure(); + } + updatePlugins(update) { + let prevSpecs = update.startState.facet(viewPlugin), specs = update.state.facet(viewPlugin); + if (prevSpecs != specs) { + let newPlugins = []; + for (let spec of specs) { + let found = prevSpecs.indexOf(spec); + if (found < 0) { + newPlugins.push(new PluginInstance(spec)); + } + else { + let plugin = this.plugins[found]; + plugin.mustUpdate = update; + newPlugins.push(plugin); + } + } + for (let plugin of this.plugins) + if (plugin.mustUpdate != update) + plugin.destroy(this); + this.plugins = newPlugins; + this.pluginMap.clear(); + } + else { + for (let p of this.plugins) + p.mustUpdate = update; + } + for (let i = 0; i < this.plugins.length; i++) + this.plugins[i].update(this); + if (prevSpecs != specs) + this.inputState.ensureHandlers(this.plugins); + } + docViewUpdate() { + for (let plugin of this.plugins) { + let val = plugin.value; + if (val && val.docViewUpdate) { + try { + val.docViewUpdate(this); + } + catch (e) { + logException(this.state, e, "doc view update listener"); + } + } + } + } + /** + @internal + */ + measure(flush = true) { + var _a, _b; + if (this.destroyed) + return; + if (this.measureScheduled > -1) + this.win.cancelAnimationFrame(this.measureScheduled); + if (this.observer.delayedAndroidKey) { + this.measureScheduled = -1; + this.requestMeasure(); + return; + } + this.measureScheduled = 0; // Prevent requestMeasure calls from scheduling another animation frame + if (flush) + this.observer.forceFlush(); + let updated = null; + let scroll = this.viewState.scrollParent, scrollOffset = this.viewState.getScrollOffset(); + let { scrollAnchorPos, scrollAnchorHeight } = this.viewState; + if (Math.abs(scrollOffset - this.viewState.scrollOffset) > 1) + scrollAnchorHeight = -1; + this.viewState.scrollAnchorHeight = -1; + try { + for (let i = 0;; i++) { + if (scrollAnchorHeight < 0) { + if (isScrolledToBottom(scroll || this.win)) { + scrollAnchorPos = -1; + scrollAnchorHeight = this.viewState.heightMap.height; + } + else { + let block = this.viewState.scrollAnchorAt(scrollOffset); + scrollAnchorPos = block.from; + scrollAnchorHeight = block.top; + } + } + this.updateState = 1 /* UpdateState.Measuring */; + let changed = this.viewState.measure(); + if (!changed && !this.measureRequests.length && this.viewState.scrollTarget == null) + break; + if (i > 5) { + console.warn(this.measureRequests.length + ? "Measure loop restarted more than 5 times" + : "Viewport failed to stabilize"); + break; + } + let measuring = []; + // Only run measure requests in this cycle when the viewport didn't change + if (!(changed & 4 /* UpdateFlag.Viewport */)) + [this.measureRequests, measuring] = [measuring, this.measureRequests]; + let measured = measuring.map(m => { + try { + return m.read(this); + } + catch (e) { + logException(this.state, e); + return BadMeasure; + } + }); + let update = ViewUpdate.create(this, this.state, []), redrawn = false; + update.flags |= changed; + if (!updated) + updated = update; + else + updated.flags |= changed; + this.updateState = 2 /* UpdateState.Updating */; + if (!update.empty) { + this.updatePlugins(update); + this.inputState.update(update); + this.updateAttrs(); + redrawn = this.docView.update(update); + if (redrawn) + this.docViewUpdate(); + } + for (let i = 0; i < measuring.length; i++) + if (measured[i] != BadMeasure) { + try { + let m = measuring[i]; + if (m.write) + m.write(measured[i], this); + } + catch (e) { + logException(this.state, e); + } + } + if (redrawn) + this.docView.updateSelection(true); + if (!update.viewportChanged && this.measureRequests.length == 0) { + if (this.viewState.editorHeight) { + if (this.viewState.scrollTarget) { + this.docView.scrollIntoView(this.viewState.scrollTarget); + this.viewState.scrollTarget = null; + scrollAnchorHeight = -1; + continue; + } + else { + let newAnchorHeight = scrollAnchorPos < 0 ? this.viewState.heightMap.height : + this.viewState.lineBlockAt(scrollAnchorPos).top; + let diff = (newAnchorHeight - scrollAnchorHeight) / this.scaleY; + if ((diff > 1 || diff < -1) && + !(browser.ios && this.inputState.lastIOSMomentumScroll > Date.now() - 100) && + (scroll == this.scrollDOM || this.hasFocus || + Math.max(this.inputState.lastWheelEvent, this.inputState.lastTouchTime) > Date.now() - 100)) { + scrollOffset = scrollOffset + diff; + if (!scroll) + this.win.scrollBy(0, diff); + else { + let target = scrollAnchorPos < 0 ? scroll.scrollHeight : scroll.scrollTop + diff; + scroll.scrollTop = scroll == this.scrollDOM + ? (_b = (_a = this.controlledTouchScrollController) === null || _a === void 0 ? void 0 : _a.clampToRenderedOffset(target)) !== null && _b !== void 0 ? _b : target + : target; + } + scrollAnchorHeight = -1; + continue; + } + } + } + break; + } + } + } + finally { + this.updateState = 0 /* UpdateState.Idle */; + this.measureScheduled = -1; + } + if (updated && !updated.empty) + for (let listener of this.state.facet(updateListener)) + listener(updated); + } + /** + Get the CSS classes for the currently active editor themes. + */ + get themeClasses() { + return baseThemeID + " " + + (this.state.facet(darkTheme) ? baseDarkID : baseLightID) + " " + + this.state.facet(theme); + } + updateAttrs() { + let editorAttrs = attrsFromFacet(this, editorAttributes, { + class: "cm-editor" + (this.hasFocus ? " cm-focused " : " ") + this.themeClasses + }); + let contentAttrs = { + spellcheck: "false", + autocorrect: "off", + autocapitalize: "off", + writingsuggestions: "false", + translate: "no", + contenteditable: !this.state.facet(editable) ? "false" : "true", + class: "cm-content", + style: `${browser.tabSize}: ${this.state.tabSize}`, + role: "textbox", + "aria-multiline": "true" + }; + if (this.state.readOnly) + contentAttrs["aria-readonly"] = "true"; + attrsFromFacet(this, contentAttributes, contentAttrs); + let changed = this.observer.ignore(() => { + let changedContent = updateAttrs(this.contentDOM, this.contentAttrs, contentAttrs); + let changedEditor = updateAttrs(this.dom, this.editorAttrs, editorAttrs); + return changedContent || changedEditor; + }); + this.editorAttrs = editorAttrs; + this.contentAttrs = contentAttrs; + return changed; + } + showAnnouncements(trs) { + let first = true; + for (let tr of trs) + for (let effect of tr.effects) + if (effect.is(EditorView.announce)) { + if (first) + this.announceDOM.textContent = ""; + first = false; + let div = this.announceDOM.appendChild(document.createElement("div")); + div.textContent = effect.value; + } + } + mountStyles() { + this.styleModules = this.state.facet(styleModule); + let nonce = this.state.facet(EditorView.cspNonce); + styleMod.StyleModule.mount(this.root, this.styleModules.concat(baseTheme$1).reverse(), nonce ? { nonce } : undefined); + } + readMeasured() { + if (this.updateState == 2 /* UpdateState.Updating */) + throw new Error("Reading the editor layout isn't allowed during an update"); + if (this.updateState == 0 /* UpdateState.Idle */ && this.measureScheduled > -1) + this.measure(false); + } + /** + Schedule a layout measurement, optionally providing callbacks to + do custom DOM measuring followed by a DOM write phase. Using + this is preferable reading DOM layout directly from, for + example, an event handler, because it'll make sure measuring and + drawing done by other components is synchronized, avoiding + unnecessary DOM layout computations. + */ + requestMeasure(request) { + if (this.measureScheduled < 0) + this.measureScheduled = this.win.requestAnimationFrame(() => this.measure()); + if (request) { + if (this.measureRequests.indexOf(request) > -1) + return; + if (request.key != null) + for (let i = 0; i < this.measureRequests.length; i++) { + if (this.measureRequests[i].key === request.key) { + this.measureRequests[i] = request; + return; + } + } + this.measureRequests.push(request); + } + } + /** + Get the value of a specific plugin, if present. Note that + plugins that crash can be dropped from a view, so even when you + know you registered a given plugin, it is recommended to check + the return value of this method. + */ + plugin(plugin) { + let known = this.pluginMap.get(plugin); + if (known === undefined || known && known.plugin != plugin) + this.pluginMap.set(plugin, known = this.plugins.find(p => p.plugin == plugin) || null); + return known && known.update(this).value; + } + /** + The top position of the document, in screen coordinates. This + may be negative when the editor is scrolled down. Points + directly to the top of the first line, not above the padding. + */ + get documentTop() { + return this.contentDOM.getBoundingClientRect().top + this.viewState.paddingTop; + } + /** + Reports the padding above and below the document. + */ + get documentPadding() { + return { top: this.viewState.paddingTop, bottom: this.viewState.paddingBottom }; + } + /** + If the editor is transformed with CSS, this provides the scale + along the X axis. Otherwise, it will just be 1. Note that + transforms other than translation and scaling are not supported. + */ + get scaleX() { return this.viewState.scaleX; } + /** + Provide the CSS transformed scale along the Y axis. + */ + get scaleY() { return this.viewState.scaleY; } + /** + Find the text line or block widget at the given vertical + position (which is interpreted as relative to the [top of the + document](https://codemirror.net/6/docs/ref/#view.EditorView.documentTop)). + */ + elementAtHeight(height) { + this.readMeasured(); + return this.viewState.elementAtHeight(height); + } + /** + Find the line block (see + [`lineBlockAt`](https://codemirror.net/6/docs/ref/#view.EditorView.lineBlockAt)) at the given + height, again interpreted relative to the [top of the + document](https://codemirror.net/6/docs/ref/#view.EditorView.documentTop). + */ + lineBlockAtHeight(height) { + this.readMeasured(); + return this.viewState.lineBlockAtHeight(height); + } + /** + Get the extent and vertical position of all [line + blocks](https://codemirror.net/6/docs/ref/#view.EditorView.lineBlockAt) in the viewport. Positions + are relative to the [top of the + document](https://codemirror.net/6/docs/ref/#view.EditorView.documentTop); + */ + get viewportLineBlocks() { + return this.viewState.viewportLines; + } + /** + Find the line block around the given document position. A line + block is a range delimited on both sides by either a + non-[hidden](https://codemirror.net/6/docs/ref/#view.Decoration^replace) line break, or the + start/end of the document. It will usually just hold a line of + text, but may be broken into multiple textblocks by block + widgets. + */ + lineBlockAt(pos) { + return this.viewState.lineBlockAt(pos); + } + /** + The editor's total content height. + */ + get contentHeight() { + return this.viewState.contentHeight; + } + /** + Move a cursor position by [grapheme + cluster](https://codemirror.net/6/docs/ref/#state.findClusterBreak). `forward` determines whether + the motion is away from the line start, or towards it. In + bidirectional text, the line is traversed in visual order, using + the editor's [text direction](https://codemirror.net/6/docs/ref/#view.EditorView.textDirection). + When the start position was the last one on the line, the + returned position will be across the line break. If there is no + further line, the original position is returned. + + By default, this method moves over a single cluster. The + optional `by` argument can be used to move across more. It will + be called with the first cluster as argument, and should return + a predicate that determines, for each subsequent cluster, + whether it should also be moved over. + */ + moveByChar(start, forward, by) { + return skipAtoms(this, start, moveByChar(this, start, forward, by)); + } + /** + Move a cursor position across the next group of either + [letters](https://codemirror.net/6/docs/ref/#state.EditorState.charCategorizer) or non-letter + non-whitespace characters. + */ + moveByGroup(start, forward) { + return skipAtoms(this, start, moveByChar(this, start, forward, initial => byGroup(this, start.head, initial))); + } + /** + Get the cursor position visually at the start or end of a line. + Note that this may differ from the _logical_ position at its + start or end (which is simply at `line.from`/`line.to`) if text + at the start or end goes against the line's base text direction. + */ + visualLineSide(line, end) { + let order = this.bidiSpans(line), dir = this.textDirectionAt(line.from); + let span = order[end ? order.length - 1 : 0]; + return state.EditorSelection.cursor(span.side(end, dir) + line.from, span.forward(!end, dir) ? 1 : -1); + } + /** + Move to the next line boundary in the given direction. If + `includeWrap` is true, line wrapping is on, and there is a + further wrap point on the current line, the wrap point will be + returned. Otherwise this function will return the start or end + of the line. + */ + moveToLineBoundary(start, forward, includeWrap = true) { + return moveToLineBoundary(this, start, forward, includeWrap); + } + /** + Move a cursor position vertically. When `distance` isn't given, + it defaults to moving to the next line (including wrapped + lines). Otherwise, `distance` should provide a positive distance + in pixels. + + When `start` has a + [`goalColumn`](https://codemirror.net/6/docs/ref/#state.SelectionRange.goalColumn), the vertical + motion will use that as a target horizontal position. Otherwise, + the cursor's own horizontal position is used. The returned + cursor will have its goal column set to whichever column was + used. + */ + moveVertically(start, forward, distance) { + return skipAtoms(this, start, moveVertically(this, start, forward, distance)); + } + /** + Find the DOM parent node and offset (child offset if `node` is + an element, character offset when it is a text node) at the + given document position. + + Note that for positions that aren't currently in + `visibleRanges`, the resulting DOM position isn't necessarily + meaningful (it may just point before or after a placeholder + element). + */ + domAtPos(pos, side = 1) { + return this.docView.domAtPos(pos, side); + } + /** + Find the document position at the given DOM node. Can be useful + for associating positions with DOM events. Will raise an error + when `node` isn't part of the editor content. + */ + posAtDOM(node, offset = 0) { + return this.docView.posFromDOM(node, offset); + } + posAtCoords(coords, precise = true) { + this.readMeasured(); + let found = posAtCoords(this, coords, precise); + return found && found.pos; + } + posAndSideAtCoords(coords, precise = true) { + this.readMeasured(); + return posAtCoords(this, coords, precise); + } + /** + Get the screen coordinates at the given document position. + `side` determines whether the coordinates are based on the + element before (-1) or after (1) the position (if no element is + available on the given side, the method will transparently use + another strategy to get reasonable coordinates). + */ + coordsAtPos(pos, side = 1) { + this.readMeasured(); + let line = this.state.doc.lineAt(pos), order = this.bidiSpans(line); + let span = order[BidiSpan.find(order, pos - line.from, -1, side)]; + return this.docView.coordsAt(pos, side, span.dir == exports.Direction.RTL); + } + /** + Return the rectangle around a given character. If `pos` does not + point in front of a character that is in the viewport and + rendered (i.e. not replaced, not a line break), this will return + null. For space characters that are a line wrap point, this will + return the position before the line break. + */ + coordsForChar(pos) { + this.readMeasured(); + return this.docView.coordsForChar(pos); + } + /** + The default width of a character in the editor. May not + accurately reflect the width of all characters (given variable + width fonts or styling of invididual ranges). + */ + get defaultCharacterWidth() { return this.viewState.heightOracle.charWidth; } + /** + The default height of a line in the editor. May not be accurate + for all lines. + */ + get defaultLineHeight() { return this.viewState.heightOracle.lineHeight; } + /** + The text direction + ([`direction`](https://developer.mozilla.org/en-US/docs/Web/CSS/direction) + CSS property) of the editor's content element. + */ + get textDirection() { return this.viewState.defaultTextDirection; } + /** + Find the text direction of the block at the given position, as + assigned by CSS. If + [`perLineTextDirection`](https://codemirror.net/6/docs/ref/#view.EditorView^perLineTextDirection) + isn't enabled, or the given position is outside of the viewport, + this will always return the same as + [`textDirection`](https://codemirror.net/6/docs/ref/#view.EditorView.textDirection). Note that + this may trigger a DOM layout. + */ + textDirectionAt(pos) { + let perLine = this.state.facet(perLineTextDirection); + if (!perLine || pos < this.viewport.from || pos > this.viewport.to) + return this.textDirection; + this.readMeasured(); + return this.docView.textDirectionAt(pos); + } + /** + Whether this editor [wraps lines](https://codemirror.net/6/docs/ref/#view.EditorView.lineWrapping) + (as determined by the + [`white-space`](https://developer.mozilla.org/en-US/docs/Web/CSS/white-space) + CSS property of its content element). + */ + get lineWrapping() { return this.viewState.heightOracle.lineWrapping; } + /** + Returns the bidirectional text structure of the given line + (which should be in the current document) as an array of span + objects. The order of these spans matches the [text + direction](https://codemirror.net/6/docs/ref/#view.EditorView.textDirection)—if that is + left-to-right, the leftmost spans come first, otherwise the + rightmost spans come first. + */ + bidiSpans(line) { + if (line.length > MaxBidiLine) + return trivialOrder(line.length); + let dir = this.textDirectionAt(line.from), isolates; + for (let entry of this.bidiCache) { + if (entry.from == line.from && entry.dir == dir && + (entry.fresh || isolatesEq(entry.isolates, isolates = getIsolatedRanges(this, line)))) + return entry.order; + } + if (!isolates) + isolates = getIsolatedRanges(this, line); + let order = computeOrder(line.text, dir, isolates); + this.bidiCache.push(new CachedOrder(line.from, line.to, dir, isolates, true, order)); + return order; + } + /** + Check whether the editor has focus. + */ + get hasFocus() { + var _a; + // Safari return false for hasFocus when the context menu is open + // or closing, which leads us to ignore selection changes from the + // context menu because it looks like the editor isn't focused. + // This kludges around that. + return (this.dom.ownerDocument.hasFocus() || browser.safari && ((_a = this.inputState) === null || _a === void 0 ? void 0 : _a.lastContextMenu) > Date.now() - 3e4) && + this.root.activeElement == this.contentDOM; + } + /** + Put focus on the editor. + */ + focus() { + this.observer.ignore(() => { + focusPreventScroll(this.contentDOM); + this.docView.updateSelection(); + }); + } + /** + Update the [root](https://codemirror.net/6/docs/ref/##view.EditorViewConfig.root) in which the editor lives. This is only + necessary when moving the editor's existing DOM to a new window or shadow root. + */ + setRoot(root) { + if (this._root != root) { + this._root = root; + this.observer.setWindow((root.nodeType == 9 ? root : root.ownerDocument).defaultView || window); + this.controlledTouchScrollController.setWindow(); + this.mountStyles(); + } + } + /** + Clean up this editor view, removing its element from the + document, unregistering event handlers, and notifying + plugins. The view instance can no longer be used after + calling this. + */ + destroy() { + if (this.root.activeElement == this.contentDOM) + this.contentDOM.blur(); + this.controlledTouchScrollController.destroy(); + for (let plugin of this.plugins) + plugin.destroy(this); + this.plugins = []; + this.inputState.destroy(); + this.docView.destroy(); + this.dom.remove(); + this.observer.destroy(); + if (this.measureScheduled > -1) + this.win.cancelAnimationFrame(this.measureScheduled); + this.destroyed = true; + } + /** + Returns an effect that can be + [added](https://codemirror.net/6/docs/ref/#state.TransactionSpec.effects) to a transaction to + cause it to scroll the given position or range into view. + */ + static scrollIntoView(pos, options = {}) { + var _a, _b, _c, _d; + return scrollIntoView.of(new ScrollTarget(typeof pos == "number" ? state.EditorSelection.cursor(pos) : pos, (_a = options.y) !== null && _a !== void 0 ? _a : "nearest", (_b = options.x) !== null && _b !== void 0 ? _b : "nearest", (_c = options.yMargin) !== null && _c !== void 0 ? _c : 5, (_d = options.xMargin) !== null && _d !== void 0 ? _d : 5)); + } + /** + Return an effect that resets the editor to its current (at the + time this method was called) scroll position. Note that this + only affects the editor's own scrollable element, not parents. + See also + [`EditorViewConfig.scrollTo`](https://codemirror.net/6/docs/ref/#view.EditorViewConfig.scrollTo). + + The effect should be used with a document identical to the one + it was created for. Failing to do so is not an error, but may + not scroll to the expected position. You can + [map](https://codemirror.net/6/docs/ref/#state.StateEffect.map) the effect to account for changes. + */ + scrollSnapshot() { + let { scrollTop, scrollLeft } = this.scrollDOM; + let ref = this.viewState.scrollAnchorAt(scrollTop); + return scrollIntoView.of(new ScrollTarget(state.EditorSelection.cursor(ref.from), "start", "start", ref.top - scrollTop, scrollLeft, true)); + } + /** + Enable or disable tab-focus mode, which disables key bindings + for Tab and Shift-Tab, letting the browser's default + focus-changing behavior go through instead. This is useful to + prevent trapping keyboard users in your editor. + + Without argument, this toggles the mode. With a boolean, it + enables (true) or disables it (false). Given a number, it + temporarily enables the mode until that number of milliseconds + have passed or another non-Tab key is pressed. + */ + setTabFocusMode(to) { + if (to == null) + this.inputState.tabFocusMode = this.inputState.tabFocusMode < 0 ? 0 : -1; + else if (typeof to == "boolean") + this.inputState.tabFocusMode = to ? 0 : -1; + else if (this.inputState.tabFocusMode != 0) + this.inputState.tabFocusMode = Date.now() + to; + } + /** + Returns an extension that can be used to add DOM event handlers. + The value should be an object mapping event names to handler + functions. For any given event, such functions are ordered by + extension precedence, and the first handler to return true will + be assumed to have handled that event, and no other handlers or + built-in behavior will be activated for it. These are registered + on the [content element](https://codemirror.net/6/docs/ref/#view.EditorView.contentDOM), except + for `scroll` handlers, which will be called any time the + editor's [scroll element](https://codemirror.net/6/docs/ref/#view.EditorView.scrollDOM) or one of + its parent nodes is scrolled. + */ + static domEventHandlers(handlers) { + return ViewPlugin.define(() => ({}), { eventHandlers: handlers }); + } + /** + Create an extension that registers DOM event observers. Contrary + to event [handlers](https://codemirror.net/6/docs/ref/#view.EditorView^domEventHandlers), + observers can't be prevented from running by a higher-precedence + handler returning true. They also don't prevent other handlers + and observers from running when they return true, and should not + call `preventDefault`. + */ + static domEventObservers(observers) { + return ViewPlugin.define(() => ({}), { eventObservers: observers }); + } + /** + Create a theme extension. The first argument can be a + [`style-mod`](https://code.haverbeke.berlin/marijn/style-mod#documentation) + style spec providing the styles for the theme. These will be + prefixed with a generated class for the style. + + Because the selectors will be prefixed with a scope class, rule + that directly match the editor's [wrapper + element](https://codemirror.net/6/docs/ref/#view.EditorView.dom)—to which the scope class will be + added—need to be explicitly differentiated by adding an `&` to + the selector for that element—for example + `&.cm-focused`. + + When `dark` is set to true, the theme will be marked as dark, + which will cause the `&dark` rules from [base + themes](https://codemirror.net/6/docs/ref/#view.EditorView^baseTheme) to be used (as opposed to + `&light` when a light theme is active). + */ + static theme(spec, options) { + let prefix = styleMod.StyleModule.newName(); + let result = [theme.of(prefix), styleModule.of(buildTheme(`.${prefix}`, spec))]; + if (options && options.dark) + result.push(darkTheme.of(true)); + return result; + } + /** + Create an extension that adds styles to the base theme. Like + with [`theme`](https://codemirror.net/6/docs/ref/#view.EditorView^theme), use `&` to indicate the + place of the editor wrapper element when directly targeting + that. You can also use `&dark` or `&light` instead to only + target editors with a dark or light theme. + */ + static baseTheme(spec) { + return state.Prec.lowest(styleModule.of(buildTheme("." + baseThemeID, spec, lightDarkIDs))); + } + /** + Retrieve an editor view instance from the view's DOM + representation. + */ + static findFromDOM(dom) { + var _a; + let content = dom.querySelector(".cm-content"); + let tile = content && Tile.get(content) || Tile.get(dom); + return ((_a = tile === null || tile === void 0 ? void 0 : tile.root) === null || _a === void 0 ? void 0 : _a.view) || null; + } +} +/** +Facet to add a [style +module](https://code.haverbeke.berlin/marijn/style-mod#documentation) to +an editor view. The view will ensure that the module is +mounted in its [document +root](https://codemirror.net/6/docs/ref/#view.EditorView.constructor^config.root). +*/ +EditorView.styleModule = styleModule; +/** +An input handler can override the way changes to the editable +DOM content are handled. Handlers are passed the document +positions between which the change was found, and the new +content. When one returns true, no further input handlers are +called and the default behavior is prevented. + +The `insert` argument can be used to get the default transaction +that would be applied for this input. This can be useful when +dispatching the custom behavior as a separate transaction. +*/ +EditorView.inputHandler = inputHandler; +/** +Functions provided in this facet will be used to transform text +pasted or dropped into the editor. +*/ +EditorView.clipboardInputFilter = clipboardInputFilter; +/** +Transform text copied or dragged from the editor. +*/ +EditorView.clipboardOutputFilter = clipboardOutputFilter; +/** +Scroll handlers can override how things are scrolled into view. +If they return `true`, no further handling happens for the +scrolling. If they return false, the default scroll behavior is +applied. Scroll handlers should never initiate editor updates. +*/ +EditorView.scrollHandler = scrollHandler; +/** +Configure a capped, directional viewport buffer while scrolling. +*/ +EditorView.viewportBuffer = viewportBuffer; +/** +Configure render-gated vertical touch scrolling on Android Chromium. +*/ +EditorView.controlledTouchScroll = controlledTouchScroll; +/** +This facet can be used to provide functions that create effects +to be dispatched when the editor's focus state changes. +*/ +EditorView.focusChangeEffect = focusChangeEffect; +/** +By default, the editor assumes all its content has the same +[text direction](https://codemirror.net/6/docs/ref/#view.Direction). Configure this with a `true` +value to make it read the text direction of every (rendered) +line separately. +*/ +EditorView.perLineTextDirection = perLineTextDirection; +/** +Allows you to provide a function that should be called when the +library catches an exception from an extension (mostly from view +plugins, but may be used by other extensions to route exceptions +from user-code-provided callbacks). This is mostly useful for +debugging and logging. See [`logException`](https://codemirror.net/6/docs/ref/#view.logException). +*/ +EditorView.exceptionSink = exceptionSink; +/** +A facet that can be used to register a function to be called +every time the view updates. +*/ +EditorView.updateListener = updateListener; +/** +Facet that controls whether the editor content DOM is editable. +When its highest-precedence value is `false`, the element will +not have its `contenteditable` attribute set. (Note that this +doesn't affect API calls that change the editor content, even +when those are bound to keys or buttons. See the +[`readOnly`](https://codemirror.net/6/docs/ref/#state.EditorState.readOnly) facet for that.) +*/ +EditorView.editable = editable; +/** +Allows you to influence the way mouse selection happens. The +functions in this facet will be called for a `mousedown` event +on the editor, and can return an object that overrides the way a +selection is computed from that mouse click or drag. +*/ +EditorView.mouseSelectionStyle = mouseSelectionStyle; +/** +Facet used to configure whether a given selection drag event +should move or copy the selection. The given predicate will be +called with the `mousedown` event, and can return `true` when +the drag should move the content. +*/ +EditorView.dragMovesSelection = dragMovesSelection$1; +/** +Facet used to configure whether a given selecting click adds a +new range to the existing selection or replaces it entirely. The +default behavior is to check `event.metaKey` on macOS, and +`event.ctrlKey` elsewhere. +*/ +EditorView.clickAddsSelectionRange = clickAddsSelectionRange; +/** +A facet that determines which [decorations](https://codemirror.net/6/docs/ref/#view.Decoration) +are shown in the view. Decorations can be provided in two +ways—directly, or via a function that takes an editor view. + +Only decoration sets provided directly are allowed to influence +the editor's vertical layout structure. The ones provided as +functions are called _after_ the new viewport has been computed, +and thus **must not** introduce block widgets or replacing +decorations that cover line breaks. + +If you want decorated ranges to behave like atomic units for +cursor motion and deletion purposes, also provide the range set +containing the decorations to +[`EditorView.atomicRanges`](https://codemirror.net/6/docs/ref/#view.EditorView^atomicRanges). +*/ +EditorView.decorations = decorations; +/** +[Block wrappers](https://codemirror.net/6/docs/ref/#view.BlockWrapper) provide a way to add DOM +structure around editor lines and block widgets. Sets of +wrappers are provided in a similar way to decorations, and are +nested in a similar way when they overlap. A wrapper affects all +lines and block widgets that start inside its range. +*/ +EditorView.blockWrappers = blockWrappers; +/** +Facet that works much like +[`decorations`](https://codemirror.net/6/docs/ref/#view.EditorView^decorations), but puts its +inputs at the very bottom of the precedence stack, meaning mark +decorations provided here will only be split by other, partially +overlapping `outerDecorations` ranges, and wrap around all +regular decorations. Use this for mark elements that should, as +much as possible, remain in one piece. +*/ +EditorView.outerDecorations = outerDecorations; +/** +Used to provide ranges that should be treated as atoms as far as +cursor motion is concerned. This causes methods like +[`moveByChar`](https://codemirror.net/6/docs/ref/#view.EditorView.moveByChar) and +[`moveVertically`](https://codemirror.net/6/docs/ref/#view.EditorView.moveVertically) (and the +commands built on top of them) to skip across such regions when +a selection endpoint would enter them. This does _not_ prevent +direct programmatic [selection +updates](https://codemirror.net/6/docs/ref/#state.TransactionSpec.selection) from moving into such +regions. +*/ +EditorView.atomicRanges = atomicRanges; +/** +When range decorations add a `unicode-bidi: isolate` style, they +should also include a +[`bidiIsolate`](https://codemirror.net/6/docs/ref/#view.MarkDecorationSpec.bidiIsolate) property +in their decoration spec, and be exposed through this facet, so +that the editor can compute the proper text order. (Other values +for `unicode-bidi`, except of course `normal`, are not +supported.) +*/ +EditorView.bidiIsolatedRanges = bidiIsolatedRanges; +/** +Can be used to specify the distance that scrolling cursor into +view keeps it away from the sides of the editor, either as a +single pixel number or two different values for the different +axes. Defaults to 5 pixels on both axes. +*/ +EditorView.cursorScrollMargin = state.Facet.define({ + combine: inputs => { + let x = 5, y = 5; + for (let i of inputs) { + if (typeof i == "number") + x = y = i; + else + ({ x, y } = i); + } + return { x, y }; + } +}); +/** +Facet that allows extensions to provide additional scroll +margins (space around the sides of the scrolling element that +should be considered invisible). This can be useful when the +plugin introduces elements that cover part of that element (for +example a horizontally fixed gutter). Not to be confused with +[`cursorScrollMargin`](https://codemirror.net/6/docs/ref/#view.EditorView^cursorScrollMargin). +*/ +EditorView.scrollMargins = scrollMargins; +/** +This facet records whether a dark theme is active. The extension +returned by [`theme`](https://codemirror.net/6/docs/ref/#view.EditorView^theme) automatically +includes an instance of this when the `dark` option is set to +true. +*/ +EditorView.darkTheme = darkTheme; +/** +Provides a Content Security Policy nonce to use when creating +the style sheets for the editor. Holds the empty string when no +nonce has been provided. +*/ +EditorView.cspNonce = state.Facet.define({ combine: values => values.length ? values[0] : "" }); +/** +Facet that provides additional DOM attributes for the editor's +editable DOM element. +*/ +EditorView.contentAttributes = contentAttributes; +/** +Facet that provides DOM attributes for the editor's outer +element. +*/ +EditorView.editorAttributes = editorAttributes; +/** +An extension that enables line wrapping in the editor (by +setting CSS `white-space` to `pre-wrap` in the content). +*/ +EditorView.lineWrapping = EditorView.contentAttributes.of({ "class": "cm-lineWrapping" }); +/** +State effect used to include screen reader announcements in a +transaction. These will be added to the DOM in a visually hidden +element with `aria-live="polite"` set, and should be used to +describe effects that are visually obvious but may not be +noticed by screen reader users (such as moving to the next +search match). +*/ +EditorView.announce = state.StateEffect.define(); +// Maximum line length for which we compute accurate bidi info +const MaxBidiLine = 4096; +const BadMeasure = {}; +class CachedOrder { + constructor(from, to, dir, isolates, fresh, order) { + this.from = from; + this.to = to; + this.dir = dir; + this.isolates = isolates; + this.fresh = fresh; + this.order = order; + } + static update(cache, changes) { + if (changes.empty && !cache.some(c => c.fresh)) + return cache; + let result = [], lastDir = cache.length ? cache[cache.length - 1].dir : exports.Direction.LTR; + for (let i = Math.max(0, cache.length - 10); i < cache.length; i++) { + let entry = cache[i]; + if (entry.dir == lastDir && !changes.touchesRange(entry.from, entry.to)) + result.push(new CachedOrder(changes.mapPos(entry.from, 1), changes.mapPos(entry.to, -1), entry.dir, entry.isolates, false, entry.order)); + } + return result; + } +} +function attrsFromFacet(view, facet, base) { + for (let sources = view.state.facet(facet), i = sources.length - 1; i >= 0; i--) { + let source = sources[i], value = typeof source == "function" ? source(view) : source; + if (value) + combineAttrs(value, base); + } + return base; +} + +const currentPlatform = browser.mac ? "mac" : browser.windows ? "win" : browser.linux ? "linux" : "key"; +function normalizeKeyName(name, platform) { + const parts = name.split(/-(?!$)/); + let result = parts[parts.length - 1]; + if (result == "Space") + result = " "; + let alt, ctrl, shift, meta; + for (let i = 0; i < parts.length - 1; ++i) { + const mod = parts[i]; + if (/^(cmd|meta|m)$/i.test(mod)) + meta = true; + else if (/^a(lt)?$/i.test(mod)) + alt = true; + else if (/^(c|ctrl|control)$/i.test(mod)) + ctrl = true; + else if (/^s(hift)?$/i.test(mod)) + shift = true; + else if (/^mod$/i.test(mod)) { + if (platform == "mac") + meta = true; + else + ctrl = true; + } + else + throw new Error("Unrecognized modifier name: " + mod); + } + if (alt) + result = "Alt-" + result; + if (ctrl) + result = "Ctrl-" + result; + if (meta) + result = "Meta-" + result; + if (shift) + result = "Shift-" + result; + return result; +} +function modifiers(name, event, shift) { + if (event.altKey) + name = "Alt-" + name; + if (event.ctrlKey) + name = "Ctrl-" + name; + if (event.metaKey) + name = "Meta-" + name; + if (shift !== false && event.shiftKey) + name = "Shift-" + name; + return name; +} +const handleKeyEvents = state.Prec.default(EditorView.domEventHandlers({ + keydown(event, view) { + return runHandlers(getKeymap(view.state), event, view, "editor"); + } +})); +/** +Facet used for registering keymaps. + +You can add multiple keymaps to an editor. Their priorities +determine their precedence (the ones specified early or with high +priority get checked first). When a handler has returned `true` +for a given key, no further handlers are called. +*/ +const keymap = state.Facet.define({ enables: handleKeyEvents }); +const Keymaps = new WeakMap(); +// This is hidden behind an indirection, rather than directly computed +// by the facet, to keep internal types out of the facet's type. +function getKeymap(state) { + let bindings = state.facet(keymap); + let map = Keymaps.get(bindings); + if (!map) + Keymaps.set(bindings, map = buildKeymap(bindings.reduce((a, b) => a.concat(b), []))); + return map; +} +/** +Run the key handlers registered for a given scope. The event +object should be a `"keydown"` event. Returns true if any of the +handlers handled it. +*/ +function runScopeHandlers(view, event, scope) { + return runHandlers(getKeymap(view.state), event, view, scope); +} +let storedPrefix = null; +const PrefixTimeout = 4000; +function buildKeymap(bindings, platform = currentPlatform) { + let bound = Object.create(null); + let isPrefix = Object.create(null); + let checkPrefix = (name, is) => { + let current = isPrefix[name]; + if (current == null) + isPrefix[name] = is; + else if (current != is) + throw new Error("Key binding " + name + " is used both as a regular binding and as a multi-stroke prefix"); + }; + let add = (scope, key, command, preventDefault, stopPropagation) => { + var _a, _b; + let scopeObj = bound[scope] || (bound[scope] = Object.create(null)); + let parts = key.split(/ (?!$)/).map(k => normalizeKeyName(k, platform)); + for (let i = 1; i < parts.length; i++) { + let prefix = parts.slice(0, i).join(" "); + checkPrefix(prefix, true); + if (!scopeObj[prefix]) + scopeObj[prefix] = { + preventDefault: true, + stopPropagation: false, + run: [(view) => { + let ourObj = storedPrefix = { view, prefix, scope }; + setTimeout(() => { if (storedPrefix == ourObj) + storedPrefix = null; }, PrefixTimeout); + return true; + }] + }; + } + let full = parts.join(" "); + checkPrefix(full, false); + let binding = scopeObj[full] || (scopeObj[full] = { + preventDefault: false, + stopPropagation: false, + run: ((_b = (_a = scopeObj._any) === null || _a === void 0 ? void 0 : _a.run) === null || _b === void 0 ? void 0 : _b.slice()) || [] + }); + if (command) + binding.run.push(command); + if (preventDefault) + binding.preventDefault = true; + if (stopPropagation) + binding.stopPropagation = true; + }; + for (let b of bindings) { + let scopes = b.scope ? b.scope.split(" ") : ["editor"]; + if (b.any) + for (let scope of scopes) { + let scopeObj = bound[scope] || (bound[scope] = Object.create(null)); + if (!scopeObj._any) + scopeObj._any = { preventDefault: false, stopPropagation: false, run: [] }; + let { any } = b; + for (let key in scopeObj) + scopeObj[key].run.push(view => any(view, currentKeyEvent)); + } + let name = b[platform] || b.key; + if (!name) + continue; + for (let scope of scopes) { + add(scope, name, b.run, b.preventDefault, b.stopPropagation); + if (b.shift) + add(scope, "Shift-" + name, b.shift, b.preventDefault, b.stopPropagation); + } + } + return bound; +} +let currentKeyEvent = null; +function runHandlers(map, event, view, scope) { + currentKeyEvent = event; + let name = w3cKeyname.keyName(event); + let charCode = state.codePointAt(name, 0), isChar = state.codePointSize(charCode) == name.length && name != " "; + let prefix = "", handled = false, prevented = false, stopPropagation = false; + if (storedPrefix && storedPrefix.view == view && storedPrefix.scope == scope) { + prefix = storedPrefix.prefix + " "; + if (modifierCodes.indexOf(event.keyCode) < 0) { + prevented = true; + storedPrefix = null; + } + } + let ran = new Set; + let runFor = (binding) => { + if (binding) { + for (let cmd of binding.run) + if (!ran.has(cmd)) { + ran.add(cmd); + if (cmd(view)) { + if (binding.stopPropagation) + stopPropagation = true; + return true; + } + } + if (binding.preventDefault) { + if (binding.stopPropagation) + stopPropagation = true; + prevented = true; + } + } + return false; + }; + let scopeObj = map[scope], baseName, shiftName; + if (scopeObj) { + if (runFor(scopeObj[prefix + modifiers(name, event, !isChar)])) { + handled = true; + } + else if (isChar && (event.altKey || event.metaKey || event.ctrlKey) && + // Ctrl-Alt may be used for AltGr on Windows + !(browser.windows && event.ctrlKey && event.altKey) && + // Alt-combinations on macOS tend to be typed characters + !(browser.mac && event.altKey && !(event.ctrlKey || event.metaKey)) && + (baseName = w3cKeyname.base[event.keyCode]) && baseName != name) { + if (runFor(scopeObj[prefix + modifiers(baseName, event, true)])) { + handled = true; + } + else if (event.shiftKey && (shiftName = w3cKeyname.shift[event.keyCode]) != name && shiftName != baseName && + runFor(scopeObj[prefix + modifiers(shiftName, event, false)])) { + handled = true; + } + } + else if (isChar && event.shiftKey && + runFor(scopeObj[prefix + modifiers(name, event, true)])) { + handled = true; + } + if (!handled && runFor(scopeObj._any)) + handled = true; + } + if (prevented) + handled = true; + if (handled && stopPropagation) + event.stopPropagation(); + currentKeyEvent = null; + return handled; +} + +/** +Implementation of [`LayerMarker`](https://codemirror.net/6/docs/ref/#view.LayerMarker) that creates +a rectangle at a given set of coordinates. +*/ +class RectangleMarker { + /** + Create a marker with the given class and dimensions. If `width` + is null, the DOM element will get no width style. + */ + constructor(className, + /** + The left position of the marker (in pixels, document-relative). + */ + left, + /** + The top position of the marker. + */ + top, + /** + The width of the marker, or null if it shouldn't get a width assigned. + */ + width, + /** + The height of the marker. + */ + height) { + this.className = className; + this.left = left; + this.top = top; + this.width = width; + this.height = height; + } + draw() { + let elt = document.createElement("div"); + elt.className = this.className; + this.adjust(elt); + return elt; + } + update(elt, prev) { + if (prev.className != this.className) + return false; + this.adjust(elt); + return true; + } + adjust(elt) { + elt.style.left = this.left + "px"; + elt.style.top = this.top + "px"; + if (this.width != null) + elt.style.width = this.width + "px"; + elt.style.height = this.height + "px"; + } + eq(p) { + return this.left == p.left && this.top == p.top && this.width == p.width && this.height == p.height && + this.className == p.className; + } + /** + Create a set of rectangles for the given selection range, + assigning them theclass`className`. Will create a single + rectangle for empty ranges, and a set of selection-style + rectangles covering the range's content (in a bidi-aware + way) for non-empty ones. + */ + static forRange(view, className, range) { + if (range.empty) { + let pos = view.coordsAtPos(range.head, range.assoc || 1); + if (!pos) + return []; + let base = getBase(view); + return [new RectangleMarker(className, pos.left - base.left, pos.top - base.top, null, pos.bottom - pos.top)]; + } + else { + return rectanglesForRange(view, className, range); + } + } +} +function getBase(view) { + let rect = view.scrollDOM.getBoundingClientRect(); + let left = view.textDirection == exports.Direction.LTR ? rect.left : rect.right - view.scrollDOM.clientWidth * view.scaleX; + return { left: left - view.scrollDOM.scrollLeft * view.scaleX, top: rect.top - view.scrollDOM.scrollTop * view.scaleY }; +} +function wrappedLine(view, pos, side, inside) { + let coords = view.coordsAtPos(pos, side * 2); + if (!coords) + return inside; + let editorRect = view.dom.getBoundingClientRect(); + let y = (coords.top + coords.bottom) / 2; + let left = view.posAtCoords({ x: editorRect.left + 1, y }); + let right = view.posAtCoords({ x: editorRect.right - 1, y }); + if (left == null || right == null) + return inside; + return { from: Math.max(inside.from, Math.min(left, right)), to: Math.min(inside.to, Math.max(left, right)) }; +} +function rectanglesForRange(view, className, range) { + if (range.to <= view.viewport.from || range.from >= view.viewport.to) + return []; + let from = Math.max(range.from, view.viewport.from), to = Math.min(range.to, view.viewport.to); + let ltr = view.textDirection == exports.Direction.LTR; + let content = view.contentDOM, contentRect = content.getBoundingClientRect(), base = getBase(view); + let lineElt = content.querySelector(".cm-line"), lineStyle = lineElt && window.getComputedStyle(lineElt); + let leftSide = contentRect.left + + (lineStyle ? parseInt(lineStyle.paddingLeft) + Math.min(0, parseInt(lineStyle.textIndent)) : 0); + let rightSide = contentRect.right - (lineStyle ? parseInt(lineStyle.paddingRight) : 0); + let startBlock = blockAt(view, from, 1), endBlock = blockAt(view, to, -1); + let visualStart = startBlock.type == exports.BlockType.Text ? startBlock : null; + let visualEnd = endBlock.type == exports.BlockType.Text ? endBlock : null; + if (visualStart && (view.lineWrapping || startBlock.widgetLineBreaks)) + visualStart = wrappedLine(view, from, 1, visualStart); + if (visualEnd && (view.lineWrapping || endBlock.widgetLineBreaks)) + visualEnd = wrappedLine(view, to, -1, visualEnd); + if (visualStart && visualEnd && visualStart.from == visualEnd.from && visualStart.to == visualEnd.to) { + return pieces(drawForLine(range.from, range.to, visualStart)); + } + else { + let top = visualStart ? drawForLine(range.from, null, visualStart) : drawForWidget(startBlock, false); + let bottom = visualEnd ? drawForLine(null, range.to, visualEnd) : drawForWidget(endBlock, true); + let between = []; + if ((visualStart || startBlock).to < (visualEnd || endBlock).from - (visualStart && visualEnd ? 1 : 0) || + startBlock.widgetLineBreaks > 1 && top.bottom + view.defaultLineHeight / 2 < bottom.top) + between.push(piece(leftSide, top.bottom, rightSide, bottom.top)); + else if (top.bottom < bottom.top && view.elementAtHeight((top.bottom + bottom.top) / 2).type == exports.BlockType.Text) + top.bottom = bottom.top = (top.bottom + bottom.top) / 2; + return pieces(top).concat(between).concat(pieces(bottom)); + } + function piece(left, top, right, bottom) { + return new RectangleMarker(className, left - base.left, top - base.top, Math.max(0, right - left), bottom - top); + } + function pieces({ top, bottom, horizontal }) { + let pieces = []; + for (let i = 0; i < horizontal.length; i += 2) + pieces.push(piece(horizontal[i], top, horizontal[i + 1], bottom)); + return pieces; + } + // Gets passed from/to in line-local positions + function drawForLine(from, to, line) { + let top = 1e9, bottom = -1e9, horizontal = []; + function addSpan(from, fromOpen, to, toOpen, dir) { + // Passing 2/-2 is a kludge to force the view to return + // coordinates on the proper side of block widgets, since + // normalizing the side there, though appropriate for most + // coordsAtPos queries, would break selection drawing. + let fromCoords = view.coordsAtPos(from, (from == line.to ? -2 : 2)); + let toCoords = view.coordsAtPos(to, (to == line.from ? 2 : -2)); + if (!fromCoords || !toCoords) + return; + top = Math.min(fromCoords.top, toCoords.top, top); + bottom = Math.max(fromCoords.bottom, toCoords.bottom, bottom); + if (dir == exports.Direction.LTR) + horizontal.push(ltr && fromOpen ? leftSide : fromCoords.left, ltr && toOpen ? rightSide : toCoords.right); + else + horizontal.push(!ltr && toOpen ? leftSide : toCoords.left, !ltr && fromOpen ? rightSide : fromCoords.right); + } + let start = from !== null && from !== void 0 ? from : line.from, end = to !== null && to !== void 0 ? to : line.to; + // Split the range by visible range and document line + for (let r of view.visibleRanges) + if (r.to > start && r.from < end) { + for (let pos = Math.max(r.from, start), endPos = Math.min(r.to, end);;) { + let docLine = view.state.doc.lineAt(pos); + for (let span of view.bidiSpans(docLine)) { + let spanFrom = span.from + docLine.from, spanTo = span.to + docLine.from; + if (spanFrom >= endPos) + break; + if (spanTo > pos) + addSpan(Math.max(spanFrom, pos), from == null && spanFrom <= start, Math.min(spanTo, endPos), to == null && spanTo >= end, span.dir); + } + pos = docLine.to + 1; + if (pos >= endPos) + break; + } + } + if (horizontal.length == 0) + addSpan(start, from == null, end, to == null, view.textDirection); + return { top, bottom, horizontal }; + } + function drawForWidget(block, top) { + let y = contentRect.top + (top ? block.top : block.bottom); + return { top: y, bottom: y, horizontal: [] }; + } +} +function sameMarker(a, b) { + return a.constructor == b.constructor && a.eq(b); +} +class LayerView { + constructor(view, layer) { + this.view = view; + this.layer = layer; + this.drawn = []; + this.scaleX = 1; + this.scaleY = 1; + this.measureReq = { read: this.measure.bind(this), write: this.draw.bind(this) }; + this.dom = view.scrollDOM.appendChild(document.createElement("div")); + this.dom.classList.add("cm-layer"); + if (layer.above) + this.dom.classList.add("cm-layer-above"); + if (layer.class) + this.dom.classList.add(layer.class); + this.scale(); + this.dom.setAttribute("aria-hidden", "true"); + this.setOrder(view.state); + view.requestMeasure(this.measureReq); + if (layer.mount) + layer.mount(this.dom, view); + } + update(update) { + if (update.startState.facet(layerOrder) != update.state.facet(layerOrder)) + this.setOrder(update.state); + if (this.layer.update(update, this.dom) || update.geometryChanged) { + this.scale(); + update.view.requestMeasure(this.measureReq); + } + } + docViewUpdate(view) { + if (this.layer.updateOnDocViewUpdate !== false) + view.requestMeasure(this.measureReq); + } + setOrder(state) { + let pos = 0, order = state.facet(layerOrder); + while (pos < order.length && order[pos] != this.layer) + pos++; + this.dom.style.zIndex = String((this.layer.above ? 150 : -1) - pos); + } + measure() { + return this.layer.markers(this.view); + } + scale() { + let { scaleX, scaleY } = this.view; + if (scaleX != this.scaleX || scaleY != this.scaleY) { + this.scaleX = scaleX; + this.scaleY = scaleY; + this.dom.style.transform = `scale(${1 / scaleX}, ${1 / scaleY})`; + } + } + draw(markers) { + if (markers.length != this.drawn.length || markers.some((p, i) => !sameMarker(p, this.drawn[i]))) { + let old = this.dom.firstChild, oldI = 0; + for (let marker of markers) { + if (marker.update && old && marker.constructor && this.drawn[oldI].constructor && + marker.update(old, this.drawn[oldI])) { + old = old.nextSibling; + oldI++; + } + else { + this.dom.insertBefore(marker.draw(), old); + } + } + while (old) { + let next = old.nextSibling; + old.remove(); + old = next; + } + this.drawn = markers; + if (browser.webkit) // Issue #1600, 1627, 1686 + this.dom.style.display = this.dom.firstChild ? "" : "none"; + } + } + destroy() { + if (this.layer.destroy) + this.layer.destroy(this.dom, this.view); + this.dom.remove(); + } +} +const layerOrder = state.Facet.define(); +/** +Define a layer. +*/ +function layer(config) { + return [ + ViewPlugin.define(v => new LayerView(v, config)), + layerOrder.of(config) + ]; +} + +const selectionConfig = state.Facet.define({ + combine(configs) { + return state.combineConfig(configs, { + cursorBlinkRate: 1200, + drawRangeCursor: true, + iosSelectionHandles: true + }, { + cursorBlinkRate: (a, b) => Math.min(a, b), + drawRangeCursor: (a, b) => a || b + }); + } +}); +/** +Returns an extension that hides the browser's native selection and +cursor, replacing the selection with a background behind the text +(with the `cm-selectionBackground` class), and the +cursors with elements overlaid over the code (using +`cm-cursor-primary` and `cm-cursor-secondary`). + +This allows the editor to display secondary selection ranges, and +tends to produce a type of selection more in line with that users +expect in a text editor (the native selection styling will often +leave gaps between lines and won't fill the horizontal space after +a line when the selection continues past it). + +It does have a performance cost, in that it requires an extra DOM +layout cycle for many updates (the selection is drawn based on DOM +layout information that's only available after laying out the +content). +*/ +function drawSelection(config = {}) { + return [ + selectionConfig.of(config), + cursorLayer, + selectionLayer, + hideNativeSelection, + nativeSelectionHidden.of(true) + ]; +} +/** +Retrieve the [`drawSelection`](https://codemirror.net/6/docs/ref/#view.drawSelection) configuration +for this state. (Note that this will return a set of defaults even +if `drawSelection` isn't enabled.) +*/ +function getDrawSelectionConfig(state) { + return state.facet(selectionConfig); +} +function configChanged(update) { + return update.startState.facet(selectionConfig) != update.state.facet(selectionConfig); +} +const cursorLayer = layer({ + above: true, + markers(view) { + let { state: state$1 } = view, conf = state$1.facet(selectionConfig); + let cursors = []; + for (let r of state$1.selection.ranges) { + let prim = r == state$1.selection.main; + if (r.empty || conf.drawRangeCursor && !(prim && browser.ios && conf.iosSelectionHandles)) { + let className = prim ? "cm-cursor cm-cursor-primary" : "cm-cursor cm-cursor-secondary"; + let cursor = r.empty ? r : state.EditorSelection.cursor(r.head, r.assoc); + for (let piece of RectangleMarker.forRange(view, className, cursor)) + cursors.push(piece); + } + } + return cursors; + }, + update(update, dom) { + if (update.transactions.some(tr => tr.selection)) + dom.style.animationName = dom.style.animationName == "cm-blink" ? "cm-blink2" : "cm-blink"; + let confChange = configChanged(update); + if (confChange) + setBlinkRate(update.state, dom); + return update.docChanged || update.selectionSet || confChange; + }, + mount(dom, view) { + setBlinkRate(view.state, dom); + }, + class: "cm-cursorLayer" +}); +function setBlinkRate(state, dom) { + dom.style.animationDuration = state.facet(selectionConfig).cursorBlinkRate + "ms"; +} +const selectionLayer = layer({ + above: false, + markers(view) { + let markers = [], { main, ranges } = view.state.selection; + for (let r of ranges) + if (!r.empty) { + for (let marker of RectangleMarker.forRange(view, "cm-selectionBackground", r)) + markers.push(marker); + } + if (browser.ios && !main.empty && view.state.facet(selectionConfig).iosSelectionHandles) { + for (let piece of RectangleMarker.forRange(view, "cm-selectionHandle cm-selectionHandle-start", state.EditorSelection.cursor(main.from, 1))) + markers.push(piece); + for (let piece of RectangleMarker.forRange(view, "cm-selectionHandle cm-selectionHandle-end", state.EditorSelection.cursor(main.to, 1))) + markers.push(piece); + } + return markers; + }, + update(update, dom) { + return update.docChanged || update.selectionSet || update.viewportChanged || configChanged(update); + }, + class: "cm-selectionLayer" +}); +// https://discuss.codemirror.net/t/firefox-153-ignores-transparent-selection-styling/9838 +const selectionBg = browser.gecko && browser.gecko_version == 153 ? "#ffffff01" : "transparent"; +const hideNativeSelection = state.Prec.highest(EditorView.theme({ + ".cm-line": { + "& ::selection, &::selection": { backgroundColor: `${selectionBg} !important` }, + caretColor: "transparent !important" + }, + ".cm-content": { + caretColor: "transparent !important", + "& :focus": { + caretColor: "initial !important", + "&::selection, & ::selection": { + backgroundColor: "Highlight !important" + } + } + } +})); + +const setDropCursorPos = state.StateEffect.define({ + map(pos, mapping) { return pos == null ? null : mapping.mapPos(pos); } +}); +const dropCursorPos = state.StateField.define({ + create() { return null; }, + update(pos, tr) { + if (pos != null) + pos = tr.changes.mapPos(pos); + return tr.effects.reduce((pos, e) => e.is(setDropCursorPos) ? e.value : pos, pos); + } +}); +const drawDropCursor = ViewPlugin.fromClass(class { + constructor(view) { + this.view = view; + this.cursor = null; + this.measureReq = { read: this.readPos.bind(this), write: this.drawCursor.bind(this) }; + } + update(update) { + var _a; + let cursorPos = update.state.field(dropCursorPos); + if (cursorPos == null) { + if (this.cursor != null) { + (_a = this.cursor) === null || _a === void 0 ? void 0 : _a.remove(); + this.cursor = null; + } + } + else { + if (!this.cursor) { + this.cursor = this.view.scrollDOM.appendChild(document.createElement("div")); + this.cursor.className = "cm-dropCursor"; + } + if (update.startState.field(dropCursorPos) != cursorPos || update.docChanged || update.geometryChanged) + this.view.requestMeasure(this.measureReq); + } + } + readPos() { + let { view } = this; + let pos = view.state.field(dropCursorPos); + let rect = pos != null && view.coordsAtPos(pos); + if (!rect) + return null; + let outer = view.scrollDOM.getBoundingClientRect(); + return { + left: rect.left - outer.left + view.scrollDOM.scrollLeft * view.scaleX, + top: rect.top - outer.top + view.scrollDOM.scrollTop * view.scaleY, + height: rect.bottom - rect.top + }; + } + drawCursor(pos) { + if (this.cursor) { + let { scaleX, scaleY } = this.view; + if (pos) { + this.cursor.style.left = pos.left / scaleX + "px"; + this.cursor.style.top = pos.top / scaleY + "px"; + this.cursor.style.height = pos.height / scaleY + "px"; + } + else { + this.cursor.style.left = "-100000px"; + } + } + } + destroy() { + if (this.cursor) + this.cursor.remove(); + } + setDropPos(pos) { + if (this.view.state.field(dropCursorPos) != pos) + this.view.dispatch({ effects: setDropCursorPos.of(pos) }); + } +}, { + eventObservers: { + dragover(event) { + this.setDropPos(this.view.posAtCoords({ x: event.clientX, y: event.clientY })); + }, + dragleave(event) { + if (event.target == this.view.contentDOM || !this.view.contentDOM.contains(event.relatedTarget)) + this.setDropPos(null); + }, + dragend() { + this.setDropPos(null); + }, + drop() { + this.setDropPos(null); + } + } +}); +/** +Draws a cursor at the current drop position when something is +dragged over the editor. +*/ +function dropCursor() { + return [dropCursorPos, drawDropCursor]; +} + +function iterMatches(doc, re, from, to, f) { + re.lastIndex = 0; + for (let cursor = doc.iterRange(from, to), pos = from, m; !cursor.next().done; pos += cursor.value.length) { + if (!cursor.lineBreak) + while (m = re.exec(cursor.value)) + f(pos + m.index, m); + } +} +function matchRanges(view, maxLength) { + let visible = view.visibleRanges; + if (visible.length == 1 && visible[0].from == view.viewport.from && + visible[0].to == view.viewport.to) + return visible; + let result = []; + for (let { from, to } of visible) { + from = Math.max(view.state.doc.lineAt(from).from, from - maxLength); + to = Math.min(view.state.doc.lineAt(to).to, to + maxLength); + if (result.length && result[result.length - 1].to >= from) + result[result.length - 1].to = to; + else + result.push({ from, to }); + } + return result; +} +/** +Helper class used to make it easier to maintain decorations on +visible code that matches a given regular expression. To be used +in a [view plugin](https://codemirror.net/6/docs/ref/#view.ViewPlugin). Instances of this object +represent a matching configuration. +*/ +class MatchDecorator { + /** + Create a decorator. + */ + constructor(config) { + const { regexp, decoration, decorate, boundary, maxLength = 1000 } = config; + if (!regexp.global) + throw new RangeError("The regular expression given to MatchDecorator should have its 'g' flag set"); + this.regexp = regexp; + if (decorate) { + this.addMatch = (match, view, from, add) => decorate(add, from, from + match[0].length, match, view); + } + else if (typeof decoration == "function") { + this.addMatch = (match, view, from, add) => { + let deco = decoration(match, view, from); + if (deco) + add(from, from + match[0].length, deco); + }; + } + else if (decoration) { + this.addMatch = (match, _view, from, add) => add(from, from + match[0].length, decoration); + } + else { + throw new RangeError("Either 'decorate' or 'decoration' should be provided to MatchDecorator"); + } + this.boundary = boundary; + this.maxLength = maxLength; + } + /** + Compute the full set of decorations for matches in the given + view's viewport. You'll want to call this when initializing your + plugin. + */ + createDeco(view) { + let build = new state.RangeSetBuilder(), add = build.add.bind(build); + for (let { from, to } of matchRanges(view, this.maxLength)) + iterMatches(view.state.doc, this.regexp, from, to, (from, m) => this.addMatch(m, view, from, add)); + return build.finish(); + } + /** + Update a set of decorations for a view update. `deco` _must_ be + the set of decorations produced by _this_ `MatchDecorator` for + the view state before the update. + */ + updateDeco(update, deco) { + let changeFrom = 1e9, changeTo = -1; + if (update.docChanged) + update.changes.iterChanges((_f, _t, from, to) => { + if (to >= update.view.viewport.from && from <= update.view.viewport.to) { + changeFrom = Math.min(from, changeFrom); + changeTo = Math.max(to, changeTo); + } + }); + if (update.viewportMoved || changeTo - changeFrom > 1000) + return this.createDeco(update.view); + if (changeTo > -1) + return this.updateRange(update.view, deco.map(update.changes), changeFrom, changeTo); + return deco; + } + updateRange(view, deco, updateFrom, updateTo) { + for (let r of view.visibleRanges) { + let from = Math.max(r.from, updateFrom), to = Math.min(r.to, updateTo); + if (to >= from) { + let fromLine = view.state.doc.lineAt(from), toLine = fromLine.to < to ? view.state.doc.lineAt(to) : fromLine; + let start = Math.max(r.from, fromLine.from), end = Math.min(r.to, toLine.to); + if (this.boundary) { + for (; from > fromLine.from; from--) + if (this.boundary.test(fromLine.text[from - 1 - fromLine.from])) { + start = from; + break; + } + for (; to < toLine.to; to++) + if (this.boundary.test(toLine.text[to - toLine.from])) { + end = to; + break; + } + } + let ranges = [], m; + let add = (from, to, deco) => ranges.push(deco.range(from, to)); + if (fromLine == toLine) { + this.regexp.lastIndex = start - fromLine.from; + while ((m = this.regexp.exec(fromLine.text)) && m.index < end - fromLine.from) + this.addMatch(m, view, m.index + fromLine.from, add); + } + else { + iterMatches(view.state.doc, this.regexp, start, end, (from, m) => this.addMatch(m, view, from, add)); + } + deco = deco.update({ filterFrom: start, filterTo: end, filter: (from, to) => from < start || to > end, add: ranges }); + } + } + return deco; + } +} + +const UnicodeRegexpSupport = /x/.unicode != null ? "gu" : "g"; +const Specials = new RegExp("[\u0000-\u0008\u000a-\u001f\u007f-\u009f\u00ad\u061c\u200b\u200e\u200f\u2028\u2029\u202d\u202e\u2066\u2067\u2069\ufeff\ufff9-\ufffc]", UnicodeRegexpSupport); +const Names = { + 0: "null", + 7: "bell", + 8: "backspace", + 10: "newline", + 11: "vertical tab", + 13: "carriage return", + 27: "escape", + 8203: "zero width space", + 8204: "zero width non-joiner", + 8205: "zero width joiner", + 8206: "left-to-right mark", + 8207: "right-to-left mark", + 8232: "line separator", + 8237: "left-to-right override", + 8238: "right-to-left override", + 8294: "left-to-right isolate", + 8295: "right-to-left isolate", + 8297: "pop directional isolate", + 8233: "paragraph separator", + 65279: "zero width no-break space", + 65532: "object replacement" +}; +let _supportsTabSize = null; +function supportsTabSize() { + var _a; + if (_supportsTabSize == null && typeof document != "undefined" && document.body) { + let styles = document.body.style; + _supportsTabSize = ((_a = styles.tabSize) !== null && _a !== void 0 ? _a : styles.MozTabSize) != null; + } + return _supportsTabSize || false; +} +const specialCharConfig = state.Facet.define({ + combine(configs) { + let config = state.combineConfig(configs, { + render: null, + specialChars: Specials, + addSpecialChars: null + }); + if (config.replaceTabs = !supportsTabSize()) + config.specialChars = new RegExp("\t|" + config.specialChars.source, UnicodeRegexpSupport); + if (config.addSpecialChars) + config.specialChars = new RegExp(config.specialChars.source + "|" + config.addSpecialChars.source, UnicodeRegexpSupport); + return config; + } +}); +/** +Returns an extension that installs highlighting of special +characters. +*/ +function highlightSpecialChars( +/** +Configuration options. +*/ +config = {}) { + return [specialCharConfig.of(config), specialCharPlugin()]; +} +let _plugin = null; +function specialCharPlugin() { + return _plugin || (_plugin = ViewPlugin.fromClass(class { + constructor(view) { + this.view = view; + this.decorations = Decoration.none; + this.decorationCache = Object.create(null); + this.decorator = this.makeDecorator(view.state.facet(specialCharConfig)); + this.decorations = this.decorator.createDeco(view); + } + makeDecorator(conf) { + return new MatchDecorator({ + regexp: conf.specialChars, + decoration: (m, view, pos) => { + let { doc } = view.state; + let code = state.codePointAt(m[0], 0); + if (code == 9) { + let line = doc.lineAt(pos); + let size = view.state.tabSize, col = state.countColumn(line.text, size, pos - line.from); + return Decoration.replace({ + widget: new TabWidget((size - (col % size)) * this.view.defaultCharacterWidth / this.view.scaleX) + }); + } + return this.decorationCache[code] || + (this.decorationCache[code] = Decoration.replace({ widget: new SpecialCharWidget(conf, code) })); + }, + boundary: conf.replaceTabs ? undefined : /[^]/ + }); + } + update(update) { + let conf = update.state.facet(specialCharConfig); + if (update.startState.facet(specialCharConfig) != conf) { + this.decorator = this.makeDecorator(conf); + this.decorations = this.decorator.createDeco(update.view); + } + else { + this.decorations = this.decorator.updateDeco(update, this.decorations); + } + } + }, { + decorations: v => v.decorations + })); +} +const DefaultPlaceholder = "\u2022"; +// Assigns placeholder characters from the Control Pictures block to +// ASCII control characters +function placeholder$1(code) { + if (code >= 32) + return DefaultPlaceholder; + if (code == 10) + return "\u2424"; + return String.fromCharCode(9216 + code); +} +class SpecialCharWidget extends WidgetType { + constructor(options, code) { + super(); + this.options = options; + this.code = code; + } + eq(other) { return other.code == this.code; } + toDOM(view) { + let ph = placeholder$1(this.code); + let desc = view.state.phrase("Control character") + " " + (Names[this.code] || "0x" + this.code.toString(16)); + let custom = this.options.render && this.options.render(this.code, desc, ph); + if (custom) + return custom; + let span = document.createElement("span"); + span.textContent = ph; + span.title = desc; + span.setAttribute("aria-label", desc); + span.className = "cm-specialChar"; + return span; + } + ignoreEvent() { return false; } +} +class TabWidget extends WidgetType { + constructor(width) { + super(); + this.width = width; + } + eq(other) { return other.width == this.width; } + toDOM() { + let span = document.createElement("span"); + span.textContent = "\t"; + span.className = "cm-tab"; + span.style.width = this.width + "px"; + return span; + } + ignoreEvent() { return false; } +} + +const plugin = ViewPlugin.fromClass(class { + constructor() { + this.height = 1000; + this.attrs = { style: "padding-bottom: 1000px" }; + } + update(update) { + let { view } = update; + let height = view.viewState.editorHeight - + view.defaultLineHeight - view.documentPadding.top - 0.5; + if (height >= 0 && height != this.height) { + this.height = height; + this.attrs = { style: `padding-bottom: ${height}px` }; + } + } +}); +/** +Returns an extension that makes sure the content has a bottom +margin equivalent to the height of the editor, minus one line +height, so that every line in the document can be scrolled to the +top of the editor. + +This is only meaningful when the editor is scrollable, and should +not be enabled in editors that take the size of their content. +*/ +function scrollPastEnd() { + return [plugin, contentAttributes.of(view => { var _a; return ((_a = view.plugin(plugin)) === null || _a === void 0 ? void 0 : _a.attrs) || null; })]; +} + +/** +Mark lines that have a cursor on them with the `"cm-activeLine"` +DOM class. +*/ +function highlightActiveLine() { + return activeLineHighlighter; +} +const lineDeco = Decoration.line({ class: "cm-activeLine" }); +const activeLineHighlighter = ViewPlugin.fromClass(class { + constructor(view) { + this.decorations = this.getDeco(view); + } + update(update) { + if (update.docChanged || update.selectionSet) + this.decorations = this.getDeco(update.view); + } + getDeco(view) { + let lastLineStart = -1, deco = []; + for (let r of view.state.selection.ranges) { + let line = view.lineBlockAt(r.head); + if (line.from > lastLineStart) { + deco.push(lineDeco.range(line.from)); + lastLineStart = line.from; + } + } + return Decoration.set(deco); + } +}, { + decorations: v => v.decorations +}); + +class Placeholder extends WidgetType { + constructor(content) { + super(); + this.content = content; + } + toDOM(view) { + let wrap = document.createElement("span"); + wrap.className = "cm-placeholder"; + wrap.style.pointerEvents = "none"; + wrap.appendChild(typeof this.content == "string" ? document.createTextNode(this.content) : + typeof this.content == "function" ? this.content(view) : + this.content.cloneNode(true)); + wrap.setAttribute("aria-hidden", "true"); + return wrap; + } + coordsAt(dom) { + let rects = dom.firstChild ? clientRectsFor(dom.firstChild) : []; + if (!rects.length) + return null; + let style = window.getComputedStyle(dom.parentNode); + let rect = flattenRect(rects[0], style.direction != "rtl"); + let lineHeight = parseInt(style.lineHeight); + if (rect.bottom - rect.top > lineHeight * 1.5) + return { left: rect.left, right: rect.right, top: rect.top, bottom: rect.top + lineHeight }; + return rect; + } + ignoreEvent() { return false; } +} +/** +Extension that enables a placeholder—a piece of example content +to show when the editor is empty. +*/ +function placeholder(content) { + let plugin = ViewPlugin.fromClass(class { + constructor(view) { + this.view = view; + this.placeholder = content + ? Decoration.set([Decoration.widget({ widget: new Placeholder(content), side: 1 }).range(0)]) + : Decoration.none; + } + get decorations() { return this.view.state.doc.length ? Decoration.none : this.placeholder; } + }, { decorations: v => v.decorations }); + return typeof content == "string" ? [ + plugin, EditorView.contentAttributes.of({ "aria-placeholder": content }) + ] : plugin; +} + +// Don't compute precise column positions for line offsets above this +// (since it could get expensive). Assume offset==column for them. +const MaxOff = 2000; +function rectangleFor(state$1, a, b) { + let startLine = Math.min(a.line, b.line), endLine = Math.max(a.line, b.line); + let ranges = []; + if (a.off > MaxOff || b.off > MaxOff || a.col < 0 || b.col < 0) { + let startOff = Math.min(a.off, b.off), endOff = Math.max(a.off, b.off); + for (let i = startLine; i <= endLine; i++) { + let line = state$1.doc.line(i); + if (line.length <= endOff) + ranges.push(state.EditorSelection.range(line.from + startOff, line.to + endOff)); + } + } + else { + let startCol = Math.min(a.col, b.col), endCol = Math.max(a.col, b.col); + for (let i = startLine; i <= endLine; i++) { + let line = state$1.doc.line(i); + let start = state.findColumn(line.text, startCol, state$1.tabSize, true); + if (start < 0) { + ranges.push(state.EditorSelection.cursor(line.to)); + } + else { + let end = state.findColumn(line.text, endCol, state$1.tabSize); + ranges.push(state.EditorSelection.range(line.from + start, line.from + end)); + } + } + } + return ranges; +} +function absoluteColumn(view, x) { + let ref = view.coordsAtPos(view.viewport.from); + return ref ? Math.round(Math.abs((ref.left - x) / view.defaultCharacterWidth)) : -1; +} +function getPos(view, event) { + let offset = view.posAtCoords({ x: event.clientX, y: event.clientY }, false); + let line = view.state.doc.lineAt(offset), off = offset - line.from; + let col = off > MaxOff ? -1 + : off == line.length ? absoluteColumn(view, event.clientX) + : state.countColumn(line.text, view.state.tabSize, offset - line.from); + return { line: line.number, col, off }; +} +function rectangleSelectionStyle(view, event) { + let start = getPos(view, event), startSel = view.state.selection; + if (!start) + return null; + return { + update(update) { + if (update.docChanged) { + let newStart = update.changes.mapPos(update.startState.doc.line(start.line).from); + let newLine = update.state.doc.lineAt(newStart); + start = { line: newLine.number, col: start.col, off: Math.min(start.off, newLine.length) }; + startSel = startSel.map(update.changes); + } + }, + get(event, _extend, multiple) { + let cur = getPos(view, event); + if (!cur) + return startSel; + let ranges = rectangleFor(view.state, start, cur); + if (!ranges.length) + return startSel; + if (multiple) + return state.EditorSelection.create(ranges.concat(startSel.ranges)); + else + return state.EditorSelection.create(ranges); + } + }; +} +/** +Create an extension that enables rectangular selections. By +default, it will react to left mouse drag with the Alt key held +down. When such a selection occurs, the text within the rectangle +that was dragged over will be selected, as one selection +[range](https://codemirror.net/6/docs/ref/#state.SelectionRange) per line. +*/ +function rectangularSelection(options) { + let filter = (options === null || options === void 0 ? void 0 : options.eventFilter) || (e => e.altKey && e.button == 0); + return EditorView.mouseSelectionStyle.of((view, event) => filter(event) ? rectangleSelectionStyle(view, event) : null); +} +const keys = { + Alt: [18, e => !!e.altKey], + Control: [17, e => !!e.ctrlKey], + Shift: [16, e => !!e.shiftKey], + Meta: [91, e => !!e.metaKey] +}; +const showCrosshair = { style: "cursor: crosshair" }; +/** +Returns an extension that turns the pointer cursor into a +crosshair when a given modifier key, defaulting to Alt, is held +down. Can serve as a visual hint that rectangular selection is +going to happen when paired with +[`rectangularSelection`](https://codemirror.net/6/docs/ref/#view.rectangularSelection). +*/ +function crosshairCursor(options = {}) { + let [code, getter] = keys[options.key || "Alt"]; + let plugin = ViewPlugin.fromClass(class { + constructor(view) { + this.view = view; + this.isDown = false; + } + set(isDown) { + if (this.isDown != isDown) { + this.isDown = isDown; + this.view.update([]); + } + } + }, { + eventObservers: { + keydown(e) { + this.set(e.keyCode == code || getter(e)); + }, + keyup(e) { + if (e.keyCode == code || !getter(e)) + this.set(false); + }, + mousemove(e) { + this.set(getter(e)); + } + } + }); + return [ + plugin, + EditorView.contentAttributes.of(view => { var _a; return ((_a = view.plugin(plugin)) === null || _a === void 0 ? void 0 : _a.isDown) ? showCrosshair : null; }) + ]; +} + +const Outside = "-10000px"; +class TooltipViewManager { + constructor(view, facet, createTooltipView, removeTooltipView) { + this.facet = facet; + this.createTooltipView = createTooltipView; + this.removeTooltipView = removeTooltipView; + this.input = view.state.facet(facet); + this.tooltips = this.input.filter(t => t); + let prev = null; + this.tooltipViews = this.tooltips.map(t => prev = createTooltipView(t, prev)); + } + update(update, above) { + var _a; + let input = update.state.facet(this.facet); + let tooltips = input.filter(x => x); + if (input === this.input) { + for (let t of this.tooltipViews) + if (t.update) + t.update(update); + return false; + } + let tooltipViews = [], newAbove = above ? [] : null; + for (let i = 0; i < tooltips.length; i++) { + let tip = tooltips[i], known = -1; + if (!tip) + continue; + for (let i = 0; i < this.tooltips.length; i++) { + let other = this.tooltips[i]; + if (other && other.create == tip.create) + known = i; + } + if (known < 0) { + tooltipViews[i] = this.createTooltipView(tip, i ? tooltipViews[i - 1] : null); + if (newAbove) + newAbove[i] = !!tip.above; + } + else { + let tooltipView = tooltipViews[i] = this.tooltipViews[known]; + if (newAbove) + newAbove[i] = above[known]; + if (tooltipView.update) + tooltipView.update(update); + } + } + for (let t of this.tooltipViews) + if (tooltipViews.indexOf(t) < 0) { + this.removeTooltipView(t); + (_a = t.destroy) === null || _a === void 0 ? void 0 : _a.call(t); + } + if (above) { + newAbove.forEach((val, i) => above[i] = val); + above.length = newAbove.length; + } + this.input = input; + this.tooltips = tooltips; + this.tooltipViews = tooltipViews; + return true; + } +} +/** +Creates an extension that configures tooltip behavior. +*/ +function tooltips(config = {}) { + return tooltipConfig.of(config); +} +function windowSpace(view) { + let docElt = view.dom.ownerDocument.documentElement; + return { top: 0, left: 0, bottom: docElt.clientHeight, right: docElt.clientWidth }; +} +const tooltipConfig = state.Facet.define({ + combine: values => { + var _a, _b, _c; + return ({ + position: browser.ios ? "absolute" : ((_a = values.find(conf => conf.position)) === null || _a === void 0 ? void 0 : _a.position) || "fixed", + parent: ((_b = values.find(conf => conf.parent)) === null || _b === void 0 ? void 0 : _b.parent) || null, + tooltipSpace: ((_c = values.find(conf => conf.tooltipSpace)) === null || _c === void 0 ? void 0 : _c.tooltipSpace) || windowSpace, + }); + } +}); +const knownHeight = new WeakMap(); +const tooltipPlugin = ViewPlugin.fromClass(class { + constructor(view) { + this.view = view; + this.above = []; + this.inView = true; + this.madeAbsolute = false; + this.lastTransaction = 0; + this.measureTimeout = -1; + let config = view.state.facet(tooltipConfig); + this.position = config.position; + this.parent = config.parent; + this.classes = view.themeClasses; + this.createContainer(); + this.measureReq = { read: this.readMeasure.bind(this), write: this.writeMeasure.bind(this), key: this }; + this.resizeObserver = typeof ResizeObserver == "function" ? new ResizeObserver(() => this.measureSoon()) : null; + this.manager = new TooltipViewManager(view, showTooltip, (t, p) => this.createTooltip(t, p), t => { + if (this.resizeObserver) + this.resizeObserver.unobserve(t.dom); + t.dom.remove(); + }); + this.above = this.manager.tooltips.map(t => !!t.above); + this.intersectionObserver = typeof IntersectionObserver == "function" ? new IntersectionObserver(entries => { + if (Date.now() > this.lastTransaction - 50 && + entries.length > 0 && entries[entries.length - 1].intersectionRatio < 1) + this.measureSoon(); + }, { threshold: [1] }) : null; + this.observeIntersection(); + view.win.addEventListener("resize", this.measureSoon = this.measureSoon.bind(this)); + this.maybeMeasure(); + } + createContainer() { + if (this.parent) { + this.container = document.createElement("div"); + this.container.style.position = "relative"; + this.container.className = this.view.themeClasses; + this.parent.appendChild(this.container); + } + else { + this.container = this.view.dom; + } + } + observeIntersection() { + if (this.intersectionObserver) { + this.intersectionObserver.disconnect(); + for (let tooltip of this.manager.tooltipViews) + this.intersectionObserver.observe(tooltip.dom); + } + } + measureSoon() { + if (this.measureTimeout < 0) + this.measureTimeout = setTimeout(() => { + this.measureTimeout = -1; + this.maybeMeasure(); + }, 50); + } + update(update) { + if (update.transactions.length) + this.lastTransaction = Date.now(); + let updated = this.manager.update(update, this.above); + if (updated) + this.observeIntersection(); + let shouldMeasure = updated || update.geometryChanged; + let newConfig = update.state.facet(tooltipConfig); + if (newConfig.position != this.position && !this.madeAbsolute) { + this.position = newConfig.position; + for (let t of this.manager.tooltipViews) + t.dom.style.position = this.position; + shouldMeasure = true; + } + if (newConfig.parent != this.parent) { + if (this.parent) + this.container.remove(); + this.parent = newConfig.parent; + this.createContainer(); + for (let t of this.manager.tooltipViews) + this.container.appendChild(t.dom); + shouldMeasure = true; + } + else if (this.parent && this.view.themeClasses != this.classes) { + this.classes = this.container.className = this.view.themeClasses; + } + if (shouldMeasure) + this.maybeMeasure(); + } + createTooltip(tooltip, prev) { + let tooltipView = tooltip.create(this.view); + let before = prev ? prev.dom : null; + tooltipView.dom.classList.add("cm-tooltip"); + if (tooltip.arrow && !tooltipView.dom.querySelector(".cm-tooltip > .cm-tooltip-arrow")) { + let arrow = document.createElement("div"); + arrow.className = "cm-tooltip-arrow"; + tooltipView.dom.appendChild(arrow); + } + tooltipView.dom.style.position = this.position; + tooltipView.dom.style.top = Outside; + tooltipView.dom.style.left = "0px"; + this.container.insertBefore(tooltipView.dom, before); + if (tooltipView.mount) + tooltipView.mount(this.view); + if (this.resizeObserver) + this.resizeObserver.observe(tooltipView.dom); + return tooltipView; + } + destroy() { + var _a, _b, _c; + this.view.win.removeEventListener("resize", this.measureSoon); + for (let tooltipView of this.manager.tooltipViews) { + tooltipView.dom.remove(); + (_a = tooltipView.destroy) === null || _a === void 0 ? void 0 : _a.call(tooltipView); + } + if (this.parent) + this.container.remove(); + (_b = this.resizeObserver) === null || _b === void 0 ? void 0 : _b.disconnect(); + (_c = this.intersectionObserver) === null || _c === void 0 ? void 0 : _c.disconnect(); + clearTimeout(this.measureTimeout); + } + readMeasure() { + let scaleX = 1, scaleY = 1, makeAbsolute = false; + if (this.position == "fixed" && this.manager.tooltipViews.length) { + let { dom } = this.manager.tooltipViews[0]; + if (browser.safari) { + // Safari always sets offsetParent to null, even if a fixed + // element is positioned relative to a transformed parent. So + // we use this kludge to try and detect this. + let rect = dom.getBoundingClientRect(); + makeAbsolute = Math.abs(rect.top + 10000) > 1 || Math.abs(rect.left) > 1; + } + else { + // More conforming browsers will set offsetParent to the + // transformed element. + makeAbsolute = !!dom.offsetParent && dom.offsetParent != this.container.ownerDocument.body; + } + } + if (makeAbsolute || this.position == "absolute") { + if (this.parent) { + let rect = this.parent.getBoundingClientRect(); + if (rect.width && rect.height) { + scaleX = rect.width / this.parent.offsetWidth; + scaleY = rect.height / this.parent.offsetHeight; + } + } + else { + ({ scaleX, scaleY } = this.view.viewState); + } + } + let visible = this.view.scrollDOM.getBoundingClientRect(), margins = getScrollMargins(this.view); + return { + visible: { + left: visible.left + margins.left, top: visible.top + margins.top, + right: visible.right - margins.right, bottom: visible.bottom - margins.bottom + }, + parent: this.parent ? this.container.getBoundingClientRect() : this.view.dom.getBoundingClientRect(), + pos: this.manager.tooltips.map((t, i) => { + let tv = this.manager.tooltipViews[i]; + return tv.getCoords ? tv.getCoords(t.pos) : this.view.coordsAtPos(t.pos); + }), + size: this.manager.tooltipViews.map(({ dom }) => dom.getBoundingClientRect()), + space: this.view.state.facet(tooltipConfig).tooltipSpace(this.view), + scaleX, scaleY, makeAbsolute + }; + } + writeMeasure(measured) { + var _a; + if (measured.makeAbsolute) { + this.madeAbsolute = true; + this.position = "absolute"; + for (let t of this.manager.tooltipViews) + t.dom.style.position = "absolute"; + } + let { visible, space, scaleX, scaleY } = measured; + let others = []; + for (let i = 0; i < this.manager.tooltips.length; i++) { + let tooltip = this.manager.tooltips[i], tView = this.manager.tooltipViews[i], { dom } = tView; + let pos = measured.pos[i], size = measured.size[i]; + // Hide tooltips that are outside of the editor. + if (!pos || tooltip.clip !== false && (pos.bottom <= Math.max(visible.top, space.top) || + pos.top >= Math.min(visible.bottom, space.bottom) || + pos.right < Math.max(visible.left, space.left) - .1 || + pos.left > Math.min(visible.right, space.right) + .1)) { + dom.style.top = Outside; + continue; + } + let arrow = tooltip.arrow ? tView.dom.querySelector(".cm-tooltip-arrow") : null; + let arrowHeight = arrow ? 7 /* Arrow.Size */ : 0; + let width = size.right - size.left, height = (_a = knownHeight.get(tView)) !== null && _a !== void 0 ? _a : size.bottom - size.top; + let offset = tView.offset || noOffset, ltr = this.view.textDirection == exports.Direction.LTR; + let left = size.width > space.right - space.left + ? (ltr ? space.left : space.right - size.width) + : ltr ? Math.max(space.left, Math.min(pos.left - (arrow ? 14 /* Arrow.Offset */ : 0) + offset.x, space.right - width)) + : Math.min(Math.max(space.left, pos.left - width + (arrow ? 14 /* Arrow.Offset */ : 0) - offset.x), space.right - width); + let above = this.above[i]; + if (!tooltip.strictSide && (above + ? pos.top - height - arrowHeight - offset.y < space.top + : pos.bottom + height + arrowHeight + offset.y > space.bottom) && + above == (space.bottom - pos.bottom > pos.top - space.top)) + above = this.above[i] = !above; + let spaceVert = (above ? pos.top - space.top : space.bottom - pos.bottom) - arrowHeight; + if (spaceVert < height && tView.resize !== false) { + if (spaceVert < this.view.defaultLineHeight) { + dom.style.top = Outside; + continue; + } + knownHeight.set(tView, height); + dom.style.height = (height = spaceVert) / scaleY + "px"; + } + else if (dom.style.height) { + dom.style.height = ""; + } + let top = above ? pos.top - height - arrowHeight - offset.y : pos.bottom + arrowHeight + offset.y; + let right = left + width; + if (tView.overlap !== true) + for (let r of others) + if (r.left < right && r.right > left && r.top < top + height && r.bottom > top) + top = above ? r.top - height - 2 - arrowHeight : r.bottom + arrowHeight + 2; + if (this.position == "absolute") { + dom.style.top = (top - measured.parent.top) / scaleY + "px"; + setLeftStyle(dom, (left - measured.parent.left) / scaleX); + } + else { + dom.style.top = top / scaleY + "px"; + setLeftStyle(dom, left / scaleX); + } + if (arrow) { + let arrowLeft = pos.left + (ltr ? offset.x : -offset.x) - (left + 14 /* Arrow.Offset */ - 7 /* Arrow.Size */); + arrow.style.left = arrowLeft / scaleX + "px"; + } + if (tView.overlap !== true) + others.push({ left, top, right, bottom: top + height }); + dom.classList.toggle("cm-tooltip-above", above); + dom.classList.toggle("cm-tooltip-below", !above); + if (tView.positioned) + tView.positioned(measured.space); + } + } + maybeMeasure() { + if (this.manager.tooltips.length) { + if (this.view.inView) + this.view.requestMeasure(this.measureReq); + if (this.inView != this.view.inView) { + this.inView = this.view.inView; + if (!this.inView) + for (let tv of this.manager.tooltipViews) + tv.dom.style.top = Outside; + } + } + } +}, { + eventObservers: { + scroll() { this.maybeMeasure(); } + } +}); +function setLeftStyle(elt, value) { + let current = parseInt(elt.style.left, 10); + if (isNaN(current) || Math.abs(value - current) > 1) + elt.style.left = value + "px"; +} +const baseTheme = EditorView.baseTheme({ + ".cm-tooltip": { + zIndex: 500, + boxSizing: "border-box" + }, + "&light .cm-tooltip": { + border: "1px solid #bbb", + backgroundColor: "#f5f5f5" + }, + "&light .cm-tooltip-section:not(:first-child)": { + borderTop: "1px solid #bbb", + }, + "&dark .cm-tooltip": { + backgroundColor: "#333338", + color: "white" + }, + ".cm-tooltip-arrow": { + height: `${7 /* Arrow.Size */}px`, + width: `${7 /* Arrow.Size */ * 2}px`, + position: "absolute", + zIndex: -1, + overflow: "hidden", + "&:before, &:after": { + content: "''", + position: "absolute", + width: 0, + height: 0, + borderLeft: `${7 /* Arrow.Size */}px solid transparent`, + borderRight: `${7 /* Arrow.Size */}px solid transparent`, + }, + ".cm-tooltip-above &": { + bottom: `-${7 /* Arrow.Size */}px`, + "&:before": { + borderTop: `${7 /* Arrow.Size */}px solid #bbb`, + }, + "&:after": { + borderTop: `${7 /* Arrow.Size */}px solid #f5f5f5`, + bottom: "1px" + } + }, + ".cm-tooltip-below &": { + top: `-${7 /* Arrow.Size */}px`, + "&:before": { + borderBottom: `${7 /* Arrow.Size */}px solid #bbb`, + }, + "&:after": { + borderBottom: `${7 /* Arrow.Size */}px solid #f5f5f5`, + top: "1px" + } + }, + }, + "&dark .cm-tooltip .cm-tooltip-arrow": { + "&:before": { + borderTopColor: "#333338", + borderBottomColor: "#333338" + }, + "&:after": { + borderTopColor: "transparent", + borderBottomColor: "transparent" + } + } +}); +const noOffset = { x: 0, y: 0 }; +/** +Facet to which an extension can add a value to show a tooltip. +*/ +const showTooltip = state.Facet.define({ + enables: [tooltipPlugin, baseTheme] +}); +const showHoverTooltip = state.Facet.define({ + combine: inputs => inputs.reduce((a, i) => a.concat(i), []) +}); +class HoverTooltipHost { + // Needs to be static so that host tooltip instances always match + static create(view) { + return new HoverTooltipHost(view); + } + constructor(view) { + this.view = view; + this.mounted = false; + this.dom = document.createElement("div"); + this.dom.classList.add("cm-tooltip-hover"); + this.manager = new TooltipViewManager(view, showHoverTooltip, (t, p) => this.createHostedView(t, p), t => t.dom.remove()); + } + createHostedView(tooltip, prev) { + let hostedView = tooltip.create(this.view); + hostedView.dom.classList.add("cm-tooltip-section"); + this.dom.insertBefore(hostedView.dom, prev ? prev.dom.nextSibling : this.dom.firstChild); + if (this.mounted && hostedView.mount) + hostedView.mount(this.view); + return hostedView; + } + mount(view) { + for (let hostedView of this.manager.tooltipViews) { + if (hostedView.mount) + hostedView.mount(view); + } + this.mounted = true; + } + positioned(space) { + for (let hostedView of this.manager.tooltipViews) { + if (hostedView.positioned) + hostedView.positioned(space); + } + } + update(update) { + this.manager.update(update); + } + destroy() { + var _a; + for (let t of this.manager.tooltipViews) + (_a = t.destroy) === null || _a === void 0 ? void 0 : _a.call(t); + } + passProp(name) { + let value = undefined; + for (let view of this.manager.tooltipViews) { + let given = view[name]; + if (given !== undefined) { + if (value === undefined) + value = given; + else if (value !== given) + return undefined; + } + } + return value; + } + get offset() { return this.passProp("offset"); } + get getCoords() { return this.passProp("getCoords"); } + get overlap() { return this.passProp("overlap"); } + get resize() { return this.passProp("resize"); } +} +const showHoverTooltipHost = showTooltip.compute([showHoverTooltip], state => { + let tooltips = state.facet(showHoverTooltip); + if (tooltips.length === 0) + return null; + return { + pos: Math.min(...tooltips.map(t => t.pos)), + end: Math.max(...tooltips.map(t => { var _a; return (_a = t.end) !== null && _a !== void 0 ? _a : t.pos; })), + create: HoverTooltipHost.create, + above: tooltips[0].above, + arrow: tooltips.some(t => t.arrow), + }; +}); +const hoverPlugin = state.Facet.define(); +class HoverPlugin { + constructor(view, source, field, locked, setHover, hoverTime) { + this.view = view; + this.source = source; + this.field = field; + this.locked = locked; + this.setHover = setHover; + this.hoverTime = hoverTime; + this.hoverTimeout = -1; + this.restartTimeout = -1; + this.pending = null; + this.lastMove = { x: 0, y: 0, target: view.dom, time: 0 }; + this.checkHover = this.checkHover.bind(this); + view.dom.addEventListener("mouseleave", this.mouseleave = this.mouseleave.bind(this)); + view.dom.addEventListener("mousemove", this.mousemove = this.mousemove.bind(this)); + } + update(update) { + if (this.pending) { + this.pending = null; + clearTimeout(this.restartTimeout); + this.restartTimeout = setTimeout(() => this.startHover(), 20); + } + } + get active() { + return this.view.state.field(this.field); + } + checkHover() { + this.hoverTimeout = -1; + if (this.active.length) + return; + let hovered = Date.now() - this.lastMove.time; + if (hovered < this.hoverTime) + this.hoverTimeout = setTimeout(this.checkHover, this.hoverTime - hovered); + else + this.startHover(); + } + startHover() { + clearTimeout(this.restartTimeout); + let { view, lastMove } = this; + let tile = view.docView.tile.nearest(lastMove.target); + if (!tile) + return; + let pos, side = 1; + if (tile.isWidget()) { + pos = tile.posAtStart; + } + else { + pos = view.posAtCoords(lastMove); + if (pos == null) + return; + let posCoords = view.coordsAtPos(pos); + if (!posCoords || + lastMove.y < posCoords.top || lastMove.y > posCoords.bottom || + lastMove.x < posCoords.left - view.defaultCharacterWidth || + lastMove.x > posCoords.right + view.defaultCharacterWidth) + return; + let bidi = view.bidiSpans(view.state.doc.lineAt(pos)).find(s => s.from <= pos && s.to >= pos); + let rtl = bidi && bidi.dir == exports.Direction.RTL ? -1 : 1; + side = (lastMove.x < posCoords.left ? -rtl : rtl); + } + this.activateHover(view, pos, side); + } + activateHover(view, pos, side, locked) { + let open = this.source(view, pos, side); + let done = (value) => { + if (value && !(Array.isArray(value) && !value.length)) { + let tooltips = Array.isArray(value) ? value : [value]; + if (locked) + this.locked.set(tooltips, locked); + view.dispatch({ effects: this.setHover.of(tooltips) }); + } + }; + if (open && "then" in open) { + let pending = this.pending = { pos }; + open.then(result => { + if (this.pending == pending) { + this.pending = null; + done(result); + } + }, e => logException(view.state, e, "hover tooltip")); + } + else { + done(open); + } + } + get tooltip() { + let plugin = this.view.plugin(tooltipPlugin); + let index = plugin ? plugin.manager.tooltips.findIndex(t => t.create == HoverTooltipHost.create) : -1; + return index > -1 ? plugin.manager.tooltipViews[index] : null; + } + mousemove(event) { + var _a, _b; + this.lastMove = { x: event.clientX, y: event.clientY, target: event.target, time: Date.now() }; + if (this.hoverTimeout < 0) + this.hoverTimeout = setTimeout(this.checkHover, this.hoverTime); + let { active, tooltip } = this; + if (active.length && !this.locked.has(active) && tooltip && !isInTooltip(tooltip.dom, event) || this.pending) { + let { pos } = active[0] || this.pending, end = (_b = (_a = active[0]) === null || _a === void 0 ? void 0 : _a.end) !== null && _b !== void 0 ? _b : pos; + if ((pos == end ? this.view.posAtCoords(this.lastMove) != pos + : !isOverRange(this.view, pos, end, event.clientX, event.clientY))) { + this.view.dispatch({ effects: this.setHover.of([]) }); + this.pending = null; + } + } + } + mouseleave(event) { + clearTimeout(this.hoverTimeout); + this.hoverTimeout = -1; + let { active } = this; + if (active.length && !this.locked.has(active)) { + let { tooltip } = this; + let inTooltip = tooltip && tooltip.dom.contains(event.relatedTarget); + if (!inTooltip) + this.view.dispatch({ effects: this.setHover.of([]) }); + else + this.watchTooltipLeave(tooltip.dom); + } + } + watchTooltipLeave(tooltip) { + let watch = (event) => { + tooltip.removeEventListener("mouseleave", watch); + let { active } = this; + if (active.length && !this.locked.has(active) && !this.view.dom.contains(event.relatedTarget)) + this.view.dispatch({ effects: this.setHover.of([]) }); + }; + tooltip.addEventListener("mouseleave", watch); + } + destroy() { + clearTimeout(this.hoverTimeout); + clearTimeout(this.restartTimeout); + this.view.dom.removeEventListener("mouseleave", this.mouseleave); + this.view.dom.removeEventListener("mousemove", this.mousemove); + } +} +const tooltipMargin = 4; +function isInTooltip(tooltip, event) { + let { left, right, top, bottom } = tooltip.getBoundingClientRect(), arrow; + if (arrow = tooltip.querySelector(".cm-tooltip-arrow")) { + let arrowRect = arrow.getBoundingClientRect(); + top = Math.min(arrowRect.top, top); + bottom = Math.max(arrowRect.bottom, bottom); + } + return event.clientX >= left - tooltipMargin && event.clientX <= right + tooltipMargin && + event.clientY >= top - tooltipMargin && event.clientY <= bottom + tooltipMargin; +} +function isOverRange(view, from, to, x, y, margin) { + let rect = view.scrollDOM.getBoundingClientRect(); + let docBottom = view.documentTop + view.documentPadding.top + view.contentHeight; + if (rect.left > x || rect.right < x || rect.top > y || Math.min(rect.bottom, docBottom) < y) + return false; + let pos = view.posAtCoords({ x, y }, false); + return pos >= from && pos <= to; +} +/** +Set up a hover tooltip, which shows up when the pointer hovers +over ranges of text. The callback is called when the mouse hovers +over the document text. It should, if there is a tooltip +associated with position `pos`, return the tooltip description +(either directly or in a promise). The `side` argument indicates +on which side of the position the pointer is—it will be -1 if the +pointer is before the position, 1 if after the position. + +Note that all hover tooltips are hosted within a single tooltip +container element. This allows multiple tooltips over the same +range to be "merged" together without overlapping. + +The return value is a valid [editor extension](https://codemirror.net/6/docs/ref/#state.Extension) +but also provides an `active` property holding a state field that +can be used to read the currently active tooltips produced by this +extension. +*/ +function hoverTooltip(source, options = {}) { + let setHover = state.StateEffect.define(); + // This would be better stored in the state field, but we've set + // down the type of the field in our interface, so it's indirectly + // stored by array identity. + let locked = new WeakMap(); + let hoverState = state.StateField.define({ + create() { return []; }, + update(value, tr) { + let lock = locked.get(value); + if (value.length) { + if (options.hideOnChange && (tr.docChanged || tr.selection)) + value = []; + else if (lock && lock(tr)) + value = []; + else if (options.hideOn) + value = value.filter(v => !options.hideOn(tr, v)); + } + if (tr.docChanged && value.length) { + let mapped = []; + for (let tooltip of value) { + let newPos = tr.changes.mapPos(tooltip.pos, -1, state.MapMode.TrackDel); + if (newPos != null) { + let copy = Object.assign(Object.create(null), tooltip); + copy.pos = newPos; + if (copy.end != null) + copy.end = tr.changes.mapPos(copy.end); + mapped.push(copy); + } + } + value = mapped; + } + for (let effect of tr.effects) { + if (effect.is(setHover)) { + value = effect.value; + lock = undefined; + } + if (effect.is(closeHoverTooltipEffect) && !effect.value || effect.value == hoverState) + value = []; + } + if (value.length && lock) + locked.set(value, lock); + return value; + }, + provide: f => showHoverTooltip.from(f) + }); + const plugin = ViewPlugin.define(view => new HoverPlugin(view, source, hoverState, locked, setHover, options.hoverTime || 300 /* Hover.Time */)); + return { + active: hoverState, + extension: [ + hoverState, + plugin, + hoverPlugin.of(plugin), + showHoverTooltipHost + ] + }; +} +/** +Activate hover tooltips for the given position and side. If you +provide a specific hover tooltip (the value returned from +[`hoverTooltip`](https://codemirror.net/6/docs/ref/#view.hoverTooltip)), only that one will be +activated. If not given, all hover tooltips at the given position +are triggered. + +Note that tooltips opened this way don't close automatically, and +you'll want to pass an `until` callback or use +[`closeHoverTooltip`](https://codemirror.net/6/docs/ref/#view.closeHoverTooltip)/[`closeHoverTooltips`](https://codemirror.net/6/docs/ref/#view.closeHoverTooltips) +to deactivate them. +*/ +function activateHover(view, pos, side, options = {}) { + var _a; + let plugins = view.state.facet(hoverPlugin).map(p => view.plugin(p)).filter((p) => !!p); + if (options.tooltip && options.tooltip.active) { + let found = plugins.find(p => p.field == options.tooltip.active); + if (found) + plugins = [found]; + } + for (let plugin of plugins) + plugin.activateHover(view, pos, side, (_a = options.until) !== null && _a !== void 0 ? _a : (() => false)); +} +/** +Get the active tooltip view for a given tooltip, if available. +*/ +function getTooltip(view, tooltip) { + let plugin = view.plugin(tooltipPlugin); + if (!plugin) + return null; + let found = plugin.manager.tooltips.indexOf(tooltip); + return found < 0 ? null : plugin.manager.tooltipViews[found]; +} +/** +Returns true if any hover tooltips are currently active. +*/ +function hasHoverTooltips(state) { + return state.facet(showHoverTooltip).some(x => x); +} +const closeHoverTooltipEffect = state.StateEffect.define(); +/** +Transaction effect that closes all hover tooltips. +*/ +const closeHoverTooltips = closeHoverTooltipEffect.of(null); +/** +Transaction effect that closes a specific hover tooltip. +*/ +function closeHoverTooltip(tooltip) { + return closeHoverTooltipEffect.of(tooltip.active); +} +/** +Tell the tooltip extension to recompute the position of the active +tooltips. This can be useful when something happens (such as a +re-positioning or CSS change affecting the editor) that could +invalidate the existing tooltip positions. +*/ +function repositionTooltips(view) { + let plugin = view.plugin(tooltipPlugin); + if (plugin) + plugin.maybeMeasure(); +} + +const panelConfig = state.Facet.define({ + combine(configs) { + let topContainer, bottomContainer; + for (let c of configs) { + topContainer = topContainer || c.topContainer; + bottomContainer = bottomContainer || c.bottomContainer; + } + return { topContainer, bottomContainer }; + } +}); +/** +Configures the panel-managing extension. +*/ +function panels(config) { + return config ? [panelConfig.of(config)] : []; +} +/** +Get the active panel created by the given constructor, if any. +This can be useful when you need access to your panels' DOM +structure. +*/ +function getPanel(view, panel) { + let plugin = view.plugin(panelPlugin); + let index = plugin ? plugin.specs.indexOf(panel) : -1; + return index > -1 ? plugin.panels[index] : null; +} +const panelPlugin = ViewPlugin.fromClass(class { + constructor(view) { + this.input = view.state.facet(showPanel); + this.specs = this.input.filter(s => s); + this.panels = this.specs.map(spec => spec(view)); + let conf = view.state.facet(panelConfig); + this.top = new PanelGroup(view, true, conf.topContainer); + this.bottom = new PanelGroup(view, false, conf.bottomContainer); + this.top.sync(this.panels.filter(p => p.top)); + this.bottom.sync(this.panels.filter(p => !p.top)); + for (let p of this.panels) { + p.dom.classList.add("cm-panel"); + if (p.mount) + p.mount(); + } + } + update(update) { + let conf = update.state.facet(panelConfig); + if (this.top.container != conf.topContainer) { + this.top.sync([]); + this.top = new PanelGroup(update.view, true, conf.topContainer); + } + if (this.bottom.container != conf.bottomContainer) { + this.bottom.sync([]); + this.bottom = new PanelGroup(update.view, false, conf.bottomContainer); + } + this.top.syncClasses(); + this.bottom.syncClasses(); + let input = update.state.facet(showPanel); + if (input != this.input) { + let specs = input.filter(x => x); + let panels = [], top = [], bottom = [], mount = []; + for (let spec of specs) { + let known = this.specs.indexOf(spec), panel; + if (known < 0) { + panel = spec(update.view); + mount.push(panel); + } + else { + panel = this.panels[known]; + if (panel.update) + panel.update(update); + } + panels.push(panel); + (panel.top ? top : bottom).push(panel); + } + this.specs = specs; + this.panels = panels; + this.top.sync(top); + this.bottom.sync(bottom); + for (let p of mount) { + p.dom.classList.add("cm-panel"); + if (p.mount) + p.mount(); + } + } + else { + for (let p of this.panels) + if (p.update) + p.update(update); + } + } + destroy() { + this.top.sync([]); + this.bottom.sync([]); + } +}, { + provide: plugin => EditorView.scrollMargins.of(view => { + let value = view.plugin(plugin); + return value && { top: value.top.scrollMargin(), bottom: value.bottom.scrollMargin() }; + }) +}); +class PanelGroup { + constructor(view, top, container) { + this.view = view; + this.top = top; + this.container = container; + this.dom = undefined; + this.classes = ""; + this.panels = []; + this.syncClasses(); + } + sync(panels) { + for (let p of this.panels) + if (p.destroy && panels.indexOf(p) < 0) + p.destroy(); + this.panels = panels; + this.syncDOM(); + } + syncDOM() { + if (this.panels.length == 0) { + if (this.dom) { + this.dom.remove(); + this.dom = undefined; + } + return; + } + if (!this.dom) { + this.dom = document.createElement("div"); + this.dom.className = this.top ? "cm-panels cm-panels-top" : "cm-panels cm-panels-bottom"; + let parent = this.container || this.view.dom; + parent.insertBefore(this.dom, this.top ? parent.firstChild : null); + } + let curDOM = this.dom.firstChild; + for (let panel of this.panels) { + if (panel.dom.parentNode == this.dom) { + while (curDOM != panel.dom) + curDOM = rm(curDOM); + curDOM = curDOM.nextSibling; + } + else { + this.dom.insertBefore(panel.dom, curDOM); + } + } + while (curDOM) + curDOM = rm(curDOM); + } + scrollMargin() { + return !this.dom || this.container ? 0 + : Math.max(0, this.top ? + this.dom.getBoundingClientRect().bottom - Math.max(0, this.view.scrollDOM.getBoundingClientRect().top) : + Math.min(innerHeight, this.view.scrollDOM.getBoundingClientRect().bottom) - this.dom.getBoundingClientRect().top); + } + syncClasses() { + if (!this.container || this.classes == this.view.themeClasses) + return; + for (let cls of this.classes.split(" ")) + if (cls) + this.container.classList.remove(cls); + for (let cls of (this.classes = this.view.themeClasses).split(" ")) + if (cls) + this.container.classList.add(cls); + } +} +function rm(node) { + let next = node.nextSibling; + node.remove(); + return next; +} +/** +Opening a panel is done by providing a constructor function for +the panel through this facet. (The panel is closed again when its +constructor is no longer provided.) Values of `null` are ignored. +*/ +const showPanel = state.Facet.define({ + enables: panelPlugin +}); + +/** +Show a panel above or below the editor to show the user a message +or prompt them for input. Returns an effect that can be dispatched +to close the dialog, and a promise that resolves when the dialog +is closed or a form inside of it is submitted. + +You are encouraged, if your handling of the result of the promise +dispatches a transaction, to include the `close` effect in it. If +you don't, this function will automatically dispatch a separate +transaction right after. +*/ +function showDialog(view, config) { + let resolve; + let promise = new Promise(r => resolve = r); + let panelCtor = (view) => createDialog(view, config, resolve); + if (view.state.field(dialogField, false)) { + view.dispatch({ effects: openDialogEffect.of(panelCtor) }); + } + else { + view.dispatch({ effects: state.StateEffect.appendConfig.of(dialogField.init(() => [panelCtor])) }); + } + let close = closeDialogEffect.of(panelCtor); + return { close, result: promise.then(form => { + let queue = view.win.queueMicrotask || ((f) => view.win.setTimeout(f, 10)); + queue(() => { + if (view.state.field(dialogField).indexOf(panelCtor) > -1) + view.dispatch({ effects: close }); + }); + return form; + }) }; +} +/** +Find the [`Panel`](https://codemirror.net/6/docs/ref/#view.Panel) for an open dialog, using a class +name as identifier. +*/ +function getDialog(view, className) { + let dialogs = view.state.field(dialogField, false) || []; + for (let open of dialogs) { + let panel = getPanel(view, open); + if (panel && panel.dom.classList.contains(className)) + return panel; + } + return null; +} +const dialogField = state.StateField.define({ + create() { return []; }, + update(dialogs, tr) { + for (let e of tr.effects) { + if (e.is(openDialogEffect)) + dialogs = [e.value].concat(dialogs); + else if (e.is(closeDialogEffect)) + dialogs = dialogs.filter(d => d != e.value); + } + return dialogs; + }, + provide: f => showPanel.computeN([f], state => state.field(f)) +}); +const openDialogEffect = state.StateEffect.define(); +const closeDialogEffect = state.StateEffect.define(); +function createDialog(view, config, result) { + let content = config.content ? config.content(view, () => done(null)) : null; + if (!content) { + content = elt("form"); + if (config.input) { + let input = elt("input", config.input); + if (/^(text|password|number|email|tel|url)$/.test(input.type)) + input.classList.add("cm-textfield"); + if (!input.name) + input.name = "input"; + content.appendChild(elt("label", (config.label || "") + ": ", input)); + } + else { + content.appendChild(document.createTextNode(config.label || "")); + } + content.appendChild(document.createTextNode(" ")); + content.appendChild(elt("button", { class: "cm-button", type: "submit" }, config.submitLabel || "OK")); + } + let forms = content.nodeName == "FORM" ? [content] : content.querySelectorAll("form"); + for (let i = 0; i < forms.length; i++) { + let form = forms[i]; + form.addEventListener("keydown", (event) => { + if (event.keyCode == 27) { // Escape + event.preventDefault(); + done(null); + } + else if (event.keyCode == 13) { // Enter + event.preventDefault(); + done(form); + } + }); + form.addEventListener("submit", (event) => { + event.preventDefault(); + done(form); + }); + } + let panel = elt("div", content, elt("button", { + onclick: () => done(null), + "aria-label": view.state.phrase("close"), + class: "cm-dialog-close", + type: "button" + }, ["×"])); + if (config.class) + panel.className = config.class; + panel.classList.add("cm-dialog"); + function done(form) { + if (panel.contains(panel.ownerDocument.activeElement)) + view.focus(); + result(form); + } + return { + dom: panel, + top: config.top, + mount: () => { + if (config.focus) { + let focus; + if (typeof config.focus == "string") + focus = content.querySelector(config.focus); + else + focus = content.querySelector("input") || content.querySelector("button"); + if (focus && "select" in focus) + focus.select(); + else if (focus && "focus" in focus) + focus.focus(); + } + } + }; +} + +/** +A gutter marker represents a bit of information attached to a line +in a specific gutter. Your own custom markers have to extend this +class. +*/ +class GutterMarker extends state.RangeValue { + /** + @internal + */ + compare(other) { + return this == other || this.constructor == other.constructor && this.eq(other); + } + /** + Compare this marker to another marker of the same type. + */ + eq(other) { return false; } + /** + Called if the marker has a `toDOM` method and its representation + was removed from a gutter. + */ + destroy(dom) { } +} +GutterMarker.prototype.elementClass = ""; +GutterMarker.prototype.toDOM = undefined; +GutterMarker.prototype.mapMode = state.MapMode.TrackBefore; +GutterMarker.prototype.startSide = GutterMarker.prototype.endSide = -1; +GutterMarker.prototype.point = true; +/** +Facet used to add a class to all gutter elements for a given line. +Markers given to this facet should _only_ define an +[`elementclass`](https://codemirror.net/6/docs/ref/#view.GutterMarker.elementClass), not a +[`toDOM`](https://codemirror.net/6/docs/ref/#view.GutterMarker.toDOM) (or the marker will appear +in all gutters for the line). +*/ +const gutterLineClass = state.Facet.define(); +/** +Facet used to add a class to all gutter elements next to a widget. +Should not provide widgets with a `toDOM` method. +*/ +const gutterWidgetClass = state.Facet.define(); +const defaults = { + class: "", + renderEmptyElements: false, + elementStyle: "", + markers: () => state.RangeSet.empty, + lineMarker: () => null, + widgetMarker: () => null, + lineMarkerChange: null, + initialSpacer: null, + updateSpacer: null, + domEventHandlers: {}, + side: "before" +}; +const activeGutters = state.Facet.define(); +/** +Define an editor gutter. The order in which the gutters appear is +determined by their extension priority. +*/ +function gutter(config) { + return [gutters(), activeGutters.of(Object.assign(Object.assign({}, defaults), config))]; +} +const unfixGutters = state.Facet.define({ + combine: values => values.some(x => x) +}); +/** +The gutter-drawing plugin is automatically enabled when you add a +gutter, but you can use this function to explicitly configure it. + +Unless `fixed` is explicitly set to `false`, the gutters are +fixed, meaning they don't scroll along with the content +horizontally (except on Internet Explorer, which doesn't support +CSS [`position: +sticky`](https://developer.mozilla.org/en-US/docs/Web/CSS/position#sticky)). +*/ +function gutters(config) { + let result = [ + gutterView, + ]; + if (config && config.fixed === false) + result.push(unfixGutters.of(true)); + return result; +} +const gutterView = ViewPlugin.fromClass(class { + constructor(view) { + this.view = view; + this.domAfter = null; + this.prevViewport = view.viewport; + this.dom = document.createElement("div"); + this.dom.className = "cm-gutters cm-gutters-before"; + this.dom.setAttribute("aria-hidden", "true"); + this.dom.style.minHeight = (this.view.contentHeight / this.view.scaleY) + "px"; + this.gutters = view.state.facet(activeGutters).map(conf => new SingleGutterView(view, conf)); + this.fixed = !view.state.facet(unfixGutters); + for (let gutter of this.gutters) { + if (gutter.config.side == "after") + this.getDOMAfter().appendChild(gutter.dom); + else + this.dom.appendChild(gutter.dom); + } + if (this.fixed) { + // FIXME IE11 fallback, which doesn't support position: sticky, + // by using position: relative + event handlers that realign the + // gutter (or just force fixed=false on IE11?) + this.dom.style.position = "sticky"; + } + this.syncGutters(false); + view.scrollDOM.insertBefore(this.dom, view.contentDOM); + } + getDOMAfter() { + if (!this.domAfter) { + this.domAfter = document.createElement("div"); + this.domAfter.className = "cm-gutters cm-gutters-after"; + this.domAfter.setAttribute("aria-hidden", "true"); + this.domAfter.style.minHeight = (this.view.contentHeight / this.view.scaleY) + "px"; + this.domAfter.style.position = this.fixed ? "sticky" : ""; + this.view.scrollDOM.appendChild(this.domAfter); + } + return this.domAfter; + } + update(update) { + if (this.updateGutters(update)) { + // Detach during sync when the viewport changed significantly + // (such as during scrolling), since for large updates that is + // faster. + let vpA = this.prevViewport, vpB = update.view.viewport; + let vpOverlap = Math.min(vpA.to, vpB.to) - Math.max(vpA.from, vpB.from); + this.syncGutters(vpOverlap < (vpB.to - vpB.from) * 0.8); + } + if (update.geometryChanged) { + let min = (this.view.contentHeight / this.view.scaleY) + "px"; + this.dom.style.minHeight = min; + if (this.domAfter) + this.domAfter.style.minHeight = min; + } + if (this.view.state.facet(unfixGutters) != !this.fixed) { + this.fixed = !this.fixed; + this.dom.style.position = this.fixed ? "sticky" : ""; + if (this.domAfter) + this.domAfter.style.position = this.fixed ? "sticky" : ""; + } + this.prevViewport = update.view.viewport; + } + syncGutters(detach) { + let after = this.dom.nextSibling; + if (detach) { + this.dom.remove(); + if (this.domAfter) + this.domAfter.remove(); + } + let lineClasses = state.RangeSet.iter(this.view.state.facet(gutterLineClass), this.view.viewport.from); + let classSet = []; + let contexts = this.gutters.map(gutter => new UpdateContext(gutter, this.view.viewport, -this.view.documentPadding.top)); + for (let line of this.view.viewportLineBlocks) { + if (classSet.length) + classSet = []; + if (Array.isArray(line.type)) { + let first = true; + for (let b of line.type) { + if (b.type == exports.BlockType.Text && first) { + advanceCursor(lineClasses, classSet, b.from); + for (let cx of contexts) + cx.line(this.view, b, classSet); + first = false; + } + else if (b.widget) { + for (let cx of contexts) + cx.widget(this.view, b); + } + } + } + else if (line.type == exports.BlockType.Text) { + advanceCursor(lineClasses, classSet, line.from); + for (let cx of contexts) + cx.line(this.view, line, classSet); + } + else if (line.widget) { + for (let cx of contexts) + cx.widget(this.view, line); + } + } + for (let cx of contexts) + cx.finish(); + if (detach) { + this.view.scrollDOM.insertBefore(this.dom, after); + if (this.domAfter) + this.view.scrollDOM.appendChild(this.domAfter); + } + } + updateGutters(update) { + let prev = update.startState.facet(activeGutters), cur = update.state.facet(activeGutters); + let change = update.docChanged || update.heightChanged || update.viewportChanged || + !state.RangeSet.eq(update.startState.facet(gutterLineClass), update.state.facet(gutterLineClass), update.view.viewport.from, update.view.viewport.to); + if (prev == cur) { + for (let gutter of this.gutters) + if (gutter.update(update)) + change = true; + } + else { + change = true; + let gutters = []; + for (let conf of cur) { + let known = prev.indexOf(conf); + if (known < 0) { + gutters.push(new SingleGutterView(this.view, conf)); + } + else { + this.gutters[known].update(update); + gutters.push(this.gutters[known]); + } + } + for (let g of this.gutters) { + g.dom.remove(); + if (gutters.indexOf(g) < 0) + g.destroy(); + } + for (let g of gutters) { + if (g.config.side == "after") + this.getDOMAfter().appendChild(g.dom); + else + this.dom.appendChild(g.dom); + } + this.gutters = gutters; + } + return change; + } + destroy() { + for (let view of this.gutters) + view.destroy(); + this.dom.remove(); + if (this.domAfter) + this.domAfter.remove(); + } +}, { + provide: plugin => EditorView.scrollMargins.of(view => { + let value = view.plugin(plugin); + if (!value || value.gutters.length == 0 || !value.fixed) + return null; + let before = value.dom.offsetWidth * view.scaleX, after = value.domAfter ? value.domAfter.offsetWidth * view.scaleX : 0; + return view.textDirection == exports.Direction.LTR + ? { left: before, right: after } + : { right: before, left: after }; + }) +}); +function asArray(val) { return (Array.isArray(val) ? val : [val]); } +function advanceCursor(cursor, collect, pos) { + while (cursor.value && cursor.from <= pos) { + if (cursor.from == pos) + collect.push(cursor.value); + cursor.next(); + } +} +class UpdateContext { + constructor(gutter, viewport, height) { + this.gutter = gutter; + this.height = height; + this.i = 0; + this.cursor = state.RangeSet.iter(gutter.markers, viewport.from); + } + addElement(view, block, markers) { + let { gutter } = this, above = (block.top - this.height) / view.scaleY, height = block.height / view.scaleY; + if (this.i == gutter.elements.length) { + let newElt = new GutterElement(view, height, above, markers); + gutter.elements.push(newElt); + gutter.dom.appendChild(newElt.dom); + } + else { + gutter.elements[this.i].update(view, height, above, markers); + } + this.height = block.bottom; + this.i++; + } + line(view, line, extraMarkers) { + let localMarkers = []; + advanceCursor(this.cursor, localMarkers, line.from); + if (extraMarkers.length) + localMarkers = localMarkers.concat(extraMarkers); + let forLine = this.gutter.config.lineMarker(view, line, localMarkers); + if (forLine) + localMarkers.unshift(forLine); + let gutter = this.gutter; + if (localMarkers.length == 0 && !gutter.config.renderEmptyElements) + return; + this.addElement(view, line, localMarkers); + } + widget(view, block) { + let marker = this.gutter.config.widgetMarker(view, block.widget, block), markers = marker ? [marker] : null; + for (let cls of view.state.facet(gutterWidgetClass)) { + let marker = cls(view, block.widget, block); + if (marker) + (markers || (markers = [])).push(marker); + } + if (markers) + this.addElement(view, block, markers); + } + finish() { + let gutter = this.gutter; + while (gutter.elements.length > this.i) { + let last = gutter.elements.pop(); + gutter.dom.removeChild(last.dom); + last.destroy(); + } + } +} +class SingleGutterView { + constructor(view, config) { + this.view = view; + this.config = config; + this.elements = []; + this.spacer = null; + this.dom = document.createElement("div"); + this.dom.className = "cm-gutter" + (this.config.class ? " " + this.config.class : ""); + for (let prop in config.domEventHandlers) { + this.dom.addEventListener(prop, (event) => { + let target = event.target, y; + if (target != this.dom && this.dom.contains(target)) { + while (target.parentNode != this.dom) + target = target.parentNode; + let rect = target.getBoundingClientRect(); + y = (rect.top + rect.bottom) / 2; + } + else { + y = event.clientY; + } + let line = view.lineBlockAtHeight(y - view.documentTop); + if (config.domEventHandlers[prop](view, line, event)) + event.preventDefault(); + }); + } + this.markers = asArray(config.markers(view)); + if (config.initialSpacer) { + this.spacer = new GutterElement(view, 0, 0, [config.initialSpacer(view)]); + this.dom.appendChild(this.spacer.dom); + this.spacer.dom.style.cssText += "visibility: hidden; pointer-events: none"; + } + } + update(update) { + let prevMarkers = this.markers; + this.markers = asArray(this.config.markers(update.view)); + if (this.spacer && this.config.updateSpacer) { + let updated = this.config.updateSpacer(this.spacer.markers[0], update); + if (updated != this.spacer.markers[0]) + this.spacer.update(update.view, 0, 0, [updated]); + } + let vp = update.view.viewport; + return !state.RangeSet.eq(this.markers, prevMarkers, vp.from, vp.to) || + (this.config.lineMarkerChange ? this.config.lineMarkerChange(update) : false); + } + destroy() { + for (let elt of this.elements) + elt.destroy(); + } +} +class GutterElement { + constructor(view, height, above, markers) { + this.height = -1; + this.above = 0; + this.markers = []; + this.dom = document.createElement("div"); + this.dom.className = "cm-gutterElement"; + this.update(view, height, above, markers); + } + update(view, height, above, markers) { + if (this.height != height) { + this.height = height; + this.dom.style.height = height + "px"; + } + if (this.above != above) + this.dom.style.marginTop = (this.above = above) ? above + "px" : ""; + if (!sameMarkers(this.markers, markers)) + this.setMarkers(view, markers); + } + setMarkers(view, markers) { + let cls = "cm-gutterElement", domPos = this.dom.firstChild; + for (let iNew = 0, iOld = 0;;) { + let skipTo = iOld, marker = iNew < markers.length ? markers[iNew++] : null, matched = false; + if (marker) { + let c = marker.elementClass; + if (c) + cls += " " + c; + for (let i = iOld; i < this.markers.length; i++) + if (this.markers[i].compare(marker)) { + skipTo = i; + matched = true; + break; + } + } + else { + skipTo = this.markers.length; + } + while (iOld < skipTo) { + let next = this.markers[iOld++]; + if (next.toDOM) { + next.destroy(domPos); + let after = domPos.nextSibling; + domPos.remove(); + domPos = after; + } + } + if (!marker) + break; + if (marker.toDOM) { + if (matched) + domPos = domPos.nextSibling; + else + this.dom.insertBefore(marker.toDOM(view), domPos); + } + if (matched) + iOld++; + } + this.dom.className = cls; + this.markers = markers; + } + destroy() { + this.setMarkers(null, []); // First argument not used unless creating markers + } +} +function sameMarkers(a, b) { + if (a.length != b.length) + return false; + for (let i = 0; i < a.length; i++) + if (!a[i].compare(b[i])) + return false; + return true; +} +/** +Facet used to provide markers to the line number gutter. +*/ +const lineNumberMarkers = state.Facet.define(); +/** +Facet used to create markers in the line number gutter next to widgets. +*/ +const lineNumberWidgetMarker = state.Facet.define(); +const lineNumberConfig = state.Facet.define({ + combine(values) { + return state.combineConfig(values, { formatNumber: String, domEventHandlers: {} }, { + domEventHandlers(a, b) { + let result = Object.assign({}, a); + for (let event in b) { + let exists = result[event], add = b[event]; + result[event] = exists ? (view, line, event) => exists(view, line, event) || add(view, line, event) : add; + } + return result; + } + }); + } +}); +class NumberMarker extends GutterMarker { + constructor(number) { + super(); + this.number = number; + } + eq(other) { return this.number == other.number; } + toDOM() { return document.createTextNode(this.number); } +} +function formatNumber(view, number) { + return view.state.facet(lineNumberConfig).formatNumber(number, view.state); +} +const lineNumberGutter = activeGutters.compute([lineNumberConfig], state => ({ + class: "cm-lineNumbers", + renderEmptyElements: false, + markers(view) { return view.state.facet(lineNumberMarkers); }, + lineMarker(view, line, others) { + if (others.some(m => m.toDOM)) + return null; + return new NumberMarker(formatNumber(view, view.state.doc.lineAt(line.from).number)); + }, + widgetMarker: (view, widget, block) => { + for (let m of view.state.facet(lineNumberWidgetMarker)) { + let result = m(view, widget, block); + if (result) + return result; + } + return null; + }, + lineMarkerChange: update => update.startState.facet(lineNumberConfig) != update.state.facet(lineNumberConfig), + initialSpacer(view) { + return new NumberMarker(formatNumber(view, maxLineNumber(view.state.doc.lines))); + }, + updateSpacer(spacer, update) { + let max = formatNumber(update.view, maxLineNumber(update.view.state.doc.lines)); + return max == spacer.number ? spacer : new NumberMarker(max); + }, + domEventHandlers: state.facet(lineNumberConfig).domEventHandlers, + side: "before" +})); +/** +Create a line number gutter extension. +*/ +function lineNumbers(config = {}) { + return [ + lineNumberConfig.of(config), + gutters(), + lineNumberGutter + ]; +} +function maxLineNumber(lines) { + let last = 9; + while (last < lines) + last = last * 10 + 9; + return last; +} +const activeLineGutterMarker = new class extends GutterMarker { + constructor() { + super(...arguments); + this.elementClass = "cm-activeLineGutter"; + } +}; +const activeLineGutterHighlighter = gutterLineClass.compute(["selection"], state$1 => { + let marks = [], last = -1; + for (let range of state$1.selection.ranges) { + let linePos = state$1.doc.lineAt(range.head).from; + if (linePos > last) { + last = linePos; + marks.push(activeLineGutterMarker.range(linePos)); + } + } + return state.RangeSet.of(marks); +}); +/** +Returns an extension that adds a `cm-activeLineGutter` class to +all gutter elements on the [active +line](https://codemirror.net/6/docs/ref/#view.highlightActiveLine). +*/ +function highlightActiveLineGutter() { + return activeLineGutterHighlighter; +} + +function matcher(decorator) { + return ViewPlugin.define(view => ({ + decorations: decorator.createDeco(view), + update(u) { + this.decorations = decorator.updateDeco(u, this.decorations); + }, + }), { + decorations: v => v.decorations + }); +} +const tabDeco = Decoration.mark({ class: "cm-highlightTab" }); +const spaceDeco = Decoration.mark({ class: "cm-highlightSpace" }); +const whitespaceHighlighter = matcher(new MatchDecorator({ + regexp: /\t| /g, + decoration: match => match[0] == "\t" ? tabDeco : spaceDeco, + boundary: /\S/, +})); +/** +Returns an extension that highlights whitespace, adding a +`cm-highlightSpace` class to stretches of spaces, and a +`cm-highlightTab` class to individual tab characters. By default, +the former are shown as faint dots, and the latter as arrows. +*/ +function highlightWhitespace() { + return whitespaceHighlighter; +} +const trailingHighlighter = matcher(new MatchDecorator({ + regexp: /\s+$/g, + decoration: Decoration.mark({ class: "cm-trailingSpace" }) +})); +/** +Returns an extension that adds a `cm-trailingSpace` class to all +trailing whitespace. +*/ +function highlightTrailingWhitespace() { + return trailingHighlighter; +} + +/** +@internal +*/ +const __test = { HeightMap, HeightOracle, MeasuredHeights, QueryType, ChangedRange, computeOrder, + moveVisually, clearHeightChangeFlag, getHeightChangeFlag: () => heightChangeFlag, + applyViewportBuffer, isNativeScrollActivity, claimControlledTouchGesture, + AndroidMinimumFlingVelocity, AndroidMaximumFlingVelocity, + createAndroidSplineFling, estimateCommittedVelocity, sampleAndroidSpline, + ControlledTouchScroll }; + +exports.BidiSpan = BidiSpan; +exports.BlockInfo = BlockInfo; +exports.BlockWrapper = BlockWrapper; +exports.Decoration = Decoration; +exports.EditorView = EditorView; +exports.GutterMarker = GutterMarker; +exports.MatchDecorator = MatchDecorator; +exports.RectangleMarker = RectangleMarker; +exports.ViewPlugin = ViewPlugin; +exports.ViewUpdate = ViewUpdate; +exports.WidgetType = WidgetType; +exports.__test = __test; +exports.activateHover = activateHover; +exports.closeHoverTooltip = closeHoverTooltip; +exports.closeHoverTooltips = closeHoverTooltips; +exports.crosshairCursor = crosshairCursor; +exports.drawSelection = drawSelection; +exports.dropCursor = dropCursor; +exports.getDialog = getDialog; +exports.getDrawSelectionConfig = getDrawSelectionConfig; +exports.getPanel = getPanel; +exports.getTooltip = getTooltip; +exports.gutter = gutter; +exports.gutterLineClass = gutterLineClass; +exports.gutterWidgetClass = gutterWidgetClass; +exports.gutters = gutters; +exports.hasHoverTooltips = hasHoverTooltips; +exports.highlightActiveLine = highlightActiveLine; +exports.highlightActiveLineGutter = highlightActiveLineGutter; +exports.highlightSpecialChars = highlightSpecialChars; +exports.highlightTrailingWhitespace = highlightTrailingWhitespace; +exports.highlightWhitespace = highlightWhitespace; +exports.hoverTooltip = hoverTooltip; +exports.keymap = keymap; +exports.layer = layer; +exports.lineNumberMarkers = lineNumberMarkers; +exports.lineNumberWidgetMarker = lineNumberWidgetMarker; +exports.lineNumbers = lineNumbers; +exports.logException = logException; +exports.panels = panels; +exports.placeholder = placeholder; +exports.rectangularSelection = rectangularSelection; +exports.repositionTooltips = repositionTooltips; +exports.runScopeHandlers = runScopeHandlers; +exports.scrollPastEnd = scrollPastEnd; +exports.showDialog = showDialog; +exports.showPanel = showPanel; +exports.showTooltip = showTooltip; +exports.tooltips = tooltips; diff --git a/vendor/codemirror-view/dist/index.d.cts b/vendor/codemirror-view/dist/index.d.cts new file mode 100644 index 0000000000..d91e70fff6 --- /dev/null +++ b/vendor/codemirror-view/dist/index.d.cts @@ -0,0 +1,2464 @@ +import * as _codemirror_state from '@codemirror/state'; +import { RangeSet, RangeValue, Range, EditorState, Transaction, ChangeSet, Extension, SelectionRange, ChangeDesc, EditorSelection, EditorStateConfig, StateEffect, TransactionSpec, Line, Facet, StateField } from '@codemirror/state'; +import { StyleModule, StyleSpec } from 'style-mod'; + +/** +Used to indicate [text direction](https://codemirror.net/6/docs/ref/#view.EditorView.textDirection). +*/ +declare enum Direction { + /** + Left-to-right. + */ + LTR = 0, + /** + Right-to-left. + */ + RTL = 1 +} +/** +Represents a contiguous range of text that has a single direction +(as in left-to-right or right-to-left). +*/ +declare class BidiSpan { + /** + The start of the span (relative to the start of the line). + */ + readonly from: number; + /** + The end of the span. + */ + readonly to: number; + /** + The ["bidi + level"](https://unicode.org/reports/tr9/#Basic_Display_Algorithm) + of the span (in this context, 0 means + left-to-right, 1 means right-to-left, 2 means left-to-right + number inside right-to-left text). + */ + readonly level: number; + /** + The direction of this span. + */ + get dir(): Direction; +} + +type Attrs = { + [name: string]: string; +}; + +/** +Basic rectangle type. +*/ +interface Rect { + readonly left: number; + readonly right: number; + readonly top: number; + readonly bottom: number; +} +type ScrollStrategy = "nearest" | "start" | "end" | "center"; + +interface MarkDecorationSpec { + /** + Whether the mark covers its start and end position or not. This + influences whether content inserted at those positions becomes + part of the mark. Defaults to false. + */ + inclusive?: boolean; + /** + Specify whether the start position of the marked range should be + inclusive. Overrides `inclusive`, when both are present. + */ + inclusiveStart?: boolean; + /** + Whether the end should be inclusive. + */ + inclusiveEnd?: boolean; + /** + Add attributes to the DOM elements that hold the text in the + marked range. + */ + attributes?: { + [key: string]: string; + }; + /** + Shorthand for `{attributes: {class: value}}`. + */ + class?: string; + /** + Add a wrapping element around the text in the marked range. Note + that there will not necessarily be a single element covering the + entire range—other decorations with lower precedence might split + this one if they partially overlap it, and line breaks always + end decoration elements. + */ + tagName?: string; + /** + When using sets of decorations in + [`bidiIsolatedRanges`](https://codemirror.net/6/docs/ref/##view.EditorView^bidiIsolatedRanges), + this property provides the direction of the isolates. When null + or not given, it indicates the range has `dir=auto`, and its + direction should be derived from the first strong directional + character in it. + */ + bidiIsolate?: Direction | null; + /** + Decoration specs allow extra properties, which can be retrieved + through the decoration's [`spec`](https://codemirror.net/6/docs/ref/#view.Decoration.spec) + property. + */ + [other: string]: any; +} +interface WidgetDecorationSpec { + /** + The type of widget to draw here. + */ + widget: WidgetType; + /** + Which side of the given position the widget is on. When this is + positive, the widget will be drawn after the cursor if the + cursor is on the same position. Otherwise, it'll be drawn before + it. When multiple widgets sit at the same position, their `side` + values will determine their ordering—those with a lower value + come first. Defaults to 0. May not be more than 10000 or less + than -10000. + */ + side?: number; + /** + By default, to avoid unintended mixing of block and inline + widgets, block widgets with a positive `side` are always drawn + after all inline widgets at that position, and those with a + non-positive side before inline widgets. Setting this option to + `true` for a block widget will turn this off and cause it to be + rendered between the inline widgets, ordered by `side`. + */ + inlineOrder?: boolean; + /** + Determines whether this is a block widgets, which will be drawn + between lines, or an inline widget (the default) which is drawn + between the surrounding text. + + Note that block-level decorations should not have vertical + margins, and if you dynamically change their height, you should + make sure to call + [`requestMeasure`](https://codemirror.net/6/docs/ref/#view.EditorView.requestMeasure), so that the + editor can update its information about its vertical layout. + */ + block?: boolean; + /** + Other properties are allowed. + */ + [other: string]: any; +} +interface ReplaceDecorationSpec { + /** + An optional widget to drawn in the place of the replaced + content. + */ + widget?: WidgetType; + /** + Whether this range covers the positions on its sides. This + influences whether new content becomes part of the range and + whether the cursor can be drawn on its sides. Defaults to false + for inline replacements, and true for block replacements. + */ + inclusive?: boolean; + /** + Set inclusivity at the start. + */ + inclusiveStart?: boolean; + /** + Set inclusivity at the end. + */ + inclusiveEnd?: boolean; + /** + Whether this is a block-level decoration. Defaults to false. + */ + block?: boolean; + /** + Other properties are allowed. + */ + [other: string]: any; +} +interface LineDecorationSpec { + /** + DOM attributes to add to the element wrapping the line. + */ + attributes?: { + [key: string]: string; + }; + /** + Shorthand for `{attributes: {class: value}}`. + */ + class?: string; + /** + Other properties are allowed. + */ + [other: string]: any; +} +/** +Widgets added to the content are described by subclasses of this +class. Using a description object like that makes it possible to +delay creating of the DOM structure for a widget until it is +needed, and to avoid redrawing widgets even if the decorations +that define them are recreated. +*/ +declare abstract class WidgetType { + /** + Build the DOM structure for this widget instance. + */ + abstract toDOM(view: EditorView): HTMLElement; + /** + Compare this instance to another instance of the same type. + (TypeScript can't express this, but only instances of the same + specific class will be passed to this method.) This is used to + avoid redrawing widgets when they are replaced by a new + decoration of the same type. The default implementation just + returns `false`, which will cause new instances of the widget to + always be redrawn. + */ + eq(widget: WidgetType): boolean; + /** + Update a DOM element created by a widget of the same type (but + different, non-`eq` content) to reflect this widget. May return + true to indicate that it could update, false to indicate it + couldn't (in which case the widget will be redrawn). The default + implementation just returns false. + */ + updateDOM(dom: HTMLElement, view: EditorView, from: this): boolean; + /** + The estimated height this widget will have, to be used when + estimating the height of content that hasn't been drawn. May + return -1 to indicate you don't know. The default implementation + returns -1. + */ + get estimatedHeight(): number; + /** + For inline widgets that are displayed inline (as opposed to + `inline-block`) and introduce line breaks (through `
` tags + or textual newlines), this must indicate the amount of line + breaks they introduce. Defaults to 0. + */ + get lineBreaks(): number; + /** + Can be used to configure which kinds of events inside the widget + should be ignored by the editor. The default is to ignore all + events. + */ + ignoreEvent(event: Event): boolean; + /** + Override the way screen coordinates for positions at/in the + widget are found. `pos` will be the offset into the widget, and + `side` the side of the position that is being queried—less than + zero for before, greater than zero for after, and zero for + directly at that position. + */ + coordsAt(dom: HTMLElement, pos: number, side: number): Rect | null; + /** + This is called when the an instance of the widget is removed + from the editor view. + */ + destroy(dom: HTMLElement): void; +} +/** +A decoration set represents a collection of decorated ranges, +organized for efficient access and mapping. See +[`RangeSet`](https://codemirror.net/6/docs/ref/#state.RangeSet) for its methods. +*/ +type DecorationSet = RangeSet; +/** +The different types of blocks that can occur in an editor view. +*/ +declare enum BlockType { + /** + A line of text. + */ + Text = 0, + /** + A block widget associated with the position after it. + */ + WidgetBefore = 1, + /** + A block widget associated with the position before it. + */ + WidgetAfter = 2, + /** + A block widget [replacing](https://codemirror.net/6/docs/ref/#view.Decoration^replace) a range of content. + */ + WidgetRange = 3 +} +/** +A decoration provides information on how to draw or style a piece +of content. You'll usually use it wrapped in a +[`Range`](https://codemirror.net/6/docs/ref/#state.Range), which adds a start and end position. +@nonabstract +*/ +declare abstract class Decoration extends RangeValue { + /** + The config object used to create this decoration. You can + include additional properties in there to store metadata about + your decoration. + */ + readonly spec: any; + protected constructor( + /** + @internal + */ + startSide: number, + /** + @internal + */ + endSide: number, + /** + @internal + */ + widget: WidgetType | null, + /** + The config object used to create this decoration. You can + include additional properties in there to store metadata about + your decoration. + */ + spec: any); + abstract eq(other: Decoration): boolean; + /** + Create a mark decoration, which influences the styling of the + content in its range. Nested mark decorations will cause nested + DOM elements to be created. Nesting order is determined by + precedence of the [facet](https://codemirror.net/6/docs/ref/#view.EditorView^decorations), with + the higher-precedence decorations creating the inner DOM nodes. + Such elements are split on line boundaries and on the boundaries + of lower-precedence decorations. + */ + static mark(spec: MarkDecorationSpec): Decoration; + /** + Create a widget decoration, which displays a DOM element at the + given position. + */ + static widget(spec: WidgetDecorationSpec): Decoration; + /** + Create a replace decoration which replaces the given range with + a widget, or simply hides it. + */ + static replace(spec: ReplaceDecorationSpec): Decoration; + /** + Create a line decoration, which can add DOM attributes to the + line starting at the given position. + */ + static line(spec: LineDecorationSpec): Decoration; + /** + Build a [`DecorationSet`](https://codemirror.net/6/docs/ref/#view.DecorationSet) from the given + decorated range or ranges. If the ranges aren't already sorted, + pass `true` for `sort` to make the library sort them for you. + */ + static set(of: Range | readonly Range[], sort?: boolean): DecorationSet; + /** + The empty set of decorations. + */ + static none: DecorationSet; +} +interface BlockWrapperSpec { + /** + Tag name of the wrapping element. + */ + tagName: string; + /** + DOM attributes to add to the wrapping element. + */ + attributes?: { + [key: string]: string; + }; + /** + When multiple overlapping block wrappers are produced by the + same source, this determines their relative precedence. Lower + rank wrappers are nested inside higher-rank ones. Should be + a number between 0 and 100. + */ + rank?: number; +} +/** +A block wrapper defines a DOM node that wraps lines or other block +wrappers at the top of the document. It affects any line or block +widget that starts inside its range, including blocks starting +directly at `from` but not including `to`. +*/ +declare class BlockWrapper extends RangeValue { + private constructor(); + eq(other: RangeValue): boolean; + /** + Create a block wrapper object with the given tag name and + attributes. + */ + static create(spec: BlockWrapperSpec): BlockWrapper; + /** + Create a range set from the given block wrapper ranges. + */ + static set(of: Range | readonly Range[], sort?: boolean): RangeSet; +} + +/** +Command functions are used in key bindings and other types of user +actions. Given an editor view, they check whether their effect can +apply to the editor, and if it can, perform it as a side effect +(which usually means [dispatching](https://codemirror.net/6/docs/ref/#view.EditorView.dispatch) a +transaction) and return `true`. +*/ +type Command = (target: EditorView) => boolean; +/** +Configure an additional viewport buffer while the editor is actively +scrolling. The regular CodeMirror viewport margin remains in place. The +extra buffer is only added in the current scroll direction and is removed +after scrolling settles. +*/ +interface ViewportBufferConfig { + /** + Maximum number of editor heights to render ahead. Values are capped at 4. + */ + maxAhead?: number; + /** + Delay in milliseconds before returning to the regular viewport margin. + */ + settleDelay?: number; +} +/** +Configure render-gated vertical touch scrolling on Android Chromium +WebViews. The controller keeps a bounded document corridor rendered while +a gesture is active and never advances the scroll position beyond DOM that +is already present. +*/ +interface ControlledTouchScrollConfig { + /** + Wait for rendered/highlighted coverage before moving. Set false for + direct finger tracking with rendering scheduled independently. + */ + waitForRendering?: boolean; + /** + Maximum number of editor heights to keep rendered ahead at once. The + window may roll through a longer fling. Values are clamped to 1–4. + */ + maxAhead?: number; + /** + Delay in milliseconds before releasing the pinned render corridor after + scrolling stops. + */ + settleDelay?: number; +} +declare class ScrollTarget { + readonly range: SelectionRange; + readonly y: ScrollStrategy; + readonly x: ScrollStrategy; + readonly yMargin: number; + readonly xMargin: number; + readonly isSnapshot: boolean; + constructor(range: SelectionRange, y: ScrollStrategy, x: ScrollStrategy, yMargin: number, xMargin: number, isSnapshot?: boolean); + map(changes: ChangeDesc): ScrollTarget; + clip(state: EditorState): ScrollTarget; +} +/** +Log or report an unhandled exception in client code. Should +probably only be used by extension code that allows client code to +provide functions, and calls those functions in a context where an +exception can't be propagated to calling code in a reasonable way +(for example when in an event handler). + +Either calls a handler registered with +[`EditorView.exceptionSink`](https://codemirror.net/6/docs/ref/#view.EditorView^exceptionSink), +`window.onerror`, if defined, or `console.error` (in which case +it'll pass `context`, when given, as first argument). +*/ +declare function logException(state: EditorState, exception: any, context?: string): void; +/** +This is the interface plugin objects conform to. +*/ +interface PluginValue extends Object { + /** + Notifies the plugin of an update that happened in the view. This + is called _before_ the view updates its own DOM. It is + responsible for updating the plugin's internal state (including + any state that may be read by plugin fields) and _writing_ to + the DOM for the changes in the update. To avoid unnecessary + layout recomputations, it should _not_ read the DOM layout—use + [`requestMeasure`](https://codemirror.net/6/docs/ref/#view.EditorView.requestMeasure) to schedule + your code in a DOM reading phase if you need to. + */ + update?(update: ViewUpdate): void; + /** + Called when the document view is updated (due to content, + decoration, or viewport changes). Should not try to immediately + start another view update. Often useful for calling + [`requestMeasure`](https://codemirror.net/6/docs/ref/#view.EditorView.requestMeasure). + */ + docViewUpdate?(view: EditorView): void; + /** + Called when the plugin is no longer going to be used. Should + revert any changes the plugin made to the DOM. + */ + destroy?(): void; +} +/** +Provides additional information when defining a [view +plugin](https://codemirror.net/6/docs/ref/#view.ViewPlugin). +*/ +interface PluginSpec { + /** + Register the given [event + handlers](https://codemirror.net/6/docs/ref/#view.EditorView^domEventHandlers) for the plugin. + When called, these will have their `this` bound to the plugin + value. + */ + eventHandlers?: DOMEventHandlers; + /** + Registers [event observers](https://codemirror.net/6/docs/ref/#view.EditorView^domEventObservers) + for the plugin. Will, when called, have their `this` bound to + the plugin value. + */ + eventObservers?: DOMEventHandlers; + /** + Specify that the plugin provides additional extensions when + added to an editor configuration. + */ + provide?: (plugin: ViewPlugin) => Extension; + /** + Allow the plugin to provide decorations. When given, this should + be a function that take the plugin value and return a + [decoration set](https://codemirror.net/6/docs/ref/#view.DecorationSet). See also the caveat about + [layout-changing decorations](https://codemirror.net/6/docs/ref/#view.EditorView^decorations) that + depend on the view. + */ + decorations?: (value: V) => DecorationSet; +} +/** +View plugins associate stateful values with a view. They can +influence the way the content is drawn, and are notified of things +that happen in the view. They optionally take an argument, in +which case you need to call [`of`](https://codemirror.net/6/docs/ref/#view.ViewPlugin.of) to create +an extension for the plugin. When the argument type is undefined, +you can use the plugin instance as an extension directly. +*/ +declare class ViewPlugin { + /** + When `Arg` is undefined, instances of this class act as + extensions. Otherwise, you have to call `of` to create an + extension value. + */ + extension: Arg extends undefined ? Extension : null; + private baseExtensions; + private constructor(); + /** + Create an extension for this plugin with the given argument. + */ + of(arg: Arg): Extension; + /** + Define a plugin from a constructor function that creates the + plugin's value, given an editor view. + */ + static define(create: (view: EditorView, arg: Arg) => V, spec?: PluginSpec): ViewPlugin; + /** + Create a plugin for a class whose constructor takes a single + editor view as argument. + */ + static fromClass(cls: { + new (view: EditorView, arg: Arg): V; + }, spec?: PluginSpec): ViewPlugin; +} +interface MeasureRequest { + /** + Called in a DOM read phase to gather information that requires + DOM layout. Should _not_ mutate the document. + */ + read(view: EditorView): T; + /** + Called in a DOM write phase to update the document. Should _not_ + do anything that triggers DOM layout. + */ + write?(measure: T, view: EditorView): void; + /** + When multiple requests with the same key are scheduled, only the + last one will actually be run. + */ + key?: any; +} +type AttrSource = Attrs | ((view: EditorView) => Attrs | null); +/** +View [plugins](https://codemirror.net/6/docs/ref/#view.ViewPlugin) are given instances of this +class, which describe what happened, whenever the view is updated. +*/ +declare class ViewUpdate { + /** + The editor view that the update is associated with. + */ + readonly view: EditorView; + /** + The new editor state. + */ + readonly state: EditorState; + /** + The transactions involved in the update. May be empty. + */ + readonly transactions: readonly Transaction[]; + /** + The changes made to the document by this update. + */ + readonly changes: ChangeSet; + /** + The previous editor state. + */ + readonly startState: EditorState; + private constructor(); + /** + Tells you whether the [viewport](https://codemirror.net/6/docs/ref/#view.EditorView.viewport) or + [visible ranges](https://codemirror.net/6/docs/ref/#view.EditorView.visibleRanges) changed in this + update. + */ + get viewportChanged(): boolean; + /** + Returns true when + [`viewportChanged`](https://codemirror.net/6/docs/ref/#view.ViewUpdate.viewportChanged) is true + and the viewport change is not just the result of mapping it in + response to document changes. + */ + get viewportMoved(): boolean; + /** + Indicates whether the height of a block element in the editor + changed in this update. + */ + get heightChanged(): boolean; + /** + Returns true when the document was modified or the size of the + editor, or elements within the editor, changed. + */ + get geometryChanged(): boolean; + /** + True when this update indicates a focus change. + */ + get focusChanged(): boolean; + /** + Whether the document changed in this update. + */ + get docChanged(): boolean; + /** + Whether the selection was explicitly set in this update. + */ + get selectionSet(): boolean; +} + +/** +Interface that objects registered with +[`EditorView.mouseSelectionStyle`](https://codemirror.net/6/docs/ref/#view.EditorView^mouseSelectionStyle) +must conform to. +*/ +interface MouseSelectionStyle { + /** + Return a new selection for the mouse gesture that starts with + the event that was originally given to the constructor, and ends + with the event passed here. In case of a plain click, those may + both be the `mousedown` event, in case of a drag gesture, the + latest `mousemove` event will be passed. + + When `extend` is true, that means the new selection should, if + possible, extend the start selection. If `multiple` is true, the + new selection should be added to the original selection. + */ + get: (curEvent: MouseEvent, extend: boolean, multiple: boolean) => EditorSelection; + /** + Called when the view is updated while the gesture is in + progress. When the document changes, it may be necessary to map + some data (like the original selection or start position) + through the changes. + + This may return `true` to indicate that the `get` method should + get queried again after the update, because something in the + update could change its result. Be wary of infinite loops when + using this (where `get` returns a new selection, which will + trigger `update`, which schedules another `get` in response). + */ + update: (update: ViewUpdate) => boolean | void; +} +type MakeSelectionStyle = (view: EditorView, event: MouseEvent) => MouseSelectionStyle | null; + +/** +Record used to represent information about a block-level element +in the editor view. +*/ +declare class BlockInfo { + /** + The start of the element in the document. + */ + readonly from: number; + /** + The length of the element. + */ + readonly length: number; + /** + The top position of the element (relative to the top of the + document). + */ + readonly top: number; + /** + Its height. + */ + readonly height: number; + /** + The type of element this is. When querying lines, this may be + an array of all the blocks that make up the line. + */ + get type(): BlockType | readonly BlockInfo[]; + /** + The end of the element as a document position. + */ + get to(): number; + /** + The bottom position of the element. + */ + get bottom(): number; + /** + If this is a widget block, this will return the widget + associated with it. + */ + get widget(): WidgetType | null; + /** + If this is a textblock, this holds the number of line breaks + that appear in widgets inside the block. + */ + get widgetLineBreaks(): number; +} + +/** +The type of object given to the [`EditorView`](https://codemirror.net/6/docs/ref/#view.EditorView) +constructor. +*/ +interface EditorViewConfig extends EditorStateConfig { + /** + The view's initial state. If not given, a new state is created + by passing this configuration object to + [`EditorState.create`](https://codemirror.net/6/docs/ref/#state.EditorState^create), using its + `doc`, `selection`, and `extensions` field (if provided). + */ + state?: EditorState; + /** + When given, the editor is immediately appended to the given + element on creation. (Otherwise, you'll have to place the view's + [`dom`](https://codemirror.net/6/docs/ref/#view.EditorView.dom) element in the document yourself.) + */ + parent?: Element | DocumentFragment; + /** + If the view is going to be mounted in a shadow root or document + other than the one held by the global variable `document` (the + default), you should pass it here. If you provide `parent`, but + not this option, the editor will automatically look up a root + from the parent. + */ + root?: Document | ShadowRoot; + /** + Pass an effect created with + [`EditorView.scrollIntoView`](https://codemirror.net/6/docs/ref/#view.EditorView^scrollIntoView) or + [`EditorView.scrollSnapshot`](https://codemirror.net/6/docs/ref/#view.EditorView.scrollSnapshot) + here to set an initial scroll position. + */ + scrollTo?: StateEffect; + /** + Override the way transactions are + [dispatched](https://codemirror.net/6/docs/ref/#view.EditorView.dispatch) for this editor view. + Your implementation, if provided, should probably call the + view's [`update` method](https://codemirror.net/6/docs/ref/#view.EditorView.update). + */ + dispatchTransactions?: (trs: readonly Transaction[], view: EditorView) => void; + /** + **Deprecated** single-transaction version of + `dispatchTransactions`. Will force transactions to be dispatched + one at a time when used. + */ + dispatch?: (tr: Transaction, view: EditorView) => void; +} +/** +An editor view represents the editor's user interface. It holds +the editable DOM surface, and possibly other elements such as the +line number gutter. It handles events and dispatches state +transactions for editing actions. +*/ +declare class EditorView { + /** + The current editor state. + */ + get state(): EditorState; + /** + To be able to display large documents without consuming too much + memory or overloading the browser, CodeMirror only draws the + code that is visible (plus a margin around it) to the DOM. This + property tells you the extent of the current drawn viewport, in + document positions. + */ + get viewport(): { + from: number; + to: number; + }; + /** + When there are, for example, large collapsed ranges in the + viewport, its size can be a lot bigger than the actual visible + content. Thus, if you are doing something like styling the + content in the viewport, it is preferable to only do so for + these ranges, which are the subset of the viewport that is + actually drawn. + */ + get visibleRanges(): readonly { + from: number; + to: number; + }[]; + /** + Returns false when the editor is entirely scrolled out of view + or otherwise hidden. + */ + get inView(): boolean; + /** + Indicates whether the user is currently composing text via + [IME](https://en.wikipedia.org/wiki/Input_method), and at least + one change has been made in the current composition. + */ + get composing(): boolean; + /** + Indicates whether the user is currently in composing state. Note + that on some platforms, like Android, this will be the case a + lot, since just putting the cursor on a word starts a + composition there. + */ + get compositionStarted(): boolean; + private dispatchTransactions; + private _root; + /** + The document or shadow root that the view lives in. + */ + get root(): DocumentOrShadowRoot; + /** + The DOM element that wraps the entire editor view. + */ + readonly dom: HTMLElement; + /** + The DOM element that can be styled to scroll. (Note that it may + not have been, so you can't assume this is scrollable.) + */ + readonly scrollDOM: HTMLElement; + /** + The editable DOM element holding the editor content. You should + not, usually, interact with this content directly though the + DOM, since the editor will immediately undo most of the changes + you make. Instead, [dispatch](https://codemirror.net/6/docs/ref/#view.EditorView.dispatch) + [transactions](https://codemirror.net/6/docs/ref/#state.Transaction) to modify content, and + [decorations](https://codemirror.net/6/docs/ref/#view.Decoration) to style it. + */ + readonly contentDOM: HTMLElement; + private announceDOM; + private plugins; + private pluginMap; + private editorAttrs; + private contentAttrs; + private styleModules; + private bidiCache; + private destroyed; + /** + Construct a new view. You'll want to either provide a `parent` + option, or put `view.dom` into your document after creating a + view, so that the user can see the editor. + */ + constructor(config?: EditorViewConfig); + /** + All regular editor state updates should go through this. It + takes a transaction, array of transactions, or transaction spec + and updates the view to show the new state produced by that + transaction. Its implementation can be overridden with an + [option](https://codemirror.net/6/docs/ref/#view.EditorView.constructor^config.dispatchTransactions). + This function is bound to the view instance, so it does not have + to be called as a method. + + Note that when multiple `TransactionSpec` arguments are + provided, these define a single transaction (the specs will be + merged), not a sequence of transactions. + */ + dispatch(tr: Transaction): void; + dispatch(trs: readonly Transaction[]): void; + dispatch(...specs: TransactionSpec[]): void; + /** + Update the view for the given array of transactions. This will + update the visible document and selection to match the state + produced by the transactions, and notify view plugins of the + change. You should usually call + [`dispatch`](https://codemirror.net/6/docs/ref/#view.EditorView.dispatch) instead, which uses this + as a primitive. + */ + update(transactions: readonly Transaction[]): void; + /** + Reset the view to the given state. (This will cause the entire + document to be redrawn and all view plugins to be reinitialized, + so you should probably only use it when the new state isn't + derived from the old state. Otherwise, use + [`dispatch`](https://codemirror.net/6/docs/ref/#view.EditorView.dispatch) instead.) + */ + setState(newState: EditorState): void; + private updatePlugins; + private docViewUpdate; + /** + Get the CSS classes for the currently active editor themes. + */ + get themeClasses(): string; + private updateAttrs; + private showAnnouncements; + private mountStyles; + private readMeasured; + /** + Schedule a layout measurement, optionally providing callbacks to + do custom DOM measuring followed by a DOM write phase. Using + this is preferable reading DOM layout directly from, for + example, an event handler, because it'll make sure measuring and + drawing done by other components is synchronized, avoiding + unnecessary DOM layout computations. + */ + requestMeasure(request?: MeasureRequest): void; + /** + Get the value of a specific plugin, if present. Note that + plugins that crash can be dropped from a view, so even when you + know you registered a given plugin, it is recommended to check + the return value of this method. + */ + plugin(plugin: ViewPlugin): T | null; + /** + The top position of the document, in screen coordinates. This + may be negative when the editor is scrolled down. Points + directly to the top of the first line, not above the padding. + */ + get documentTop(): number; + /** + Reports the padding above and below the document. + */ + get documentPadding(): { + top: number; + bottom: number; + }; + /** + If the editor is transformed with CSS, this provides the scale + along the X axis. Otherwise, it will just be 1. Note that + transforms other than translation and scaling are not supported. + */ + get scaleX(): number; + /** + Provide the CSS transformed scale along the Y axis. + */ + get scaleY(): number; + /** + Find the text line or block widget at the given vertical + position (which is interpreted as relative to the [top of the + document](https://codemirror.net/6/docs/ref/#view.EditorView.documentTop)). + */ + elementAtHeight(height: number): BlockInfo; + /** + Find the line block (see + [`lineBlockAt`](https://codemirror.net/6/docs/ref/#view.EditorView.lineBlockAt)) at the given + height, again interpreted relative to the [top of the + document](https://codemirror.net/6/docs/ref/#view.EditorView.documentTop). + */ + lineBlockAtHeight(height: number): BlockInfo; + /** + Get the extent and vertical position of all [line + blocks](https://codemirror.net/6/docs/ref/#view.EditorView.lineBlockAt) in the viewport. Positions + are relative to the [top of the + document](https://codemirror.net/6/docs/ref/#view.EditorView.documentTop); + */ + get viewportLineBlocks(): BlockInfo[]; + /** + Find the line block around the given document position. A line + block is a range delimited on both sides by either a + non-[hidden](https://codemirror.net/6/docs/ref/#view.Decoration^replace) line break, or the + start/end of the document. It will usually just hold a line of + text, but may be broken into multiple textblocks by block + widgets. + */ + lineBlockAt(pos: number): BlockInfo; + /** + The editor's total content height. + */ + get contentHeight(): number; + /** + Move a cursor position by [grapheme + cluster](https://codemirror.net/6/docs/ref/#state.findClusterBreak). `forward` determines whether + the motion is away from the line start, or towards it. In + bidirectional text, the line is traversed in visual order, using + the editor's [text direction](https://codemirror.net/6/docs/ref/#view.EditorView.textDirection). + When the start position was the last one on the line, the + returned position will be across the line break. If there is no + further line, the original position is returned. + + By default, this method moves over a single cluster. The + optional `by` argument can be used to move across more. It will + be called with the first cluster as argument, and should return + a predicate that determines, for each subsequent cluster, + whether it should also be moved over. + */ + moveByChar(start: SelectionRange, forward: boolean, by?: (initial: string) => (next: string) => boolean): SelectionRange; + /** + Move a cursor position across the next group of either + [letters](https://codemirror.net/6/docs/ref/#state.EditorState.charCategorizer) or non-letter + non-whitespace characters. + */ + moveByGroup(start: SelectionRange, forward: boolean): SelectionRange; + /** + Get the cursor position visually at the start or end of a line. + Note that this may differ from the _logical_ position at its + start or end (which is simply at `line.from`/`line.to`) if text + at the start or end goes against the line's base text direction. + */ + visualLineSide(line: Line, end: boolean): SelectionRange; + /** + Move to the next line boundary in the given direction. If + `includeWrap` is true, line wrapping is on, and there is a + further wrap point on the current line, the wrap point will be + returned. Otherwise this function will return the start or end + of the line. + */ + moveToLineBoundary(start: SelectionRange, forward: boolean, includeWrap?: boolean): SelectionRange; + /** + Move a cursor position vertically. When `distance` isn't given, + it defaults to moving to the next line (including wrapped + lines). Otherwise, `distance` should provide a positive distance + in pixels. + + When `start` has a + [`goalColumn`](https://codemirror.net/6/docs/ref/#state.SelectionRange.goalColumn), the vertical + motion will use that as a target horizontal position. Otherwise, + the cursor's own horizontal position is used. The returned + cursor will have its goal column set to whichever column was + used. + */ + moveVertically(start: SelectionRange, forward: boolean, distance?: number): SelectionRange; + /** + Find the DOM parent node and offset (child offset if `node` is + an element, character offset when it is a text node) at the + given document position. + + Note that for positions that aren't currently in + `visibleRanges`, the resulting DOM position isn't necessarily + meaningful (it may just point before or after a placeholder + element). + */ + domAtPos(pos: number, side?: -1 | 1): { + node: Node; + offset: number; + }; + /** + Find the document position at the given DOM node. Can be useful + for associating positions with DOM events. Will raise an error + when `node` isn't part of the editor content. + */ + posAtDOM(node: Node, offset?: number): number; + /** + Get the document position at the given screen coordinates. For + positions not covered by the visible viewport's DOM structure, + this will return null, unless `false` is passed as second + argument, in which case it'll return an estimated position that + would be near the coordinates if it were rendered. + */ + posAtCoords(coords: { + x: number; + y: number; + }, precise: false): number; + posAtCoords(coords: { + x: number; + y: number; + }): number | null; + /** + Like [`posAtCoords`](https://codemirror.net/6/docs/ref/#view.EditorView.posAtCoords), but also + returns which side of the position the coordinates are closest + to. For example, for coordinates on the left side of a + left-to-right character, the position before that letter is + returned, with `assoc` 1, whereas on the right side, you'd get + the position after the character, with `assoc` -1. + */ + posAndSideAtCoords(coords: { + x: number; + y: number; + }, precise: false): { + pos: number; + assoc: -1 | 1; + }; + posAndSideAtCoords(coords: { + x: number; + y: number; + }): { + pos: number; + assoc: -1 | 1; + } | null; + /** + Get the screen coordinates at the given document position. + `side` determines whether the coordinates are based on the + element before (-1) or after (1) the position (if no element is + available on the given side, the method will transparently use + another strategy to get reasonable coordinates). + */ + coordsAtPos(pos: number, side?: -1 | 1): Rect | null; + /** + Return the rectangle around a given character. If `pos` does not + point in front of a character that is in the viewport and + rendered (i.e. not replaced, not a line break), this will return + null. For space characters that are a line wrap point, this will + return the position before the line break. + */ + coordsForChar(pos: number): Rect | null; + /** + The default width of a character in the editor. May not + accurately reflect the width of all characters (given variable + width fonts or styling of invididual ranges). + */ + get defaultCharacterWidth(): number; + /** + The default height of a line in the editor. May not be accurate + for all lines. + */ + get defaultLineHeight(): number; + /** + The text direction + ([`direction`](https://developer.mozilla.org/en-US/docs/Web/CSS/direction) + CSS property) of the editor's content element. + */ + get textDirection(): Direction; + /** + Find the text direction of the block at the given position, as + assigned by CSS. If + [`perLineTextDirection`](https://codemirror.net/6/docs/ref/#view.EditorView^perLineTextDirection) + isn't enabled, or the given position is outside of the viewport, + this will always return the same as + [`textDirection`](https://codemirror.net/6/docs/ref/#view.EditorView.textDirection). Note that + this may trigger a DOM layout. + */ + textDirectionAt(pos: number): Direction; + /** + Whether this editor [wraps lines](https://codemirror.net/6/docs/ref/#view.EditorView.lineWrapping) + (as determined by the + [`white-space`](https://developer.mozilla.org/en-US/docs/Web/CSS/white-space) + CSS property of its content element). + */ + get lineWrapping(): boolean; + /** + Returns the bidirectional text structure of the given line + (which should be in the current document) as an array of span + objects. The order of these spans matches the [text + direction](https://codemirror.net/6/docs/ref/#view.EditorView.textDirection)—if that is + left-to-right, the leftmost spans come first, otherwise the + rightmost spans come first. + */ + bidiSpans(line: Line): readonly BidiSpan[]; + /** + Check whether the editor has focus. + */ + get hasFocus(): boolean; + /** + Put focus on the editor. + */ + focus(): void; + /** + Update the [root](https://codemirror.net/6/docs/ref/##view.EditorViewConfig.root) in which the editor lives. This is only + necessary when moving the editor's existing DOM to a new window or shadow root. + */ + setRoot(root: Document | ShadowRoot): void; + /** + Clean up this editor view, removing its element from the + document, unregistering event handlers, and notifying + plugins. The view instance can no longer be used after + calling this. + */ + destroy(): void; + /** + Returns an effect that can be + [added](https://codemirror.net/6/docs/ref/#state.TransactionSpec.effects) to a transaction to + cause it to scroll the given position or range into view. + */ + static scrollIntoView(pos: number | SelectionRange, options?: { + /** + By default (`"nearest"`) the position will be vertically + scrolled only the minimal amount required to move the given + position into view. You can set this to `"start"` to move it + to the top of the view, `"end"` to move it to the bottom, or + `"center"` to move it to the center. + */ + y?: ScrollStrategy; + /** + Effect similar to + [`y`](https://codemirror.net/6/docs/ref/#view.EditorView^scrollIntoView^options.y), but for the + horizontal scroll position. + */ + x?: ScrollStrategy; + /** + Extra vertical distance to add when moving something into + view. Not used with the `"center"` strategy. Defaults to 5. + Must be less than the height of the editor. + */ + yMargin?: number; + /** + Extra horizontal distance to add. Not used with the `"center"` + strategy. Defaults to 5. Must be less than the width of the + editor. + */ + xMargin?: number; + }): StateEffect; + /** + Return an effect that resets the editor to its current (at the + time this method was called) scroll position. Note that this + only affects the editor's own scrollable element, not parents. + See also + [`EditorViewConfig.scrollTo`](https://codemirror.net/6/docs/ref/#view.EditorViewConfig.scrollTo). + + The effect should be used with a document identical to the one + it was created for. Failing to do so is not an error, but may + not scroll to the expected position. You can + [map](https://codemirror.net/6/docs/ref/#state.StateEffect.map) the effect to account for changes. + */ + scrollSnapshot(): StateEffect; + /** + Enable or disable tab-focus mode, which disables key bindings + for Tab and Shift-Tab, letting the browser's default + focus-changing behavior go through instead. This is useful to + prevent trapping keyboard users in your editor. + + Without argument, this toggles the mode. With a boolean, it + enables (true) or disables it (false). Given a number, it + temporarily enables the mode until that number of milliseconds + have passed or another non-Tab key is pressed. + */ + setTabFocusMode(to?: boolean | number): void; + /** + Facet to add a [style + module](https://code.haverbeke.berlin/marijn/style-mod#documentation) to + an editor view. The view will ensure that the module is + mounted in its [document + root](https://codemirror.net/6/docs/ref/#view.EditorView.constructor^config.root). + */ + static styleModule: Facet; + /** + Returns an extension that can be used to add DOM event handlers. + The value should be an object mapping event names to handler + functions. For any given event, such functions are ordered by + extension precedence, and the first handler to return true will + be assumed to have handled that event, and no other handlers or + built-in behavior will be activated for it. These are registered + on the [content element](https://codemirror.net/6/docs/ref/#view.EditorView.contentDOM), except + for `scroll` handlers, which will be called any time the + editor's [scroll element](https://codemirror.net/6/docs/ref/#view.EditorView.scrollDOM) or one of + its parent nodes is scrolled. + */ + static domEventHandlers(handlers: DOMEventHandlers): Extension; + /** + Create an extension that registers DOM event observers. Contrary + to event [handlers](https://codemirror.net/6/docs/ref/#view.EditorView^domEventHandlers), + observers can't be prevented from running by a higher-precedence + handler returning true. They also don't prevent other handlers + and observers from running when they return true, and should not + call `preventDefault`. + */ + static domEventObservers(observers: DOMEventHandlers): Extension; + /** + An input handler can override the way changes to the editable + DOM content are handled. Handlers are passed the document + positions between which the change was found, and the new + content. When one returns true, no further input handlers are + called and the default behavior is prevented. + + The `insert` argument can be used to get the default transaction + that would be applied for this input. This can be useful when + dispatching the custom behavior as a separate transaction. + */ + static inputHandler: Facet<(view: EditorView, from: number, to: number, text: string, insert: () => Transaction) => boolean, readonly ((view: EditorView, from: number, to: number, text: string, insert: () => Transaction) => boolean)[]>; + /** + Functions provided in this facet will be used to transform text + pasted or dropped into the editor. + */ + static clipboardInputFilter: Facet<(text: string, state: EditorState) => string, readonly ((text: string, state: EditorState) => string)[]>; + /** + Transform text copied or dragged from the editor. + */ + static clipboardOutputFilter: Facet<(text: string, state: EditorState) => string, readonly ((text: string, state: EditorState) => string)[]>; + /** + Scroll handlers can override how things are scrolled into view. + If they return `true`, no further handling happens for the + scrolling. If they return false, the default scroll behavior is + applied. Scroll handlers should never initiate editor updates. + */ + static scrollHandler: Facet<(view: EditorView, range: SelectionRange, options: { + x: ScrollStrategy; + y: ScrollStrategy; + xMargin: number; + yMargin: number; + }) => boolean, readonly ((view: EditorView, range: SelectionRange, options: { + x: ScrollStrategy; + y: ScrollStrategy; + xMargin: number; + yMargin: number; + }) => boolean)[]>; + /** + Configure a capped, directional viewport buffer while scrolling. + */ + static viewportBuffer: Facet; + /** + Configure render-gated vertical touch scrolling on Android Chromium. + */ + static controlledTouchScroll: Facet; + /** + This facet can be used to provide functions that create effects + to be dispatched when the editor's focus state changes. + */ + static focusChangeEffect: Facet<(state: EditorState, focusing: boolean) => StateEffect | null, readonly ((state: EditorState, focusing: boolean) => StateEffect | null)[]>; + /** + By default, the editor assumes all its content has the same + [text direction](https://codemirror.net/6/docs/ref/#view.Direction). Configure this with a `true` + value to make it read the text direction of every (rendered) + line separately. + */ + static perLineTextDirection: Facet; + /** + Allows you to provide a function that should be called when the + library catches an exception from an extension (mostly from view + plugins, but may be used by other extensions to route exceptions + from user-code-provided callbacks). This is mostly useful for + debugging and logging. See [`logException`](https://codemirror.net/6/docs/ref/#view.logException). + */ + static exceptionSink: Facet<(exception: any) => void, readonly ((exception: any) => void)[]>; + /** + A facet that can be used to register a function to be called + every time the view updates. + */ + static updateListener: Facet<(update: ViewUpdate) => void, readonly ((update: ViewUpdate) => void)[]>; + /** + Facet that controls whether the editor content DOM is editable. + When its highest-precedence value is `false`, the element will + not have its `contenteditable` attribute set. (Note that this + doesn't affect API calls that change the editor content, even + when those are bound to keys or buttons. See the + [`readOnly`](https://codemirror.net/6/docs/ref/#state.EditorState.readOnly) facet for that.) + */ + static editable: Facet; + /** + Allows you to influence the way mouse selection happens. The + functions in this facet will be called for a `mousedown` event + on the editor, and can return an object that overrides the way a + selection is computed from that mouse click or drag. + */ + static mouseSelectionStyle: Facet; + /** + Facet used to configure whether a given selection drag event + should move or copy the selection. The given predicate will be + called with the `mousedown` event, and can return `true` when + the drag should move the content. + */ + static dragMovesSelection: Facet<(event: MouseEvent) => boolean, readonly ((event: MouseEvent) => boolean)[]>; + /** + Facet used to configure whether a given selecting click adds a + new range to the existing selection or replaces it entirely. The + default behavior is to check `event.metaKey` on macOS, and + `event.ctrlKey` elsewhere. + */ + static clickAddsSelectionRange: Facet<(event: MouseEvent) => boolean, readonly ((event: MouseEvent) => boolean)[]>; + /** + A facet that determines which [decorations](https://codemirror.net/6/docs/ref/#view.Decoration) + are shown in the view. Decorations can be provided in two + ways—directly, or via a function that takes an editor view. + + Only decoration sets provided directly are allowed to influence + the editor's vertical layout structure. The ones provided as + functions are called _after_ the new viewport has been computed, + and thus **must not** introduce block widgets or replacing + decorations that cover line breaks. + + If you want decorated ranges to behave like atomic units for + cursor motion and deletion purposes, also provide the range set + containing the decorations to + [`EditorView.atomicRanges`](https://codemirror.net/6/docs/ref/#view.EditorView^atomicRanges). + */ + static decorations: Facet DecorationSet), readonly (DecorationSet | ((view: EditorView) => DecorationSet))[]>; + /** + [Block wrappers](https://codemirror.net/6/docs/ref/#view.BlockWrapper) provide a way to add DOM + structure around editor lines and block widgets. Sets of + wrappers are provided in a similar way to decorations, and are + nested in a similar way when they overlap. A wrapper affects all + lines and block widgets that start inside its range. + */ + static blockWrappers: Facet<_codemirror_state.RangeSet | ((view: EditorView) => _codemirror_state.RangeSet), readonly (_codemirror_state.RangeSet | ((view: EditorView) => _codemirror_state.RangeSet))[]>; + /** + Facet that works much like + [`decorations`](https://codemirror.net/6/docs/ref/#view.EditorView^decorations), but puts its + inputs at the very bottom of the precedence stack, meaning mark + decorations provided here will only be split by other, partially + overlapping `outerDecorations` ranges, and wrap around all + regular decorations. Use this for mark elements that should, as + much as possible, remain in one piece. + */ + static outerDecorations: Facet DecorationSet), readonly (DecorationSet | ((view: EditorView) => DecorationSet))[]>; + /** + Used to provide ranges that should be treated as atoms as far as + cursor motion is concerned. This causes methods like + [`moveByChar`](https://codemirror.net/6/docs/ref/#view.EditorView.moveByChar) and + [`moveVertically`](https://codemirror.net/6/docs/ref/#view.EditorView.moveVertically) (and the + commands built on top of them) to skip across such regions when + a selection endpoint would enter them. This does _not_ prevent + direct programmatic [selection + updates](https://codemirror.net/6/docs/ref/#state.TransactionSpec.selection) from moving into such + regions. + */ + static atomicRanges: Facet<(view: EditorView) => _codemirror_state.RangeSet, readonly ((view: EditorView) => _codemirror_state.RangeSet)[]>; + /** + When range decorations add a `unicode-bidi: isolate` style, they + should also include a + [`bidiIsolate`](https://codemirror.net/6/docs/ref/#view.MarkDecorationSpec.bidiIsolate) property + in their decoration spec, and be exposed through this facet, so + that the editor can compute the proper text order. (Other values + for `unicode-bidi`, except of course `normal`, are not + supported.) + */ + static bidiIsolatedRanges: Facet DecorationSet), readonly (DecorationSet | ((view: EditorView) => DecorationSet))[]>; + /** + Can be used to specify the distance that scrolling cursor into + view keeps it away from the sides of the editor, either as a + single pixel number or two different values for the different + axes. Defaults to 5 pixels on both axes. + */ + static cursorScrollMargin: Facet; + /** + Facet that allows extensions to provide additional scroll + margins (space around the sides of the scrolling element that + should be considered invisible). This can be useful when the + plugin introduces elements that cover part of that element (for + example a horizontally fixed gutter). Not to be confused with + [`cursorScrollMargin`](https://codemirror.net/6/docs/ref/#view.EditorView^cursorScrollMargin). + */ + static scrollMargins: Facet<(view: EditorView) => Partial | null, readonly ((view: EditorView) => Partial | null)[]>; + /** + Create a theme extension. The first argument can be a + [`style-mod`](https://code.haverbeke.berlin/marijn/style-mod#documentation) + style spec providing the styles for the theme. These will be + prefixed with a generated class for the style. + + Because the selectors will be prefixed with a scope class, rule + that directly match the editor's [wrapper + element](https://codemirror.net/6/docs/ref/#view.EditorView.dom)—to which the scope class will be + added—need to be explicitly differentiated by adding an `&` to + the selector for that element—for example + `&.cm-focused`. + + When `dark` is set to true, the theme will be marked as dark, + which will cause the `&dark` rules from [base + themes](https://codemirror.net/6/docs/ref/#view.EditorView^baseTheme) to be used (as opposed to + `&light` when a light theme is active). + */ + static theme(spec: { + [selector: string]: StyleSpec; + }, options?: { + dark?: boolean; + }): Extension; + /** + This facet records whether a dark theme is active. The extension + returned by [`theme`](https://codemirror.net/6/docs/ref/#view.EditorView^theme) automatically + includes an instance of this when the `dark` option is set to + true. + */ + static darkTheme: Facet; + /** + Create an extension that adds styles to the base theme. Like + with [`theme`](https://codemirror.net/6/docs/ref/#view.EditorView^theme), use `&` to indicate the + place of the editor wrapper element when directly targeting + that. You can also use `&dark` or `&light` instead to only + target editors with a dark or light theme. + */ + static baseTheme(spec: { + [selector: string]: StyleSpec; + }): Extension; + /** + Provides a Content Security Policy nonce to use when creating + the style sheets for the editor. Holds the empty string when no + nonce has been provided. + */ + static cspNonce: Facet; + /** + Facet that provides additional DOM attributes for the editor's + editable DOM element. + */ + static contentAttributes: Facet; + /** + Facet that provides DOM attributes for the editor's outer + element. + */ + static editorAttributes: Facet; + /** + An extension that enables line wrapping in the editor (by + setting CSS `white-space` to `pre-wrap` in the content). + */ + static lineWrapping: Extension; + /** + State effect used to include screen reader announcements in a + transaction. These will be added to the DOM in a visually hidden + element with `aria-live="polite"` set, and should be used to + describe effects that are visually obvious but may not be + noticed by screen reader users (such as moving to the next + search match). + */ + static announce: _codemirror_state.StateEffectType; + /** + Retrieve an editor view instance from the view's DOM + representation. + */ + static findFromDOM(dom: HTMLElement): EditorView | null; +} +/** +Helper type that maps event names to event object types, or the +`any` type for unknown events. +*/ +interface DOMEventMap extends HTMLElementEventMap { + [other: string]: any; +} +/** +Event handlers are specified with objects like this. For event +types known by TypeScript, this will infer the event argument type +to hold the appropriate event object type. For unknown events, it +is inferred to `any`, and should be explicitly set if you want type +checking. +*/ +type DOMEventHandlers = { + [event in keyof DOMEventMap]?: (this: This, event: DOMEventMap[event], view: EditorView) => boolean | void; +}; + +/** +Key bindings associate key names with +[command](https://codemirror.net/6/docs/ref/#view.Command)-style functions. + +Key names may be strings like `"Shift-Ctrl-Enter"`—a key identifier +prefixed with zero or more modifiers. Key identifiers are based on +the strings that can appear in +[`KeyEvent.key`](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key). +Use lowercase letters to refer to letter keys (or uppercase letters +if you want shift to be held). You may use `"Space"` as an alias +for the `" "` name. + +Modifiers can be given in any order. `Shift-` (or `s-`), `Alt-` (or +`a-`), `Ctrl-` (or `c-` or `Control-`) and `Cmd-` (or `m-` or +`Meta-`) are recognized. + +When a key binding contains multiple key names separated by +spaces, it represents a multi-stroke binding, which will fire when +the user presses the given keys after each other. + +You can use `Mod-` as a shorthand for `Cmd-` on Mac and `Ctrl-` on +other platforms. So `Mod-b` is `Ctrl-b` on Linux but `Cmd-b` on +macOS. +*/ +interface KeyBinding { + /** + The key name to use for this binding. If the platform-specific + property (`mac`, `win`, or `linux`) for the current platform is + used as well in the binding, that one takes precedence. If `key` + isn't defined and the platform-specific binding isn't either, + a binding is ignored. + */ + key?: string; + /** + Key to use specifically on macOS. + */ + mac?: string; + /** + Key to use specifically on Windows. + */ + win?: string; + /** + Key to use specifically on Linux. + */ + linux?: string; + /** + The command to execute when this binding is triggered. When the + command function returns `false`, further bindings will be tried + for the key. + */ + run?: Command; + /** + When given, this defines a second binding, using the (possibly + platform-specific) key name prefixed with `Shift-` to activate + this command. + */ + shift?: Command; + /** + When this property is present, the function is called for every + key that is not a multi-stroke prefix. + */ + any?: (view: EditorView, event: KeyboardEvent) => boolean; + /** + By default, key bindings apply when focus is on the editor + content (the `"editor"` scope). Some extensions, mostly those + that define their own panels, might want to allow you to + register bindings local to that panel. Such bindings should use + a custom scope name. You may also assign multiple scope names to + a binding, separating them by spaces. + */ + scope?: string; + /** + When set to true (the default is false), this will always + prevent the further handling for the bound key, even if the + command(s) return false. This can be useful for cases where the + native behavior of the key is annoying or irrelevant but the + command doesn't always apply (such as, Mod-u for undo selection, + which would cause the browser to view source instead when no + selection can be undone). + */ + preventDefault?: boolean; + /** + When set to true, `stopPropagation` will be called on keyboard + events that have their `preventDefault` called in response to + this key binding (see also + [`preventDefault`](https://codemirror.net/6/docs/ref/#view.KeyBinding.preventDefault)). + */ + stopPropagation?: boolean; +} +/** +Facet used for registering keymaps. + +You can add multiple keymaps to an editor. Their priorities +determine their precedence (the ones specified early or with high +priority get checked first). When a handler has returned `true` +for a given key, no further handlers are called. +*/ +declare const keymap: Facet; +/** +Run the key handlers registered for a given scope. The event +object should be a `"keydown"` event. Returns true if any of the +handlers handled it. +*/ +declare function runScopeHandlers(view: EditorView, event: KeyboardEvent, scope: string): boolean; + +type SelectionConfig = { + /** + The length of a full cursor blink cycle, in milliseconds. + Defaults to 1200. Can be set to 0 to disable blinking. + */ + cursorBlinkRate?: number; + /** + Whether to show a cursor for non-empty ranges. Defaults to + true. + */ + drawRangeCursor?: boolean; + /** + Because hiding the cursor also hides the selection handles in + the iOS browser, when this is enabled (the default), the + extension draws handles on the side of the selection in iOS. + */ + iosSelectionHandles?: boolean; +}; +/** +Returns an extension that hides the browser's native selection and +cursor, replacing the selection with a background behind the text +(with the `cm-selectionBackground` class), and the +cursors with elements overlaid over the code (using +`cm-cursor-primary` and `cm-cursor-secondary`). + +This allows the editor to display secondary selection ranges, and +tends to produce a type of selection more in line with that users +expect in a text editor (the native selection styling will often +leave gaps between lines and won't fill the horizontal space after +a line when the selection continues past it). + +It does have a performance cost, in that it requires an extra DOM +layout cycle for many updates (the selection is drawn based on DOM +layout information that's only available after laying out the +content). +*/ +declare function drawSelection(config?: SelectionConfig): Extension; +/** +Retrieve the [`drawSelection`](https://codemirror.net/6/docs/ref/#view.drawSelection) configuration +for this state. (Note that this will return a set of defaults even +if `drawSelection` isn't enabled.) +*/ +declare function getDrawSelectionConfig(state: EditorState): SelectionConfig; + +/** +Draws a cursor at the current drop position when something is +dragged over the editor. +*/ +declare function dropCursor(): Extension; + +interface SpecialCharConfig { + /** + An optional function that renders the placeholder elements. + + The `description` argument will be text that clarifies what the + character is, which should be provided to screen readers (for + example with the + [`aria-label`](https://www.w3.org/TR/wai-aria/#aria-label) + attribute) and optionally shown to the user in other ways (such + as the + [`title`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/title) + attribute). + + The given placeholder string is a suggestion for how to display + the character visually. + */ + render?: ((code: number, description: string | null, placeholder: string) => HTMLElement) | null; + /** + Regular expression that matches the special characters to + highlight. Must have its 'g'/global flag set. + */ + specialChars?: RegExp; + /** + Regular expression that can be used to add characters to the + default set of characters to highlight. + */ + addSpecialChars?: RegExp | null; +} +/** +Returns an extension that installs highlighting of special +characters. +*/ +declare function highlightSpecialChars( +/** +Configuration options. +*/ +config?: SpecialCharConfig): Extension; + +/** +Returns an extension that makes sure the content has a bottom +margin equivalent to the height of the editor, minus one line +height, so that every line in the document can be scrolled to the +top of the editor. + +This is only meaningful when the editor is scrollable, and should +not be enabled in editors that take the size of their content. +*/ +declare function scrollPastEnd(): Extension; + +/** +Mark lines that have a cursor on them with the `"cm-activeLine"` +DOM class. +*/ +declare function highlightActiveLine(): Extension; + +/** +Extension that enables a placeholder—a piece of example content +to show when the editor is empty. +*/ +declare function placeholder(content: string | HTMLElement | ((view: EditorView) => HTMLElement)): Extension; + +/** +Markers shown in a [layer](https://codemirror.net/6/docs/ref/#view.layer) must conform to this +interface. They are created in a measuring phase, and have to +contain all their positioning information, so that they can be +drawn without further DOM layout reading. + +Markers are automatically absolutely positioned. Their parent +element has the same top-left corner as the document, so they +should be positioned relative to the document. +*/ +interface LayerMarker { + /** + Compare this marker to a marker of the same type. Used to avoid + unnecessary redraws. + */ + eq(other: LayerMarker): boolean; + /** + Draw the marker to the DOM. + */ + draw(): HTMLElement; + /** + Update an existing marker of this type to this marker. + */ + update?(dom: HTMLElement, oldMarker: LayerMarker): boolean; +} +/** +Implementation of [`LayerMarker`](https://codemirror.net/6/docs/ref/#view.LayerMarker) that creates +a rectangle at a given set of coordinates. +*/ +declare class RectangleMarker implements LayerMarker { + private className; + /** + The left position of the marker (in pixels, document-relative). + */ + readonly left: number; + /** + The top position of the marker. + */ + readonly top: number; + /** + The width of the marker, or null if it shouldn't get a width assigned. + */ + readonly width: number | null; + /** + The height of the marker. + */ + readonly height: number; + /** + Create a marker with the given class and dimensions. If `width` + is null, the DOM element will get no width style. + */ + constructor(className: string, + /** + The left position of the marker (in pixels, document-relative). + */ + left: number, + /** + The top position of the marker. + */ + top: number, + /** + The width of the marker, or null if it shouldn't get a width assigned. + */ + width: number | null, + /** + The height of the marker. + */ + height: number); + draw(): HTMLDivElement; + update(elt: HTMLElement, prev: RectangleMarker): boolean; + private adjust; + eq(p: RectangleMarker): boolean; + /** + Create a set of rectangles for the given selection range, + assigning them theclass`className`. Will create a single + rectangle for empty ranges, and a set of selection-style + rectangles covering the range's content (in a bidi-aware + way) for non-empty ones. + */ + static forRange(view: EditorView, className: string, range: SelectionRange): readonly RectangleMarker[]; +} +interface LayerConfig { + /** + Determines whether this layer is shown above or below the text. + */ + above: boolean; + /** + When given, this class is added to the DOM element that will + wrap the markers. + */ + class?: string; + /** + Called on every view update. Returning true triggers a marker + update (a call to `markers` and drawing of those markers). + */ + update(update: ViewUpdate, layer: HTMLElement): boolean; + /** + Whether to update this layer every time the document view + changes. Defaults to true. + */ + updateOnDocViewUpdate?: boolean; + /** + Build a set of markers for this layer, and measure their + dimensions. + */ + markers(view: EditorView): readonly LayerMarker[]; + /** + If given, this is called when the layer is created. + */ + mount?(layer: HTMLElement, view: EditorView): void; + /** + If given, called when the layer is removed from the editor or + the entire editor is destroyed. + */ + destroy?(layer: HTMLElement, view: EditorView): void; +} +/** +Define a layer. +*/ +declare function layer(config: LayerConfig): Extension; + +/** +Helper class used to make it easier to maintain decorations on +visible code that matches a given regular expression. To be used +in a [view plugin](https://codemirror.net/6/docs/ref/#view.ViewPlugin). Instances of this object +represent a matching configuration. +*/ +declare class MatchDecorator { + private regexp; + private addMatch; + private boundary; + private maxLength; + /** + Create a decorator. + */ + constructor(config: { + /** + The regular expression to match against the content. Will only + be matched inside lines (not across them). Should have its 'g' + flag set. + */ + regexp: RegExp; + /** + The decoration to apply to matches, either directly or as a + function of the match. + */ + decoration?: Decoration | ((match: RegExpExecArray, view: EditorView, pos: number) => Decoration | null); + /** + Customize the way decorations are added for matches. This + function, when given, will be called for matches and should + call `add` to create decorations for them. Note that the + decorations should appear *in* the given range, and the + function should have no side effects beyond calling `add`. + + The `decoration` option is ignored when `decorate` is + provided. + */ + decorate?: (add: (from: number, to: number, decoration: Decoration) => void, from: number, to: number, match: RegExpExecArray, view: EditorView) => void; + /** + By default, changed lines are re-matched entirely. You can + provide a boundary expression, which should match single + character strings that can never occur in `regexp`, to reduce + the amount of re-matching. + */ + boundary?: RegExp; + /** + Matching happens by line, by default, but when lines are + folded or very long lines are only partially drawn, the + decorator may avoid matching part of them for speed. This + controls how much additional invisible content it should + include in its matches. Defaults to 1000. + */ + maxLength?: number; + }); + /** + Compute the full set of decorations for matches in the given + view's viewport. You'll want to call this when initializing your + plugin. + */ + createDeco(view: EditorView): _codemirror_state.RangeSet; + /** + Update a set of decorations for a view update. `deco` _must_ be + the set of decorations produced by _this_ `MatchDecorator` for + the view state before the update. + */ + updateDeco(update: ViewUpdate, deco: DecorationSet): DecorationSet; + private updateRange; +} + +/** +Create an extension that enables rectangular selections. By +default, it will react to left mouse drag with the Alt key held +down. When such a selection occurs, the text within the rectangle +that was dragged over will be selected, as one selection +[range](https://codemirror.net/6/docs/ref/#state.SelectionRange) per line. +*/ +declare function rectangularSelection(options?: { + /** + A custom predicate function, which takes a `mousedown` event and + returns true if it should be used for rectangular selection. + */ + eventFilter?: (event: MouseEvent) => boolean; +}): Extension; +/** +Returns an extension that turns the pointer cursor into a +crosshair when a given modifier key, defaulting to Alt, is held +down. Can serve as a visual hint that rectangular selection is +going to happen when paired with +[`rectangularSelection`](https://codemirror.net/6/docs/ref/#view.rectangularSelection). +*/ +declare function crosshairCursor(options?: { + key?: "Alt" | "Control" | "Shift" | "Meta"; +}): Extension; + +/** +Creates an extension that configures tooltip behavior. +*/ +declare function tooltips(config?: { + /** + By default, tooltips use `"fixed"` + [positioning](https://developer.mozilla.org/en-US/docs/Web/CSS/position), + which has the advantage that tooltips don't get cut off by + scrollable parent elements. However, CSS rules like `contain: + layout` can break fixed positioning in child nodes, which can be + worked about by using `"absolute"` here. + + On iOS, which at the time of writing still doesn't properly + support fixed positioning, the library always uses absolute + positioning. + + If the tooltip parent element sits in a transformed element, the + library also falls back to absolute positioning. + */ + position?: "fixed" | "absolute"; + /** + The element to put the tooltips into. By default, they are put + in the editor (`cm-editor`) element, and that is usually what + you want. But in some layouts that can lead to positioning + issues, and you need to use a different parent to work around + those. + */ + parent?: HTMLElement; + /** + By default, when figuring out whether there is room for a + tooltip at a given position, the extension considers the entire + space between 0,0 and + `documentElement.clientWidth`/`clientHeight` to be available for + showing tooltips. You can provide a function here that returns + an alternative rectangle. + */ + tooltipSpace?: (view: EditorView) => Rect; +}): Extension; +/** +Describes a tooltip. Values of this type, when provided through +the [`showTooltip`](https://codemirror.net/6/docs/ref/#view.showTooltip) facet, control the +individual tooltips on the editor. +*/ +interface Tooltip { + /** + The document position at which to show the tooltip. + */ + pos: number; + /** + The end of the range annotated by this tooltip, if different + from `pos`. + */ + end?: number; + /** + A constructor function that creates the tooltip's [DOM + representation](https://codemirror.net/6/docs/ref/#view.TooltipView). + */ + create(view: EditorView): TooltipView; + /** + Whether the tooltip should be shown above or below the target + position. Not guaranteed to be respected for hover tooltips + since all hover tooltips for the same range are always + positioned together. Defaults to false. + */ + above?: boolean; + /** + Whether the `above` option should be honored when there isn't + enough space on that side to show the tooltip inside the + viewport. Defaults to false. + */ + strictSide?: boolean; + /** + When set to true, show a triangle connecting the tooltip element + to position `pos`. + */ + arrow?: boolean; + /** + By default, tooltips are hidden when their position is outside + of the visible editor content. Set this to false to turn that + off. + */ + clip?: boolean; +} +/** +Describes the way a tooltip is displayed. +*/ +interface TooltipView { + /** + The DOM element to position over the editor. + */ + dom: HTMLElement; + /** + Adjust the position of the tooltip relative to its anchor + position. A positive `x` value will move the tooltip + horizontally along with the text direction (so right in + left-to-right context, left in right-to-left). A positive `y` + will move the tooltip up when it is above its anchor, and down + otherwise. + */ + offset?: { + x: number; + y: number; + }; + /** + By default, a tooltip's screen position will be based on the + text position of its `pos` property. This method can be provided + to make the tooltip view itself responsible for finding its + screen position. + */ + getCoords?: (pos: number) => Rect; + /** + By default, tooltips are moved when they overlap with other + tooltips. Set this to `true` to disable that behavior for this + tooltip. + */ + overlap?: boolean; + /** + Called after the tooltip is added to the DOM for the first time. + */ + mount?(view: EditorView): void; + /** + Update the DOM element for a change in the view's state. + */ + update?(update: ViewUpdate): void; + /** + Called when the tooltip is removed from the editor or the editor + is destroyed. + */ + destroy?(): void; + /** + Called when the tooltip has been (re)positioned. The argument is + the [space](https://codemirror.net/6/docs/ref/#view.tooltips^config.tooltipSpace) available to the + tooltip. + */ + positioned?(space: Rect): void; + /** + By default, the library will restrict the size of tooltips so + that they don't stick out of the available space. Set this to + false to disable that. + */ + resize?: boolean; +} +/** +Facet to which an extension can add a value to show a tooltip. +*/ +declare const showTooltip: Facet; +/** +The type of function that can be used as a [hover tooltip +source](https://codemirror.net/6/docs/ref/#view.hoverTooltip^source). +*/ +type HoverTooltipSource = (view: EditorView, pos: number, side: -1 | 1) => Tooltip | readonly Tooltip[] | null | Promise; +/** +Set up a hover tooltip, which shows up when the pointer hovers +over ranges of text. The callback is called when the mouse hovers +over the document text. It should, if there is a tooltip +associated with position `pos`, return the tooltip description +(either directly or in a promise). The `side` argument indicates +on which side of the position the pointer is—it will be -1 if the +pointer is before the position, 1 if after the position. + +Note that all hover tooltips are hosted within a single tooltip +container element. This allows multiple tooltips over the same +range to be "merged" together without overlapping. + +The return value is a valid [editor extension](https://codemirror.net/6/docs/ref/#state.Extension) +but also provides an `active` property holding a state field that +can be used to read the currently active tooltips produced by this +extension. +*/ +declare function hoverTooltip(source: HoverTooltipSource, options?: { + /** + Controls whether a transaction hides the tooltip. The default + is to not hide. + */ + hideOn?: (tr: Transaction, tooltip: Tooltip) => boolean; + /** + When enabled (this defaults to false), close the tooltip + whenever the document changes or the selection is set. + */ + hideOnChange?: boolean | "touch"; + /** + Hover time after which the tooltip should appear, in + milliseconds. Defaults to 300ms. + */ + hoverTime?: number; +}): Extension & { + active: StateField; +}; +/** +Activate hover tooltips for the given position and side. If you +provide a specific hover tooltip (the value returned from +[`hoverTooltip`](https://codemirror.net/6/docs/ref/#view.hoverTooltip)), only that one will be +activated. If not given, all hover tooltips at the given position +are triggered. + +Note that tooltips opened this way don't close automatically, and +you'll want to pass an `until` callback or use +[`closeHoverTooltip`](https://codemirror.net/6/docs/ref/#view.closeHoverTooltip)/[`closeHoverTooltips`](https://codemirror.net/6/docs/ref/#view.closeHoverTooltips) +to deactivate them. +*/ +declare function activateHover(view: EditorView, pos: number, side: -1 | 1, options?: { + tooltip?: Extension; + until?: (tr: Transaction) => boolean; +}): void; +/** +Get the active tooltip view for a given tooltip, if available. +*/ +declare function getTooltip(view: EditorView, tooltip: Tooltip): TooltipView | null; +/** +Returns true if any hover tooltips are currently active. +*/ +declare function hasHoverTooltips(state: EditorState): boolean; +/** +Transaction effect that closes all hover tooltips. +*/ +declare const closeHoverTooltips: StateEffect; +/** +Transaction effect that closes a specific hover tooltip. +*/ +declare function closeHoverTooltip(tooltip: Extension & { + active: StateField; +}): StateEffect; +/** +Tell the tooltip extension to recompute the position of the active +tooltips. This can be useful when something happens (such as a +re-positioning or CSS change affecting the editor) that could +invalidate the existing tooltip positions. +*/ +declare function repositionTooltips(view: EditorView): void; + +type PanelConfig = { + /** + By default, panels will be placed inside the editor's DOM + structure. You can use this option to override where panels with + `top: true` are placed. + */ + topContainer?: HTMLElement; + /** + Override where panels with `top: false` are placed. + */ + bottomContainer?: HTMLElement; +}; +/** +Configures the panel-managing extension. +*/ +declare function panels(config?: PanelConfig): Extension; +/** +Object that describes an active panel. +*/ +interface Panel { + /** + The element representing this panel. The library will add the + `"cm-panel"` DOM class to this. + */ + dom: HTMLElement; + /** + Optionally called after the panel has been added to the editor. + */ + mount?(): void; + /** + Update the DOM for a given view update. + */ + update?(update: ViewUpdate): void; + /** + Called when the panel is removed from the editor or the editor + is destroyed. + */ + destroy?(): void; + /** + Whether the panel should be at the top or bottom of the editor. + Defaults to false. + */ + top?: boolean; +} +/** +Get the active panel created by the given constructor, if any. +This can be useful when you need access to your panels' DOM +structure. +*/ +declare function getPanel(view: EditorView, panel: PanelConstructor): Panel | null; +/** +A function that initializes a panel. Used in +[`showPanel`](https://codemirror.net/6/docs/ref/#view.showPanel). +*/ +type PanelConstructor = (view: EditorView) => Panel; +/** +Opening a panel is done by providing a constructor function for +the panel through this facet. (The panel is closed again when its +constructor is no longer provided.) Values of `null` are ignored. +*/ +declare const showPanel: Facet; + +type DialogConfig = { + /** + A function to render the content of the dialog. The result + should contain at least one `
` element. Submit handlers + and a handler for the Escape key will be added to the form. + + If this is not given, the `label`, `input`, and `submitLabel` + fields will be used to create a simple form for you. + */ + content?: (view: EditorView, close: () => void) => HTMLElement; + /** + When `content` isn't given, this provides the text shown in the + dialog. + */ + label?: string; + /** + The attributes for an input element shown next to the label. If + not given, no input element is added. + */ + input?: { + [attr: string]: string; + }; + /** + The label for the button that submits the form. Defaults to + `"OK"`. + */ + submitLabel?: string; + /** + Extra classes to add to the panel. + */ + class?: string; + /** + A query selector to find the field that should be focused when + the dialog is opened. When set to true, this picks the first + `` or `