diff --git a/assets/locale/en.json b/assets/locale/en.json index 5b6693ac9..8b76a58a9 100644 --- a/assets/locale/en.json +++ b/assets/locale/en.json @@ -5,7 +5,7 @@ "--------------------------------------------------------------------------------------------", "Do not edit this file. It is machine generated." ], - "version": "0.7.0", + "version": "0.7.1", "contents": { "editor/common/markdown": { "code": "Code", @@ -24,7 +24,7 @@ "rendering": "Rendering...", "error": "Error Equations" }, - "editor/contrib/blockPlaceHolderExtension/blockPlaceHolderExtension": { + "editor/contrib/blockPlaceHolder/blockPlaceHolder": { "emptyBlockPlaceHolder": "Start typing, or press '@' for AI, '/' for commands..." }, "editor/model/documentNode/node/html/html": { diff --git a/assets/locale/zh-cn.json b/assets/locale/zh-cn.json index 0ddf6f066..7c651c703 100644 --- a/assets/locale/zh-cn.json +++ b/assets/locale/zh-cn.json @@ -5,7 +5,7 @@ "--------------------------------------------------------------------------------------------", "Do not edit this file. It is machine generated." ], - "version": "0.7.0", + "version": "0.7.1", "contents": { "platform/i18n/browser/i18nService": { "relaunchDisplayLanguageMessage": "重新启动 {name} 以切换到语言:{languageName}?" @@ -19,9 +19,6 @@ "rendering": "渲染中...", "error": "错误公式" }, - "editor/contrib/blockPlaceHolderExtension/blockPlaceHolderExtension": { - "emptyBlockPlaceHolder": "开始输入,或者试试 '@' 触发 AI,'/' 呼出命令..." - }, "editor/common/markdown": { "code": "代码", "heading": "标题", @@ -36,6 +33,9 @@ }, "editor/model/documentNode/node/html/html": { "empty": "空HTML" + }, + "editor/contrib/blockPlaceHolder/blockPlaceHolder": { + "emptyBlockPlaceHolder": "开始输入,或者试试 '@' 触发 AI,'/' 呼出命令..." } } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 1c142795d..4c206d021 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nota", - "version": "0.7.0", + "version": "0.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nota", - "version": "0.7.0", + "version": "0.7.1", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -42,7 +42,7 @@ "jsdom": "^21.1.0", "link-module-alias": "^1.2.0", "mini-css-extract-plugin": "^2.7.2", - "mocha": "^9.1.3", + "mocha": "^11.1.0", "mochapack": "^2.1.4", "node-loader": "^2.0.0", "ora": "^8.1.1", @@ -339,6 +339,109 @@ "deprecated": "Use @eslint/object-schema instead", "dev": true }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", @@ -801,6 +904,17 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -1204,12 +1318,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@ungap/promise-all-settled": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz", - "integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==", - "dev": true - }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -1588,10 +1696,11 @@ } }, "node_modules/ansi-colors": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", - "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -2136,27 +2245,33 @@ } }, "node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dev": true, + "license": "ISC", "dependencies": { "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", + "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" } }, "node_modules/cliui/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/cliui/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, + "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -2579,10 +2694,11 @@ "optional": true }, "node_modules/diff": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", - "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", + "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" } @@ -2634,6 +2750,13 @@ "tslib": "^2.0.3" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/electron": { "version": "30.5.1", "resolved": "https://registry.npmjs.org/electron/-/electron-30.5.1.tgz", @@ -3340,6 +3463,23 @@ "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", "dev": true }, + "node_modules/foreground-child": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", + "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", @@ -3692,15 +3832,6 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, - "node_modules/growl": { - "version": "1.10.5", - "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", - "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", - "dev": true, - "engines": { - "node": ">=4.x" - } - }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -4145,6 +4276,22 @@ "integrity": "sha512-ex9JyqY+tCjBlxN1pXlqxEgtGGUGp1TG83ll1xpu8SfPgOhfAhEGCuepNHlB+d7Le+hLoBcfCu/G0ZQaFbi9hA==", "dev": true }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jest-worker": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", @@ -4837,151 +4984,99 @@ } }, "node_modules/mocha": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-9.2.2.tgz", - "integrity": "sha512-L6XC3EdwT6YrIk0yXpavvLkn8h+EU+Y5UcCHKECyMbdUIxyMuZj4bX4U9e1nvnvUUvQVsV2VHQr5zLdcUkhW/g==", - "dev": true, - "dependencies": { - "@ungap/promise-all-settled": "1.1.2", - "ansi-colors": "4.1.1", - "browser-stdout": "1.3.1", - "chokidar": "3.5.3", - "debug": "4.3.3", - "diff": "5.0.0", - "escape-string-regexp": "4.0.0", - "find-up": "5.0.0", - "glob": "7.2.0", - "growl": "1.10.5", - "he": "1.2.0", - "js-yaml": "4.1.0", - "log-symbols": "4.1.0", - "minimatch": "4.2.1", - "ms": "2.1.3", - "nanoid": "3.3.1", - "serialize-javascript": "6.0.0", - "strip-json-comments": "3.1.1", - "supports-color": "8.1.1", - "which": "2.0.2", - "workerpool": "6.2.0", - "yargs": "16.2.0", - "yargs-parser": "20.2.4", - "yargs-unparser": "2.0.0" + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.1.0.tgz", + "integrity": "sha512-8uJR5RTC2NgpY3GrYcgpZrsEd9zKbPDpob1RezyR2upGHRQtHWofmzTMzTMSV6dru3tj5Ukt0+Vnq1qhFEEwAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.3", + "browser-stdout": "^1.3.1", + "chokidar": "^3.5.3", + "debug": "^4.3.5", + "diff": "^5.2.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^10.4.5", + "he": "^1.2.0", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^5.1.6", + "ms": "^2.1.3", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^6.5.1", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1", + "yargs-unparser": "^2.0.0" }, "bin": { "_mocha": "bin/_mocha", - "mocha": "bin/mocha" + "mocha": "bin/mocha.js" }, "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mochajs" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/mocha/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/mocha/node_modules/chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/mocha/node_modules/debug": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz", - "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==", + "node_modules/mocha/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", "dev": true, + "license": "ISC", "dependencies": { - "ms": "2.1.2" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, - "engines": { - "node": ">=6.0" + "bin": { + "glob": "dist/esm/bin.mjs" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/mocha/node_modules/debug/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "node_modules/mocha/node_modules/glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "node_modules/mocha/node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, + "license": "ISC", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "brace-expansion": "^2.0.1" }, "engines": { - "node": "*" + "node": ">=16 || 14 >=14.17" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/mocha/node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "node_modules/mocha/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "dev": true, + "license": "ISC", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^2.0.1" }, "engines": { - "node": "*" + "node": ">=10" } }, - "node_modules/mocha/node_modules/minimatch": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-4.2.1.tgz", - "integrity": "sha512-9Uq1ChtSZO+Mxa/CL1eGizn2vRn3MlLgzhT0Iz8zaY8NdvxvB0d5QdPFmCKf7JKA9Lerx5vRrnwO03jsSfGG9g==", + "node_modules/mocha/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, + "license": "ISC", "engines": { - "node": ">=10" + "node": ">=16 || 14 >=14.17" } }, "node_modules/mocha/node_modules/supports-color": { @@ -5343,18 +5438,6 @@ "integrity": "sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==", "dev": true }, - "node_modules/nanoid": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.1.tgz", - "integrity": "sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw==", - "dev": true, - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -5867,6 +5950,13 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -5960,6 +6050,40 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -6864,10 +6988,11 @@ } }, "node_modules/serialize-javascript": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", - "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "randombytes": "^2.1.0" } @@ -7073,6 +7198,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width/node_modules/ansi-regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", @@ -7112,6 +7260,20 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -7347,15 +7509,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/terser-webpack-plugin/node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "dependencies": { - "randombytes": "^2.1.0" - } - }, "node_modules/terser/node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -8145,16 +8298,18 @@ "dev": true }, "node_modules/workerpool": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.0.tgz", - "integrity": "sha512-Rsk5qQHJ9eowMH28Jwhe8HEbmdYDX4lwoMWshiCXugjtHqMD9ZbiqSDLxcsfdqsETPzVUtX5s1Z5kStiIM6l4A==", - "dev": true + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", + "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==", + "dev": true, + "license": "Apache-2.0" }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -8167,17 +8322,60 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/wrap-ansi/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, + "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -8234,6 +8432,7 @@ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true, + "license": "ISC", "engines": { "node": ">=10" } @@ -8245,30 +8444,32 @@ "dev": true }, "node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "dev": true, + "license": "MIT", "dependencies": { - "cliui": "^7.0.2", + "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", - "string-width": "^4.2.0", + "string-width": "^4.2.3", "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" + "yargs-parser": "^21.1.1" }, "engines": { - "node": ">=10" + "node": ">=12" } }, "node_modules/yargs-parser": { - "version": "20.2.4", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", - "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==", + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true, + "license": "ISC", "engines": { - "node": ">=10" + "node": ">=12" } }, "node_modules/yargs-unparser": { @@ -8290,13 +8491,15 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/yargs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, + "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", diff --git a/package.json b/package.json index 5045a536d..1ca8b0434 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nota", - "version": "0.7.0", + "version": "0.7.1", "description": "A cross-platform markdown note-taking app.", "main": "./dist/main-bundle.js", "type": "commonjs", @@ -44,7 +44,7 @@ "jsdom": "^21.1.0", "link-module-alias": "^1.2.0", "mini-css-extract-plugin": "^2.7.2", - "mocha": "^9.1.3", + "mocha": "^11.1.0", "mochapack": "^2.1.4", "node-loader": "^2.0.0", "ora": "^8.1.1", diff --git a/product.json b/product.json index fa318055d..083606386 100644 --- a/product.json +++ b/product.json @@ -8,6 +8,6 @@ "projectName": "nota", "applicationName": "nota", "description": "A cross-platform markdown note-taking app.", - "version": "0.7.0", + "version": "0.7.1", "license": "MIT" } \ No newline at end of file diff --git a/scripts/i18n/i18n.js b/scripts/i18n/i18n.js index 8a1e1be2a..5d4ff201e 100644 --- a/scripts/i18n/i18n.js +++ b/scripts/i18n/i18n.js @@ -1,6 +1,7 @@ const fs = require('fs'); const path = require('path'); -const { log, SmartRegExp } = require('../utility'); +const { log } = require('../utility'); +const { LOCALIZE_REGEX } = require('./localizeRegExp'); /** * {@link localizationGenerator} @@ -350,11 +351,6 @@ class localizationGenerator { } #parseFile(filePath) { - const LOCALIZE_REGEX = - new SmartRegExp(/localize\(quote(str)quote,\s*quote(str)quote[\),]/g) - .replace('str', /.*?/) - .replace('quote', /["'`]/) - .get(); let fileContent = fs.readFileSync(filePath, 'utf-8'); fileContent = removeComments(fileContent); diff --git a/scripts/i18n/localizeRegExp.js b/scripts/i18n/localizeRegExp.js new file mode 100644 index 000000000..f2e360ee4 --- /dev/null +++ b/scripts/i18n/localizeRegExp.js @@ -0,0 +1,10 @@ +const { SmartRegExp } = require('../utility'); + + +const LOCALIZE_REGEX = + new SmartRegExp(/localize\(quote(str)quote,\s*quote(str)quote[\),]/g) + .replace('str', /.*?/) + .replace('quote', /["'`]/) + .get(); + +module.exports = { LOCALIZE_REGEX }; \ No newline at end of file diff --git a/src/base/browser/secondary/widgetBar/widgetBar.ts b/src/base/browser/secondary/widgetBar/widgetBar.ts index 9f3654864..10f2cc73a 100644 --- a/src/base/browser/secondary/widgetBar/widgetBar.ts +++ b/src/base/browser/secondary/widgetBar/widgetBar.ts @@ -42,6 +42,7 @@ export interface IWidgetBar extends IDisposable { * as default. * * @note Method will invoke `item.item.render()` automatically. + * @note The lifecycle of the provided item will be bond with the widget bar. */ addItem(item: IWidgetBarItem, index?: number): void; diff --git a/src/base/common/event.ts b/src/base/common/event.ts index 7ae984280..b552625f4 100644 --- a/src/base/common/event.ts +++ b/src/base/common/event.ts @@ -1,6 +1,6 @@ import type { IO } from "src/base/common/utilities/functional"; import { LinkedList } from "src/base/common/structures/linkedList"; -import { Disposable, DisposableBucket, IDisposable, isDisposable, LooseDisposableBucket, toDisposable, untrackDisposable } from "src/base/common/dispose"; +import { Disposable, DisposableBucket, IDisposable, isDisposable, LooseDisposableBucket, safeDisposable, toDisposable, untrackDisposable } from "src/base/common/dispose"; import { ErrorHandler } from "src/base/common/error"; import { panic } from "src/base/common/utilities/panic"; import { PriorityQueue } from "src/base/common/structures/priorityQueue"; @@ -184,7 +184,7 @@ export interface IEmitterOptions { // region - AbstractEmitter -interface IListenerContainer extends Iterable { +interface IListenerContainer extends Iterable, IDisposable { size(): number; add(listener: TListener): any; remove(token: any): void; @@ -355,6 +355,7 @@ export class Emitter extends AbstractEmitter, __Listener> i empty: () => list.empty(), size: () => list.size(), [Symbol.iterator]: list[Symbol.iterator].bind(list), + dispose: () => list.clear(), }; } } @@ -598,6 +599,13 @@ export class RelayEmitter exten this._inputUnregister.register(newInputRegister(e => this._relay.fire(e))); } } + + /** + * @description Gives client a chance to emulate event. + */ + public fire(event: T): void { + this._relay.fire(event); + } } // region - NodeEventEmitter @@ -763,9 +771,9 @@ export class PriorityEmitter extends AbstractEmitter, __Priori } protected override __constructContainer(): IListenerContainer<__PriorityListener> { - const pq = new PriorityQueue<__PriorityListener>( + const pq = safeDisposable(new PriorityQueue<__PriorityListener>( (a, b) => b.priority - a.priority // higher number, higher priority - ); + )); return { add: (listener) => { pq.enqueue(listener); return listener; }, remove: (token) => pq.remove(token), @@ -773,6 +781,7 @@ export class PriorityEmitter extends AbstractEmitter, __Priori empty: () => pq.empty(), size: () => pq.size(), [Symbol.iterator]: pq[Symbol.iterator].bind(pq), + dispose: () => pq.dispose(), }; } } diff --git a/src/base/common/keyboard.ts b/src/base/common/keyboard.ts index 05d70d37e..af616c712 100644 --- a/src/base/common/keyboard.ts +++ b/src/base/common/keyboard.ts @@ -71,6 +71,20 @@ export namespace Keyboard { } } + /** + * @description Returns the {@link KeyCode} corresponding browser key code. + * @param strKeyOrKeyCode The string form of the keycode or {@link KeyCode}. + */ + export function toKeyCodeBrowser(strKeyOrKeyCode: string | number): number { + + const keyCode = typeof strKeyOrKeyCode === 'string' + ? keyCodeStringMap.getKeyCode(strKeyOrKeyCode) + : strKeyOrKeyCode; + + const keyCodeBrowser = keyNumberMap.map[keyCode]; + return keyCodeBrowser ?? -1; + } + /** * @description Determines if two {@link IStandardKeyboardEvent} are the same. */ @@ -158,6 +172,9 @@ export function createStandardKeyboardEvent(event: KeyboardEvent): IStandardKeyb /** * The standard key code used to represent the keyboard pressing which may from * different operating systems. + * + * @note This is NOT the same keycode used in browser event. For example, + * `KeyCode.Enter` is `60`, but browser uses `13` to represent `Enter`. */ export const enum KeyCode { @@ -271,12 +288,19 @@ export const enum KeyCode { } /** - * @internal A mapping from the numerical value to {@link KeyCode}. + * @internal A mapping from the browser keycode to {@link KeyCode}. */ class KeyCodeMap { public map: { [keyCode: number]: KeyCode } = new Array(250); } +/** + * @internal A mapping from our {@link KeyCode} to browser keycode. + */ +class KeyNumberMap { + public map: { [keyCode: number]: number } = new Array(250); +} + /** * @internal A mapping either from {@link KeyCode} to the string form of keycode * OR string to {@link KeyCode}. @@ -302,8 +326,8 @@ class KeyCodeStringMap { } } -/** @internal */ const keyCodeMap = new KeyCodeMap(); +const keyNumberMap = new KeyNumberMap(); const keyCodeStringMap = new KeyCodeStringMap(); /** @internal */ @@ -406,6 +430,7 @@ for (const [keycode, keycodeNum, keycodeStr] of <[number, number, string][]> [KeyCode.ContextMenu, 93, 'ContextMenu'], ]) { keyCodeMap.map[keycodeNum] = keycode; + keyNumberMap.map[keycode] = keycodeNum; keyCodeStringMap.set(keycode, keycodeStr); } diff --git a/src/editor/common/editorExtension.ts b/src/editor/common/editorExtension.ts index 0a431516e..0c778dd5e 100644 --- a/src/editor/common/editorExtension.ts +++ b/src/editor/common/editorExtension.ts @@ -9,7 +9,7 @@ import { nullable } from "src/base/common/utilities/type"; /** * An interface only for {@link EditorExtension}. */ -export interface IEditorExtension extends Omit { +export interface IEditorExtension extends IProseEventBroadcaster { // [fields] diff --git a/src/editor/common/model.ts b/src/editor/common/model.ts index 04e33763d..0b16a0769 100644 --- a/src/editor/common/model.ts +++ b/src/editor/common/model.ts @@ -2,12 +2,8 @@ import * as marked from "marked"; import { IDisposable } from "src/base/common/dispose"; import { Register } from "src/base/common/event"; import { URI } from "src/base/common/files/uri"; -import { ProseEditorState, ProseTransaction } from "src/editor/common/proseMirror"; import { AsyncResult } from "src/base/common/result"; -import { IEditorExtension } from "src/editor/common/editorExtension"; -import { EditorSchema } from "src/editor/model/schema"; import { IEditorPosition } from "src/editor/common/position"; -import { IOnDidContentChangeEvent } from "src/editor/view/proseEventBroadcaster"; export type EditorToken = marked.Token; export type EditorTokenGeneric = marked.Tokens.Generic; @@ -49,6 +45,12 @@ export namespace EditorTokens { }; } +// region - IEditorModel + +export interface IModelBuildData { + readonly tokens: EditorToken[]; +} + /** * An interface only for {@link EditorModel}. */ @@ -64,23 +66,8 @@ export interface IEditorModel extends IDisposable { */ readonly dirty: boolean; - /** - * The schema of the editor. - */ - readonly schema: EditorSchema; - - /** - * The state of the model. Returns undefined if the model is not ready yet. - */ - readonly state?: ProseEditorState; - // region - events - /** - * Fires when the model is built for the first time. - */ - readonly onDidBuild: Register; - /** * Fires whenever the file is saved back to the disk successfully. */ @@ -98,24 +85,12 @@ export interface IEditorModel extends IDisposable { */ readonly onDidDirtyChange: Register; - /** - * Fires whenever a transaction to the {@link ProseEditorState} is made - * programmatically. - */ - readonly onTransaction: Register; - - /** - * Fires whenever the state of the view is updated. - */ - readonly onDidStateChange: Register; - // region - general /** * @description Start building the model. - * @note This will trigger `onDidBuild` event. */ - build(extensions: IEditorExtension[]): AsyncResult; + build(): AsyncResult; /** * @description Mark if the model has any unsaved changes. @@ -129,20 +104,6 @@ export interface IEditorModel extends IDisposable { // region - text-related APIs - /** - * @description Inserts the given text at the given offset. - * @param textOffset The character offset relatives to the whole text model. - * @param text The text to be inserted. - */ - insertAt(textOffset: number, text: string): void; - - /** - * @description Deletes the text with given length at the given offset. - * @param textOffset The character offset relatives to the whole text model. - * @param length The length of text to be deleted. - */ - deleteAt(textOffset: number, length: number): void; - /** * @description Returns all the line contents (without line breaking). * @returns An array of string, each string represents a line content. @@ -215,16 +176,6 @@ export interface IEditorModel extends IDisposable { * @param lineOffset The offset relative to the line. */ getCharCodeByLine(lineNumber: number, lineOffset: number): number; - - // region - others - - getRegisteredDocumentNodes(): string[]; - getRegisteredDocumentNodesBlock(): string[]; - getRegisteredDocumentNodesInline(): string[]; - - // region - internal - - __onDidStateChange(event: IOnDidContentChangeEvent): void; } export const enum EndOfLineType { diff --git a/src/editor/common/proseMirror.ts b/src/editor/common/proseMirror.ts index 9f50bea7d..3085758c3 100644 --- a/src/editor/common/proseMirror.ts +++ b/src/editor/common/proseMirror.ts @@ -145,7 +145,7 @@ declare module 'prosemirror-model' { * console.log(pos.before(1)); // Returns 8 (before the second paragraph starts) * ``` */ - after(depth?: number | null): number; + before(depth?: number | null): number; /** * @description Returns the absolute position immediately **after** the @@ -173,7 +173,7 @@ declare module 'prosemirror-model' { * console.log(pos.after(1)); // Returns 16 (after the paragraph has closed) * ``` */ - before(depth?: number | null): number; + after(depth?: number | null): number; /** * @description The exact same API as {@link getParentNodeAt}. Except diff --git a/src/editor/common/proseUtility.ts b/src/editor/common/proseUtility.ts index 6aaa2d3c9..92b4ad52f 100644 --- a/src/editor/common/proseUtility.ts +++ b/src/editor/common/proseUtility.ts @@ -57,6 +57,7 @@ export namespace ProseTools { export const isEmptyTextBlock = __isEmptyTextBlock; export const isInline = __isInline; export const isLeaf = __isLeaf; + export const hasChild = __hasChild; export const iterateChild = __iterateChild; export const getNextValidDefaultNodeTypeAt = __getNextValidDefaultNodeTypeAt; @@ -187,6 +188,10 @@ function __isLeaf(node: ProseNode): boolean { return node.isLeaf; } +function __hasChild(node: ProseNode): boolean { + return !!node.childCount; +} + function *__iterateChild(node: ProseNode): IterableIterator<{ node: ProseNode, offset: number, index: number }> { const fragment = node.content; let offset = 0; diff --git a/src/editor/common/range.ts b/src/editor/common/range.ts deleted file mode 100644 index 47b7e4aa9..000000000 --- a/src/editor/common/range.ts +++ /dev/null @@ -1,60 +0,0 @@ - -/** - * A range representation in the editor. - */ -export interface IEditorRange { - /** - * Line number on which the range starts (zero-based). - */ - readonly startLineNumber: number; - /** - * Column on which the range starts in line `startLineNumber` (zero-based). - */ - readonly startColumn: number; - /** - * Line number on which the range ends. - */ - readonly endLineNumber: number; - /** - * Column on which the range ends in line `endLineNumber`. - */ - readonly endColumn: number; -} - -export class EditorRange implements IEditorRange { - - // [field] - - public readonly startLineNumber: number; - public readonly startColumn: number; - public readonly endLineNumber: number; - public readonly endColumn: number; - - // [constructor] - - constructor( - startLineNumber: number, startColumn: number, - endLineNumber: number, endColumn: number - ) { - if ((startLineNumber > endLineNumber) || - (startLineNumber === endLineNumber && startColumn > endColumn) - ) { - this.startLineNumber = endLineNumber; - this.startColumn = endColumn; - this.endLineNumber = startLineNumber; - this.endColumn = startColumn; - } else { - this.startLineNumber = startLineNumber; - this.startColumn = startColumn; - this.endLineNumber = endLineNumber; - this.endColumn = endColumn; - } - } - - // [static helper methods] - - public static isEmpty(range: IEditorRange): boolean { - return (range.startLineNumber === range.endLineNumber && range.startColumn === range.endColumn); - } - -} \ No newline at end of file diff --git a/src/editor/common/view.ts b/src/editor/common/view.ts index cddc30c64..ac4281650 100644 --- a/src/editor/common/view.ts +++ b/src/editor/common/view.ts @@ -1,3 +1,4 @@ +import { KeyCode } from "src/base/common/keyboard"; import { IProseEventBroadcaster } from "src/editor/view/proseEventBroadcaster"; import { RichTextView } from "src/editor/view/richTextView"; @@ -21,4 +22,23 @@ export interface IEditorView extends IProseEventBroadcaster { * The actual editor instance. */ readonly editor: RichTextView; + + /** + * @description Programmatically emulate user typing action. + * - If `from` or `to` not provided, it will be treated as simple insertion + * at the cursor. + * @param text The text for replacement, insertion. Multi-character supported. + * Each character will be typed in sequence. + * @param from The start absolute position points to ProseMirror document. + * @param to The end absolute position points to ProseMirror document. + */ + type(text: string, from?: number, to?: number): void; + + /** + * @description Programmatically trigger a keydown event. This emulates + * low-level keyboard interactions like navigation, shortcuts, or special + * key behaviors in the editor. + * @param code The physical key code to emulate. + */ + keydown(code: KeyCode, alt?: boolean, shift?: boolean, ctrl?: boolean, meta?: boolean): void; } diff --git a/src/editor/common/viewModel.ts b/src/editor/common/viewModel.ts index b9842e7e0..16a45bca1 100644 --- a/src/editor/common/viewModel.ts +++ b/src/editor/common/viewModel.ts @@ -1,15 +1,46 @@ import { Disposable } from "src/base/common/dispose"; import { Register } from "src/base/common/event"; import { ProseEditorState, ProseTransaction } from "src/editor/common/proseMirror"; +import { EditorSchema } from "src/editor/model/schema"; import { IOnDidContentChangeEvent } from "src/editor/view/proseEventBroadcaster"; +// region - IEditorViewModel + +export interface IViewModelBuildData { + readonly state: ProseEditorState; +} + +export interface IViewModelChangeEvent { + readonly tr: ProseTransaction; +} + export interface IEditorViewModel extends Disposable { - readonly onDidChangeModelState: Register; - readonly onDidBuild: Register; + // region - field & event /** - * @description This will be invoked whenever the view content changes. + * The schema of the editor. */ - onDidViewContentChange(e: IOnDidContentChangeEvent): void; + readonly schema: EditorSchema; + + /** + * The state of the model. Returns undefined if the data is not ready yet. + */ + readonly state?: ProseEditorState; + + readonly onDidContentChange: Register; + + // region - methods + + /** + * @description Takes the changes created from the editor view, adapt it to + * model. + */ + updateViewChange(e: IOnDidContentChangeEvent): void; + + // region - others + + getRegisteredDocumentNodes(): string[]; + getRegisteredDocumentNodesBlock(): string[]; + getRegisteredDocumentNodesInline(): string[]; } \ No newline at end of file diff --git a/src/editor/contrib/askAI/askAI.ts b/src/editor/contrib/askAI/askAI.ts new file mode 100644 index 000000000..114ed092f --- /dev/null +++ b/src/editor/contrib/askAI/askAI.ts @@ -0,0 +1,75 @@ +import { EditorExtension, IEditorExtension } from "src/editor/common/editorExtension"; +import { ProseTools } from "src/editor/common/proseUtility"; +import { EditorExtensionIDs } from "src/editor/contrib/builtInExtensionList"; +import { IEditorWidget } from "src/editor/editorWidget"; +import { IOnTextInputEvent } from "src/editor/view/proseEventBroadcaster"; +import { AskAIProvider } from "src/editor/view/widget/palette/askAIProvider"; +import { EditorPalette } from "src/editor/view/widget/palette/palette"; +import { IInstantiationService } from "src/platform/instantiation/common/instantiation"; + +interface IEditorAskAIExtension extends IEditorExtension { + + readonly id: EditorExtensionIDs.AskAI; +} + +// region - EditorAskAIExtension + +export class EditorAskAIExtension extends EditorExtension implements IEditorAskAIExtension { + + // [field] + + public override readonly id = EditorExtensionIDs.AskAI; + private readonly _palette: EditorPalette; + + // [constructor] + + constructor( + editorWidget: IEditorWidget, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(editorWidget); + const provider = instantiationService.createInstance(AskAIProvider, editorWidget); + this._palette = this.__register(instantiationService.createInstance( + EditorPalette, + editorWidget, + { + contentProvider: () => provider.getContent(), + } + )); + + // show event + this.__register(this.onTextInput(e => { + const handled = this.tryShowAskAI(e); + if (handled) { + e.preventDefault(); + return true; + } + })); + } + + // [public methods] + + public tryShowAskAI(e: IOnTextInputEvent): boolean { + const { text, view } = e; + const { selection } = view.state; + + const isCursor = ProseTools.Cursor.isCursor(selection); + if (!isCursor) { + return false; + } + + const isEmptyBlock = ProseTools.Cursor.isOnEmpty(selection); + const isSlash = text === '@'; + if (!isEmptyBlock || !isSlash) { + return false; + } + + // show ask-AI palette + const position = view.coordsAtPos(selection.$from.pos); + this._palette.render(position); + + // re-focus back to editor, not the slash command. + view.focus(); + return true; + } +} \ No newline at end of file diff --git a/src/editor/contrib/autoSaveExtension.ts b/src/editor/contrib/autoSave.ts similarity index 97% rename from src/editor/contrib/autoSaveExtension.ts rename to src/editor/contrib/autoSave.ts index 2d00d5138..2763f2c22 100644 --- a/src/editor/contrib/autoSaveExtension.ts +++ b/src/editor/contrib/autoSave.ts @@ -95,8 +95,8 @@ export class EditorAutoSaveExtension extends EditorExtension implements IEditorA } private __registerEditorStateListener(): void { - this.__register(this._editorWidget.onDidStateChange(() => { - if (this._autoSave) { + this.__register(this._editorWidget.onDidDirtyChange((isDirty) => { + if (isDirty && this._autoSave) { this._scheduler.schedule(undefined, this._autoSaveDelay); } })); diff --git a/src/editor/contrib/blockHandleExtension/blockHandleExtension.scss b/src/editor/contrib/blockHandle/blockHandle.scss similarity index 100% rename from src/editor/contrib/blockHandleExtension/blockHandleExtension.scss rename to src/editor/contrib/blockHandle/blockHandle.scss diff --git a/src/editor/contrib/blockHandle/blockHandle.ts b/src/editor/contrib/blockHandle/blockHandle.ts new file mode 100644 index 000000000..ab74291f5 --- /dev/null +++ b/src/editor/contrib/blockHandle/blockHandle.ts @@ -0,0 +1,352 @@ +import "src/editor/contrib/blockHandle/blockHandle.scss"; +import { Icons } from "src/base/browser/icon/icons"; +import { EditorExtension, IEditorExtension } from "src/editor/common/editorExtension"; +import { EditorExtensionIDs } from "src/editor/contrib/builtInExtensionList"; +import { IEditorWidget } from "src/editor/editorWidget"; +import { IEditorMouseEvent } from "src/editor/view/proseEventBroadcaster"; +import { IWidgetBar, WidgetBar } from "src/base/browser/secondary/widgetBar/widgetBar"; +import { addDisposableListener, EventType, Orientation } from "src/base/browser/basic/dom"; +import { RequestAnimateController, requestAtNextAnimationFrame } from "src/base/browser/basic/animation"; +import { Event } from "src/base/common/event"; +import { ProseEditorView, ProseTextSelection, ProseTransaction } from "src/editor/common/proseMirror"; +import { EditorDragState, getDropExactPosition } from "src/editor/common/cursorDrop"; +import { Button, IButtonOptions } from "src/base/browser/basic/button/button"; +import { Markdown, TokenEnum } from "src/editor/common/markdown"; +import { assert } from "src/base/common/utilities/panic"; +import { Disposable } from "src/base/common/dispose"; +import { EditorPalette } from "src/editor/view/widget/palette/palette"; +import { IInstantiationService } from "src/platform/instantiation/common/instantiation"; +import { IPosition } from "src/base/common/utilities/size"; +import { ProseTools } from "src/editor/common/proseUtility"; +import { BlockInsertProvider } from "src/editor/view/widget/palette/blockInsertProvider"; + +// region - EditorBlockHandleExtension + +/** + * An interface only for {@link EditorBlockHandleExtension}. + */ +export interface IEditorBlockHandleExtension extends IEditorExtension { + + readonly id: EditorExtensionIDs.BlockHandle; +} + +export class EditorBlockHandleExtension extends EditorExtension implements IEditorBlockHandleExtension { + + // [field] + + public readonly id = EditorExtensionIDs.BlockHandle; + + /** + * When dragging, this indicates the current exact dropping position in + * ProseMirror document. + */ + public get currDropPosition() { return this._currDropPosition; } + private _currDropPosition?: number; + + private _widget?: IWidgetBar; + private readonly _renderController: RequestAnimateController<{ event: IEditorMouseEvent }>; + private readonly _paletteRenderer: PaletteRenderer; + + // [constructor] + + constructor( + editorWidget: IEditorWidget, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(editorWidget); + this._paletteRenderer = this.__register(new PaletteRenderer(editorWidget, instantiationService)); + + // render widget when possible + this.__register(this.onMouseMove(e => { + this._renderController.request({ event: e }); + })); + + // unrender cases + this.__register(Event.any([this.onMouseLeave, this.onDidRender, this.onDidBlur])(() => { + this.unrenderWidget(); + })); + + // RENDER LOGIC + this._renderController = this.__register(new RequestAnimateController(({ event: e }) => { + /** + * If hovering outside the editor (hovering overlay), we still can + * try to render the widget. + */ + if (!e.target) { + this.__renderWidgetWithoutTarget(e); + return; + } + + // same position, do nothing. + if (this.currDropPosition === e.target.resolvedPosition) { + return; + } + + // not the top-level node, do nothing. + const pos = e.view.state.doc.resolve(e.target.resolvedPosition); + if (pos.depth !== 0) { + return; + } + + this.unrenderWidget(); + this.renderWidget(editorWidget.view.editor.overlayContainer, e.target.resolvedPosition, e.target.nodeElement); + })); + } + + protected override onViewInit(view: ProseEditorView): void { + this._widget = this.__register(this.__initWidget(view)); + } + + protected override onViewDestroy(view: ProseEditorView): void { + this.release(this._widget); + this._widget = undefined; + this._currDropPosition = undefined; + this._renderController.cancel(); + } + + // [public methods] + + public unrenderWidget(): void { + this._widget?.unrender(); + this._currDropPosition = undefined; + } + + public renderWidget(container: HTMLElement, nodePosition: number, nodeElement: HTMLElement): void { + if (!this._widget) { + return; + } + + this._currDropPosition = nodePosition; + + // render under the editor overlay + this._widget.container.setLeft(nodeElement.offsetLeft - 55); + this._widget.container.setTop(nodeElement.offsetTop); + this._widget.render(container); + + // fade-out effect + this._widget.container.setOpacity(0); + requestAtNextAnimationFrame(() => { + this._widget?.container.setOpacity(1); + }); + } + + public renderPalette(position: IPosition): void { + this._paletteRenderer.render(position); + } + + // [private methods] + + private __renderWidgetWithoutTarget(e: IEditorMouseEvent): void { + const position = getDropExactPosition(e.view, e.event, true); + if (this.currDropPosition === position) { + return; + } + + const node = e.view.nodeDOM(position) as HTMLElement | undefined; + if (!node) { + return; + } + + this.unrenderWidget(); + this.renderWidget(this._editorWidget.view.editor.overlayContainer, position, node); + } + + private __initWidget(view: ProseEditorView): IWidgetBar { + const widget = new WidgetBar('block-handle-widget', { + orientation: Orientation.Horizontal, + }); + + [ + AddBlockButton, + DragHandleButton, + ] + .forEach(buttonCtor => { + const button = new buttonCtor(view, this._editorWidget, this); + widget.addItem({ + id: button.id, + data: button, + disposable: button, + }); + }); + + return widget; + } +} + +// region - Buttons + +class AbstractBlockHandleButton extends Button { + + constructor(opts: IButtonOptions) { + super({ + ...opts, + classes: ['block-handle-button', ...(opts.classes ?? [])], + }); + } +} + +class DragHandleButton extends AbstractBlockHandleButton { + + constructor( + private readonly view: ProseEditorView, + private readonly editorWidget: IEditorWidget, + private readonly extension: EditorBlockHandleExtension, + ) { + super({ + id: 'drag-handle', + icon: Icons.DragHandle, + classes: ['add-new-block'], + }); + } + + protected override __render(element: HTMLElement): void { + super.__render(element); + + // tell the browser the button is draggable + this.element.draggable = true; + + // on drag start + this.__register(addDisposableListener(this.element, EventType.dragstart, e => { + const currDropPosition = this.extension.currDropPosition; + if (e.dataTransfer === null || currDropPosition === undefined) { + return; + } + + this.editorWidget.updateContext('editorDragState', EditorDragState.Block); + this.element.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + + const element = this.view.nodeDOM(currDropPosition) as HTMLElement; + e.dataTransfer.setDragImage(element, 0, 0); + + const dragPosition = currDropPosition.toString(); + e.dataTransfer.setData('$nota-editor-block-handle', dragPosition); + })); + + this.__register(addDisposableListener(this.element, EventType.dragend, e => { + this.__dropEndAfterWork(); + })); + + this.__register(this.extension.onDrop(e => { + this.__dropEndAfterWork(); + })); + + this.__register(this.extension.onDropOverlay(e => { + this.__dropEndAfterWork(); + })); + } + + private __dropEndAfterWork(): void { + this.element.classList.remove('dragging'); + this.extension.unrenderWidget(); + } +} + +class AddBlockButton extends AbstractBlockHandleButton { + + constructor( + private readonly view: ProseEditorView, + private readonly editorWidget: IEditorWidget, + private readonly extension: EditorBlockHandleExtension, + ) { + super({ + id: 'add-new-block', + icon: Icons.AddNew, + classes: ['add-new-block'], + }); + } + + protected override __render(element: HTMLElement): void { + super.__render(element); + + this.__register(this.onDidClick(() => { + + // pre-condition checks + const currentDropPosition = this.extension.currDropPosition; + if (currentDropPosition === undefined) { + return; + } + + const { state } = this.view; + const $pos = state.doc.resolve(currentDropPosition); + if ($pos.depth !== 0) { + return; + } + + const currentNode = $pos.nodeAfter; + if (!currentNode) { + return; + } + + let newTr: ProseTransaction; + let insertPosition: number; + + /** + * If the current node is non-empty, insert an empty paragraph right + * below the current block. + */ + if (!currentNode.isTextblock || currentNode.textContent !== '') { + insertPosition = currentDropPosition + currentNode.nodeSize; + const paragraph = assert(Markdown.Create.empty(state, TokenEnum.Paragraph, {})); + newTr = state.tr.insert(insertPosition, paragraph); + const newPos = insertPosition + 1; + if (newPos <= newTr.doc.content.size) { + newTr = newTr.setSelection(ProseTextSelection.create(newTr.doc, newPos)); + } + } + // If the current node is empty text, we simply select it. + else { + insertPosition = currentDropPosition + 1; + newTr = state.tr.setSelection(ProseTextSelection.create(state.tr.doc, insertPosition)); + } + + // update to view + this.view.dispatch(newTr); + + // render palette + const domPosition = this.view.coordsAtPos(insertPosition); + this.extension.renderPalette(domPosition); + + // re-focus + this.view.focus(); + })); + } +} + +// region - PaletteRenderer + +class PaletteRenderer extends Disposable { + + // [field] + + private readonly _palette: EditorPalette; + private readonly _contentProvider: BlockInsertProvider; + + // [constructor] + + constructor( + private readonly editorWidget: IEditorWidget, + private readonly instantiationService: IInstantiationService, + ) { + super(); + this._contentProvider = instantiationService.createInstance(BlockInsertProvider, editorWidget); + this._palette = this.__register(this.instantiationService.createInstance( + EditorPalette, + this.editorWidget, + { + contentProvider: () => this._contentProvider.getContent(), + } + )); + } + + // [public methods] + + public render(position: IPosition): void { + this.destroy(); + this._palette.render(position); + } + + public destroy(): void { + this._palette?.destroy(); + } +} \ No newline at end of file diff --git a/src/editor/contrib/blockHandleExtension/blockHandleButton.ts b/src/editor/contrib/blockHandleExtension/blockHandleButton.ts deleted file mode 100644 index 9d38d4c22..000000000 --- a/src/editor/contrib/blockHandleExtension/blockHandleButton.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Button, IButtonOptions } from "src/base/browser/basic/button/button"; - -export class BlockHandleButton extends Button { - - constructor(opts: IButtonOptions) { - super({ - ...opts, - classes: ['block-handle-button', ...(opts.classes ?? [])], - }); - } -} \ No newline at end of file diff --git a/src/editor/contrib/blockHandleExtension/blockHandleExtension.ts b/src/editor/contrib/blockHandleExtension/blockHandleExtension.ts deleted file mode 100644 index 137990c3a..000000000 --- a/src/editor/contrib/blockHandleExtension/blockHandleExtension.ts +++ /dev/null @@ -1,209 +0,0 @@ -import "src/editor/contrib/blockHandleExtension/blockHandleExtension.scss"; -import { Icons } from "src/base/browser/icon/icons"; -import { EditorExtension, IEditorExtension } from "src/editor/common/editorExtension"; -import { EditorExtensionIDs } from "src/editor/contrib/builtInExtensionList"; -import { IEditorWidget } from "src/editor/editorWidget"; -import { IEditorMouseEvent } from "src/editor/view/proseEventBroadcaster"; -import { IWidgetBar, WidgetBar } from "src/base/browser/secondary/widgetBar/widgetBar"; -import { BlockHandleButton } from "src/editor/contrib/blockHandleExtension/blockHandleButton"; -import { addDisposableListener, EventType, Orientation } from "src/base/browser/basic/dom"; -import { RequestAnimateController, requestAtNextAnimationFrame } from "src/base/browser/basic/animation"; -import { Event } from "src/base/common/event"; -import { ProseEditorView } from "src/editor/common/proseMirror"; -import { EditorDragState, getDropExactPosition } from "src/editor/common/cursorDrop"; -import { DisposableBucket } from "src/base/common/dispose"; - -/** - * An interface only for {@link EditorBlockHandleExtension}. - */ -export interface IEditorBlockHandleExtension extends IEditorExtension { - - readonly id: EditorExtensionIDs.BlockHandle; -} - -export class EditorBlockHandleExtension extends EditorExtension implements IEditorBlockHandleExtension { - - // [field] - - public readonly id = EditorExtensionIDs.BlockHandle; - - private _currPosition?: number; - private _widget?: IWidgetBar; - private readonly _renderController: RequestAnimateController<{ event: IEditorMouseEvent }>; - - // [constructor] - - constructor(editorWidget: IEditorWidget) { - super(editorWidget); - - // render widget when possible - this.__register(this.onMouseMove(e => { - this._renderController.request({ event: e }); - })); - - // unrender cases - this.__register(Event.any([this.onMouseLeave, this.onDidRender, this.onDidBlur])(() => { - this.__unrenderWidget(); - })); - - // RENDER LOGIC - this._renderController = this.__register(new RequestAnimateController(({ event: e }) => { - /** - * If hovering outside the editor (hovering overlay), we still can - * try to render the widget. - */ - if (!e.target) { - this.__renderWidgetWithoutTarget(e); - return; - } - - // same position, do nothing. - if (this._currPosition === e.target.resolvedPosition) { - return; - } - - // not the top-level node, do nothing. - const pos = e.view.state.doc.resolve(e.target.resolvedPosition); - if (pos.depth !== 0) { - return; - } - - this.__unrenderWidget(); - this.__renderWidget(editorWidget.view.editor.overlayContainer, e.target.resolvedPosition, e.target.nodeElement); - })); - } - - protected override onViewInit(view: ProseEditorView): void { - this._widget = this.__register(this.__initWidget(view)); - } - - protected override onViewDestroy(view: ProseEditorView): void { - this.release(this._widget); - this._widget = undefined; - this._currPosition = undefined; - this._renderController.cancel(); - } - - // [private methods] - - private __renderWidgetWithoutTarget(e: IEditorMouseEvent): void { - const position = getDropExactPosition(e.view, e.event, true); - if (this._currPosition === position) { - return; - } - - const node = e.view.nodeDOM(position) as HTMLElement | undefined; - if (!node) { - return; - } - - this.__unrenderWidget(); - this.__renderWidget(this._editorWidget.view.editor.overlayContainer, position, node); - } - - private __renderWidget(container: HTMLElement, nodePosition: number, nodeElement: HTMLElement): void { - if (!this._widget) { - return; - } - - this._currPosition = nodePosition; - - // render under the editor overlay - this._widget.container.setLeft(nodeElement.offsetLeft - 55); - this._widget.container.setTop(nodeElement.offsetTop); - this._widget.render(container); - - // fade-out effect - this._widget.container.setOpacity(0); - requestAtNextAnimationFrame(() => { - this._widget?.container.setOpacity(1); - }); - } - - private __unrenderWidget(): void { - this._widget?.unrender(); - this._currPosition = undefined; - } - - private __initWidget(view: ProseEditorView): IWidgetBar { - const widget = new WidgetBar('block-handle-widget', { - orientation: Orientation.Horizontal, - }); - - const buttonsOptions = [ - { id: 'add-new-block', icon: Icons.AddNew, classes: ['add-new-block'] }, - ]; - - for (const { id, icon, classes } of buttonsOptions) { - const button = new BlockHandleButton({ id: id, icon: icon, classes: [...classes] }); - - widget.addItem({ - id: id, - data: button, - disposable: button, - }); - } - - const dragButtonLifecycle = new DisposableBucket(); - const dragButton = dragButtonLifecycle.register(new DragHandleButton()); - widget.addItem({ - id: dragButton.id, - data: dragButton, - disposable: dragButtonLifecycle, - }); - this.__initDragButton(view, dragButton, dragButtonLifecycle); - - return widget; - } - - private __initDragButton(view: ProseEditorView, button: DragHandleButton, lifecycle: DisposableBucket): void { - - // tell the browser the button is draggable - button.element.draggable = true; - - // on drag start - lifecycle.register(addDisposableListener(button.element, EventType.dragstart, e => { - if (e.dataTransfer === null || this._currPosition === undefined) { - return; - } - - this._editorWidget.updateContext('editorDragState', EditorDragState.Block); - button.element.classList.add('dragging'); - e.dataTransfer.effectAllowed = 'move'; - - const element = view.nodeDOM(this._currPosition) as HTMLElement; - e.dataTransfer.setDragImage(element, 0, 0); - - const dragPosition = this._currPosition.toString(); - e.dataTransfer.setData('$nota-editor-block-handle', dragPosition); - })); - - lifecycle.register(addDisposableListener(button.element, EventType.dragend, e => { - this.__dropEndAfterWork(button); - })); - - lifecycle.register(this.onDrop(e => { - this.__dropEndAfterWork(button); - })); - - lifecycle.register(this.onDropOverlay(e => { - this.__dropEndAfterWork(button); - })); - } - - private __dropEndAfterWork(button: DragHandleButton): void { - button.element.classList.remove('dragging'); - this.__unrenderWidget(); - } -} - -class DragHandleButton extends BlockHandleButton { - - constructor() { - super({ - id: 'drag-handle', - icon: Icons.DragHandle, - classes: ['add-new-block'], - }); - } -} \ No newline at end of file diff --git a/src/editor/contrib/blockPlaceHolderExtension/blockPlaceHolder.scss b/src/editor/contrib/blockPlaceHolder/blockPlaceHolder.scss similarity index 100% rename from src/editor/contrib/blockPlaceHolderExtension/blockPlaceHolder.scss rename to src/editor/contrib/blockPlaceHolder/blockPlaceHolder.scss diff --git a/src/editor/contrib/blockPlaceHolderExtension/blockPlaceHolderExtension.ts b/src/editor/contrib/blockPlaceHolder/blockPlaceHolder.ts similarity index 86% rename from src/editor/contrib/blockPlaceHolderExtension/blockPlaceHolderExtension.ts rename to src/editor/contrib/blockPlaceHolder/blockPlaceHolder.ts index dc6b500a1..94582318a 100644 --- a/src/editor/contrib/blockPlaceHolderExtension/blockPlaceHolderExtension.ts +++ b/src/editor/contrib/blockPlaceHolder/blockPlaceHolder.ts @@ -1,9 +1,9 @@ -import "src/editor/contrib/blockPlaceHolderExtension/blockPlaceHolder.scss"; +import "src/editor/contrib/blockPlaceHolder/blockPlaceHolder.scss"; import { EditorExtension, IEditorExtension } from "src/editor/common/editorExtension"; import { ProseTools } from "src/editor/common/proseUtility"; import { EditorExtensionIDs } from "src/editor/contrib/builtInExtensionList"; import { IEditorWidget } from "src/editor/editorWidget"; -import { ProseDecoration, ProseDecorationSet, ProseDecorationSource, ProseEditorState, ProseEditorView } from "src/editor/common/proseMirror"; +import { ProseDecoration, ProseDecorationSet, ProseDecorationSource, ProseEditorState } from "src/editor/common/proseMirror"; import { I18nService, II18nService } from "src/platform/i18n/browser/i18nService"; interface IEditorBlockPlaceHolderExtension extends IEditorExtension { @@ -44,12 +44,14 @@ export class EditorBlockPlaceHolderExtension extends EditorExtension implements return null; } - const isEmptyBlock = ProseTools.Cursor.isOnEmpty(selection); - if (!isEmptyBlock) { + const cursor = selection.$from; + + const anyChild = ProseTools.Node.hasChild(cursor.parent); + if (anyChild) { return null; } - - const blockPos = selection.$from.before(); + + const blockPos = cursor.before(); const blockNode = state.doc.nodeAt(blockPos); if (!blockNode) { return null; diff --git a/src/editor/contrib/builtInExtensionList.ts b/src/editor/contrib/builtInExtensionList.ts index 317b7b529..1d2830cd4 100644 --- a/src/editor/contrib/builtInExtensionList.ts +++ b/src/editor/contrib/builtInExtensionList.ts @@ -1,23 +1,25 @@ import { Constructor } from "src/base/common/utilities/type"; import { EditorExtension } from "src/editor/common/editorExtension"; -import { EditorCommandExtension } from "src/editor/contrib/commandExtension/commandExtension"; -import { EditorAutoSaveExtension } from "src/editor/contrib/autoSaveExtension"; -import { EditorInputRuleExtension } from "src/editor/contrib/inputRuleExtension/inputRuleExtension"; -import { EditorDragAndDropExtension } from "src/editor/contrib/dragAndDropExtension/dragAndDropExtension"; -import { EditorBlockHandleExtension } from "src/editor/contrib/blockHandleExtension/blockHandleExtension"; -import { EditorBlockPlaceHolderExtension } from "src/editor/contrib/blockPlaceHolderExtension/blockPlaceHolderExtension"; -import { EditorSlashCommandExtension } from "src/editor/contrib/slashCommandExtension/slashCommandExtension"; -// import { EditorHistoryExtension } from "src/editor/contrib/historyExtension/historyExtension"; +import { EditorCommandExtension } from "src/editor/contrib/command/command"; +import { EditorAutoSaveExtension } from "src/editor/contrib/autoSave"; +import { EditorSnippetExtension } from "src/editor/contrib/snippet/snippet"; +import { EditorDragAndDropExtension } from "src/editor/contrib/dragAndDrop/dragAndDrop"; +import { EditorBlockHandleExtension } from "src/editor/contrib/blockHandle/blockHandle"; +import { EditorBlockPlaceHolderExtension } from "src/editor/contrib/blockPlaceHolder/blockPlaceHolder"; +import { EditorSlashCommandExtension } from "src/editor/contrib/slashCommand/slashCommand"; +import { EditorAskAIExtension } from "src/editor/contrib/askAI/askAI"; +// import { EditorHistoryExtension } from "src/editor/contrib/history/history"; export const enum EditorExtensionIDs { Command = 'editor-command-extension', AutoSave = 'editor-autosave-extension', - InputRule = 'editor-inputRule-extension', + Snippet = 'editor-snippet-extension', History = 'editor-history-extension', DragAndDrop = 'editor-drag-and-drop-extension', BlockHandle = 'editor-block-handle-extension', BlockPlaceHolder = 'editor-block-place-holder-extension', SlashCommand = 'editor-slash-command-extension', + AskAI = 'editor-ask-AI', } /** @@ -25,13 +27,14 @@ export const enum EditorExtensionIDs { */ export function getBuiltInExtension(): { id: string, ctor: Constructor }[] { return [ - { id: EditorExtensionIDs.InputRule, ctor: EditorInputRuleExtension }, + { id: EditorExtensionIDs.Snippet, ctor: EditorSnippetExtension }, { id: EditorExtensionIDs.AutoSave, ctor: EditorAutoSaveExtension }, { id: EditorExtensionIDs.Command, ctor: EditorCommandExtension }, { id: EditorExtensionIDs.DragAndDrop, ctor: EditorDragAndDropExtension }, { id: EditorExtensionIDs.BlockHandle, ctor: EditorBlockHandleExtension }, { id: EditorExtensionIDs.BlockPlaceHolder, ctor: EditorBlockPlaceHolderExtension }, { id: EditorExtensionIDs.SlashCommand, ctor: EditorSlashCommandExtension }, + { id: EditorExtensionIDs.AskAI, ctor: EditorAskAIExtension }, // { id: EditorExtensionIDs.History, ctor: EditorHistoryExtension }, // TODO: unfinished (shit mountain) ]; } \ No newline at end of file diff --git a/src/editor/contrib/commandExtension/commandExtension.ts b/src/editor/contrib/command/command.ts similarity index 99% rename from src/editor/contrib/commandExtension/commandExtension.ts rename to src/editor/contrib/command/command.ts index 764bbfc06..00cfe49db 100644 --- a/src/editor/contrib/commandExtension/commandExtension.ts +++ b/src/editor/contrib/command/command.ts @@ -6,7 +6,7 @@ import { ILogService } from "src/base/common/logger"; import { EditorExtensionIDs } from "src/editor/contrib/builtInExtensionList"; import { EditorExtension, IEditorExtension } from "src/editor/common/editorExtension"; import { IEditorWidget } from "src/editor/editorWidget"; -import { registerBasicEditorCommands } from "src/editor/contrib/commandExtension/editorCommands"; +import { registerBasicEditorCommands } from "src/editor/contrib/command/editorCommands"; import { Command } from "src/platform/command/common/command"; import { ICommandService } from "src/platform/command/common/commandService"; import { RegistrantType } from "src/platform/registrant/common/registrant"; diff --git a/src/editor/contrib/commandExtension/editorCommands.ts b/src/editor/contrib/command/editorCommands.ts similarity index 99% rename from src/editor/contrib/commandExtension/editorCommands.ts rename to src/editor/contrib/command/editorCommands.ts index ed9e63143..e55a82843 100644 --- a/src/editor/contrib/commandExtension/editorCommands.ts +++ b/src/editor/contrib/command/editorCommands.ts @@ -1,4 +1,4 @@ -import type { IEditorCommandExtension } from "src/editor/contrib/commandExtension/commandExtension"; +import type { IEditorCommandExtension } from "src/editor/contrib/command/command"; import type { IEditorWidget } from "src/editor/editorWidget"; import { ReplaceAroundStep, canJoin, canSplit, liftTarget, replaceStep } from "prosemirror-transform"; import { ILogService } from "src/base/common/logger"; @@ -7,7 +7,6 @@ import { ProseEditorState, ProseTransaction, ProseAllSelection, ProseTextSelecti import { ProseTools } from "src/editor/common/proseUtility"; import { EditorSchema } from "src/editor/model/schema"; import { Command, ICommandSchema, buildChainCommand } from "src/platform/command/common/command"; -import { ICommandService } from "src/platform/command/common/commandService"; import { CreateContextKeyExpr } from "src/platform/context/common/contextKeyExpr"; import { IServiceProvider } from "src/platform/instantiation/common/instantiation"; import { EditorContextKeys } from "src/editor/common/editorContextKeys"; diff --git a/src/editor/contrib/dragAndDropExtension/dragAndDropExtension.scss b/src/editor/contrib/dragAndDrop/dragAndDrop.scss similarity index 100% rename from src/editor/contrib/dragAndDropExtension/dragAndDropExtension.scss rename to src/editor/contrib/dragAndDrop/dragAndDrop.scss diff --git a/src/editor/contrib/dragAndDropExtension/dragAndDropExtension.ts b/src/editor/contrib/dragAndDrop/dragAndDrop.ts similarity index 97% rename from src/editor/contrib/dragAndDropExtension/dragAndDropExtension.ts rename to src/editor/contrib/dragAndDrop/dragAndDrop.ts index c25ffd51b..e3a99ba97 100644 --- a/src/editor/contrib/dragAndDropExtension/dragAndDropExtension.ts +++ b/src/editor/contrib/dragAndDrop/dragAndDrop.ts @@ -1,4 +1,4 @@ -import "src/editor/contrib/dragAndDropExtension/dragAndDropExtension.scss"; +import "src/editor/contrib/dragAndDrop/dragAndDrop.scss"; import { EditorExtension, IEditorExtension } from "src/editor/common/editorExtension"; import { ProseDecorationSource, ProseEditorState, ProseEditorView } from "src/editor/common/proseMirror"; import { EditorExtensionIDs } from "src/editor/contrib/builtInExtensionList"; @@ -6,11 +6,11 @@ import { IEditorWidget } from "src/editor/editorWidget"; import { EditorDragState, getDropExactPosition } from "src/editor/common/cursorDrop"; import { IContextService } from "src/platform/context/common/contextService"; import { EditorContextKeys } from "src/editor/common/editorContextKeys"; -import { DropCursorRenderer } from "src/editor/contrib/dragAndDropExtension/dropCursorRenderer"; +import { DropCursorRenderer } from "src/editor/contrib/dragAndDrop/dropCursorRenderer"; import { IEditorDragEvent } from "src/editor/view/proseEventBroadcaster"; import { Numbers } from "src/base/common/utilities/number"; -import { DropBlinkRenderer } from "src/editor/contrib/dragAndDropExtension/dropBlinkRenderer"; -import { ScrollOnEdgeController } from "src/editor/contrib/dragAndDropExtension/scrollOnEdgeController"; +import { DropBlinkRenderer } from "src/editor/contrib/dragAndDrop/dropBlinkRenderer"; +import { ScrollOnEdgeController } from "src/editor/contrib/dragAndDrop/scrollOnEdgeController"; import { nullable } from "src/base/common/utilities/type"; export interface IEditorDragAndDropExtension extends IEditorExtension { diff --git a/src/editor/contrib/dragAndDropExtension/dropBlinkRenderer.scss b/src/editor/contrib/dragAndDrop/dropBlinkRenderer.scss similarity index 100% rename from src/editor/contrib/dragAndDropExtension/dropBlinkRenderer.scss rename to src/editor/contrib/dragAndDrop/dropBlinkRenderer.scss diff --git a/src/editor/contrib/dragAndDropExtension/dropBlinkRenderer.ts b/src/editor/contrib/dragAndDrop/dropBlinkRenderer.ts similarity index 95% rename from src/editor/contrib/dragAndDropExtension/dropBlinkRenderer.ts rename to src/editor/contrib/dragAndDrop/dropBlinkRenderer.ts index c1d58ec5c..a4c648b42 100644 --- a/src/editor/contrib/dragAndDropExtension/dropBlinkRenderer.ts +++ b/src/editor/contrib/dragAndDrop/dropBlinkRenderer.ts @@ -1,4 +1,4 @@ -import "src/editor/contrib/dragAndDropExtension/dropBlinkRenderer.scss"; +import "src/editor/contrib/dragAndDrop/dropBlinkRenderer.scss"; import { Disposable, IDisposable } from "src/base/common/dispose"; import { ProseDecoration, ProseDecorationSet, ProseEditorView } from "src/editor/common/proseMirror"; import { IEditorWidget } from "src/editor/editorWidget"; diff --git a/src/editor/contrib/dragAndDropExtension/dropCursorRenderer.ts b/src/editor/contrib/dragAndDrop/dropCursorRenderer.ts similarity index 100% rename from src/editor/contrib/dragAndDropExtension/dropCursorRenderer.ts rename to src/editor/contrib/dragAndDrop/dropCursorRenderer.ts diff --git a/src/editor/contrib/dragAndDropExtension/scrollOnEdgeController.ts b/src/editor/contrib/dragAndDrop/scrollOnEdgeController.ts similarity index 100% rename from src/editor/contrib/dragAndDropExtension/scrollOnEdgeController.ts rename to src/editor/contrib/dragAndDrop/scrollOnEdgeController.ts diff --git a/src/editor/contrib/historyExtension/historyExtension.ts b/src/editor/contrib/history/history.ts similarity index 100% rename from src/editor/contrib/historyExtension/historyExtension.ts rename to src/editor/contrib/history/history.ts diff --git a/src/editor/contrib/inputRuleExtension/editorInputRules.ts b/src/editor/contrib/inputRuleExtension/editorInputRules.ts deleted file mode 100644 index 68140d245..000000000 --- a/src/editor/contrib/inputRuleExtension/editorInputRules.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { EditorState, Transaction } from "prosemirror-state"; -import { canJoin, findWrapping } from "prosemirror-transform"; -import { TokenEnum } from "src/editor/common/markdown"; -import { IEditorInputRuleExtension, InputRuleReplacement } from "src/editor/contrib/inputRuleExtension/inputRuleExtension"; -import { CodeBlockAttrs } from "src/editor/model/documentNode/node/codeBlock/codeBlock"; -import { HeadingAttrs } from "src/editor/model/documentNode/node/heading"; -import { IInstantiationService } from "src/platform/instantiation/common/instantiation"; - -export function registerDefaultInputRules(extension: IEditorInputRuleExtension): void { - - extension.registerRule("emDashRule", /--$/, "—"); - extension.registerRule("ellipsisRule", /\.\.\.$/, "…"); - extension.registerRule("openDoubleQuoteRule", /(?:^|[\s{[(<'"\u2018\u201C])(")$/, "“"); - extension.registerRule("closeDoubleQuoteRule", /"$/, "”"); - extension.registerRule("openSingleQuoteRule", /(?:^|[\s{[(<'"\u2018\u201C])(')$/, "‘"); - extension.registerRule("closeSingleQuoteRule", /'$/, "’"); - - // Heading Rule: Matches "#" followed by a space - extension.registerRule("headingRule", /^(#{1,6})\s$/, - { - nodeType: TokenEnum.Heading, - whenReplace: 'type', - getNodeAttribute: (match): HeadingAttrs => { - return { - level: match[1]?.length, - }; - }, - wrapStrategy: 'WrapTextBlock' - } - ); - - // Blockquote Rule: Matches ">" followed by a space - extension.registerRule("blockquoteRule", /^>\s$/, - { - nodeType: TokenEnum.Blockquote, - whenReplace: 'type', - wrapStrategy: 'WrapBlock' - } - ); - - // Code Block Rule: Matches triple backticks - extension.registerRule("codeBlockRule", /^```(.*)\s*$/, - { - nodeType: TokenEnum.CodeBlock, - whenReplace: 'enter', - getNodeAttribute: (match): CodeBlockAttrs => { - const lang = match[1]; - return { - lang: lang ?? 'Unknown', - }; - }, - wrapStrategy: 'WrapTextBlock' - } - ); - - extension.registerRule("orderedListRule", /^(\d+)\.\s$/, - { - nodeType: TokenEnum.List, - whenReplace: 'type', - getNodeAttribute: (match) => { - if (match && match[1]) { - return { ordered: true, start: parseInt(match[1]) }; - } - return { ordered: true, start: 1, }; - }, - shouldJoinWithBefore: (match, prevNode) => { - if (match && match[1]) { - return prevNode.type.name === TokenEnum.List && prevNode.attrs["order"] + 1 === +match[1]; - } - return false; - }, - wrapStrategy: 'WrapBlock' - } - ); - - extension.registerRule("bulletListRule", /^\s*([-+*])\s$/, - { - nodeType: TokenEnum.List, - whenReplace: 'type', - wrapStrategy: 'WrapBlock' - } - ); -} - -/** - * Represents an individual input rule. - */ -export interface IInputRule { - /** - * Unique identifier for the input rule. - */ - readonly id: string; - - /** - * Regular expression pattern that triggers the rule when matched in the - * editor. - */ - readonly pattern: RegExp; - - /** - * Defines the replacement strategy, either as a string or as a configuration - * object that specifies the `nodeType` to wrap around the matched text. - */ - readonly replacement: InputRuleReplacement; -} - -/** - * @internal - * Internal representation of an input rule. - */ -export class InputRule implements IInputRule { - - // [fields] - - public readonly id: string; - public readonly pattern: RegExp; - public readonly replacement: InputRuleReplacement; - - private readonly _replacementString?: string; - private readonly _replacementObject?: Exclude; - - public readonly onMatch: ( - state: EditorState, - match: RegExpExecArray, - start: number, - end: number - ) => Transaction | null; - - // [constructor] - - constructor(id: string, pattern: RegExp, replacement: InputRuleReplacement, private readonly instantiationService: IInstantiationService) { - this.id = id; - this.pattern = pattern; - this.replacement = replacement; - - if (typeof this.replacement !== 'string') { - this._replacementObject = this.replacement; - if (this.replacement.wrapStrategy === 'WrapTextBlock') { - this.onMatch = this.__textblockTypeInputRule; - } else { - this.onMatch = this.__wrappingInputRule; - } - } else { - this._replacementString = this.replacement; - this.onMatch = this.__onSimpleStringMatch; - } - } - - // [private methods] - - private __onSimpleStringMatch( - state: EditorState, - match: RegExpMatchArray, - start: number, - end: number - ): Transaction | null { - let insert = this._replacementString!; - if (match[1]) { - const offset = match[0].lastIndexOf(match[1]); - insert += match[0].slice(offset + match[1].length); - start += offset; - const cutOff = start - end; - if (cutOff > 0) { - insert = match[0].slice(offset - cutOff, offset) + insert; - start = end; - } - } - return state.tr.insertText(insert, start, end); - } - - private __wrappingInputRule( - state: EditorState, - match: RegExpExecArray, - start: number, - end: number - ): Transaction | null { - const replacement = this._replacementObject!; - const nodeType = state.schema.nodes[replacement.nodeType]; - if (!nodeType) { - console.warn(`[EditorInputRuleExtension] Node type "${replacement.nodeType}" not found in schema.`); - return null; - } - - const attrs = replacement.getNodeAttribute?.(match, this.instantiationService); - const tr = state.tr.delete(start, end); - const $start = tr.doc.resolve(start); - const range = $start.blockRange(); - - const wrapping = range && findWrapping(range, nodeType, attrs); - if (!wrapping) { - return null; - } - tr.wrap(range!, wrapping); - - const before = tr.doc.resolve(start - 1).nodeBefore; - if ( - before && - before.type === nodeType && - canJoin(tr.doc, start - 1) && - (!replacement.shouldJoinWithBefore || replacement.shouldJoinWithBefore(match, before)) - ) { - tr.join(start - 1); - } - - return tr; - } - - private __textblockTypeInputRule( - state: EditorState, - match: RegExpExecArray, - start: number, - end: number - ): Transaction | null { - const replacement = this._replacementObject!; - const nodeType = state.schema.nodes[replacement.nodeType]; - if (!nodeType) { - console.warn(`[EditorInputRuleExtension] Node type "${replacement.nodeType}" not found in schema.`); - return null; - } - - const attrs = replacement.getNodeAttribute?.(match, this.instantiationService); - const $start = state.doc.resolve(start); - if (!$start.node(-1).canReplaceWith($start.index(-1), $start.indexAfter(-1), nodeType)) { - return null; - } - - return state.tr - .delete(start, end) - .setBlockType(start, start, nodeType, attrs); - } -} \ No newline at end of file diff --git a/src/editor/contrib/slashCommand/slashCommand.ts b/src/editor/contrib/slashCommand/slashCommand.ts new file mode 100644 index 000000000..087eba213 --- /dev/null +++ b/src/editor/contrib/slashCommand/slashCommand.ts @@ -0,0 +1,68 @@ +import { EditorExtension, IEditorExtension } from "src/editor/common/editorExtension"; +import { ProseTools } from "src/editor/common/proseUtility"; +import { EditorExtensionIDs } from "src/editor/contrib/builtInExtensionList"; +import { IEditorWidget } from "src/editor/editorWidget"; +import { IOnTextInputEvent } from "src/editor/view/proseEventBroadcaster"; +import { EditorPalette } from "src/editor/view/widget/palette/palette"; +import { IInstantiationService } from "src/platform/instantiation/common/instantiation"; +import { BlockInsertProvider } from "src/editor/view/widget/palette/blockInsertProvider"; + +interface IEditorSlashCommandExtension extends IEditorExtension { + + readonly id: EditorExtensionIDs.SlashCommand; +} + +// region - EditorSlashCommandExtension + +export class EditorSlashCommandExtension extends EditorExtension implements IEditorSlashCommandExtension { + + // [fields] + + public override readonly id = EditorExtensionIDs.SlashCommand; + private readonly _palette: EditorPalette; + private readonly _contentProvider: BlockInsertProvider; + + constructor( + editorWidget: IEditorWidget, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(editorWidget); + this._contentProvider = instantiationService.createInstance(BlockInsertProvider, editorWidget); + + this._palette = this.__register(instantiationService.createInstance( + EditorPalette, + editorWidget, + { + contentProvider: () => this._contentProvider.getContent(), + } + )); + + // slash-command rendering + this.__register(this.onTextInput(e => this.tryShowSlashCommand(e))); + } + + // [public methods] + + public tryShowSlashCommand(e: IOnTextInputEvent): void { + const { text, view } = e; + const { selection } = view.state; + + const isCursor = ProseTools.Cursor.isCursor(selection); + if (!isCursor) { + return; + } + + const isEmptyBlock = ProseTools.Cursor.isOnEmpty(selection); + const isSlash = text === '/'; + if (!isEmptyBlock || !isSlash) { + return; + } + + // show slash command + const position = view.coordsAtPos(selection.$from.pos); + this._palette.render(position); + + // re-focus back to editor, not the slash command. + view.focus(); + } +} diff --git a/src/editor/contrib/slashCommandExtension/slashCommandExtension.ts b/src/editor/contrib/slashCommandExtension/slashCommandExtension.ts deleted file mode 100644 index a71b3d16d..000000000 --- a/src/editor/contrib/slashCommandExtension/slashCommandExtension.ts +++ /dev/null @@ -1,414 +0,0 @@ -import "src/editor/contrib/slashCommandExtension/slashCommand.scss"; -import { AnchorPrimaryAxisAlignment, AnchorVerticalPosition } from "src/base/browser/basic/contextMenu/contextMenu"; -import { MenuAction, MenuItemType, SimpleMenuAction, SubmenuAction } from "src/base/browser/basic/menu/menuItem"; -import { IPosition } from "src/base/common/utilities/size"; -import { EditorExtension, IEditorExtension } from "src/editor/common/editorExtension"; -import { ProseTools } from "src/editor/common/proseUtility"; -import { EditorExtensionIDs } from "src/editor/contrib/builtInExtensionList"; -import { IEditorWidget } from "src/editor/editorWidget"; -import { IOnTextInputEvent } from "src/editor/view/proseEventBroadcaster"; -import { IContextMenuService } from "src/workbench/services/contextMenu/contextMenuService"; -import { Disposable, DisposableBucket, IDisposable, safeDisposable } from "src/base/common/dispose"; -import { KeyCode } from "src/base/common/keyboard"; -import { ProseAttrs, ProseEditorView, ProseTextSelection } from "src/editor/common/proseMirror"; -import { Emitter, Priority } from "src/base/common/event"; -import { getTokenReadableName, Markdown, TokenEnum } from "src/editor/common/markdown"; -import { II18nService } from "src/platform/i18n/browser/i18nService"; -import { Arrays } from "src/base/common/utilities/array"; -import { ErrorHandler } from "src/base/common/error"; - -interface IEditorSlashCommandExtension extends IEditorExtension { - - readonly id: EditorExtensionIDs.SlashCommand; -} - -// region - EditorSlashCommandExtension - -export class EditorSlashCommandExtension extends EditorExtension implements IEditorSlashCommandExtension { - - // [fields] - - public override readonly id = EditorExtensionIDs.SlashCommand; - private readonly _menuRenderer: SlashMenuRenderer; - private readonly _menuController: SlashMenuController; - private readonly _keyboardController: SlashKeyboardController; - - constructor( - editorWidget: IEditorWidget, - @IContextMenuService contextMenuService: IContextMenuService, - @II18nService i18nService: II18nService, - ) { - super(editorWidget); - this._keyboardController = this.__register(new SlashKeyboardController(this, contextMenuService)); - this._menuController = new SlashMenuController(editorWidget); - this._menuRenderer = this.__register(new SlashMenuRenderer(editorWidget, contextMenuService, i18nService)); - - // slash-command rendering - this.__register(this.onTextInput(e => { - this.__tryShowSlashCommand(e); - })); - - // always back to normal - this.__register(this._menuRenderer.onMenuDestroy(() => { - this._keyboardController.unlisten(); - editorWidget.view.editor.focus(); - })); - - // menu click logic - this.__register(this._menuRenderer.onClick(e => { - contextMenuService.contextMenu.destroy(); - this._menuController.onClick(e); - })); - } - - // [private methods] - - private __tryShowSlashCommand(e: IOnTextInputEvent): void { - const { text, view } = e; - const { selection } = view.state; - - const isCursor = ProseTools.Cursor.isCursor(selection); - if (!isCursor) { - return; - } - - const isEmptyBlock = ProseTools.Cursor.isOnEmpty(selection); - const isSlash = text === '/'; - if (!isEmptyBlock || !isSlash) { - return; - } - - // show slash command - const position = view.coordsAtPos(selection.$from.pos); - this._menuRenderer.show(position); - - // re-focus back to editor, not the slash command. - view.focus(); - - // slash command shown, we capture certain key press. - this._keyboardController.listen(view); - } -} - -// region - SlashKeyboardController - -class SlashKeyboardController implements IDisposable { - - private _ongoing?: IDisposable; - - private _triggeredNode?: { - readonly pos: number; - readonly depth: number; - readonly nodeType: string; - }; - - constructor( - private readonly extension: EditorSlashCommandExtension, - private readonly contextMenuService: IContextMenuService, - ) { - this._triggeredNode = undefined; - } - - public dispose(): void { - this._ongoing?.dispose(); - this._ongoing = undefined; - this._triggeredNode = undefined; - } - - public unlisten(): void { - this.dispose(); - } - - /** - * Invoked whenever a menu is rendererd, we handle the keyboard logic here. - */ - public listen(view: ProseEditorView): void { - this.__trackCurrentNode(view); - - this._ongoing?.dispose(); - const bucket = (this._ongoing = safeDisposable(new DisposableBucket())); - - /** - * Capture certain key down we handle it by ourselves. - * @note Registered with {@link Priority.High} - */ - bucket.register(this.extension.onKeydown(e => { - const pressed = e.event.key; - const captureKey = [ - KeyCode.UpArrow, - KeyCode.DownArrow, - KeyCode.LeftArrow, - KeyCode.RightArrow, - - KeyCode.Escape, - KeyCode.Enter, - ]; - - // do nothing if non-capture key pressed. - if (!captureKey.includes(pressed)) { - return false; - } - - // escape: destroy the slash command - if (pressed === KeyCode.Escape) { - this.contextMenuService.contextMenu.destroy(); - } - // handle up/down arrows - else if (pressed === KeyCode.UpArrow) { - this.contextMenuService.contextMenu.focusPrev(); - } else if (pressed === KeyCode.DownArrow) { - this.contextMenuService.contextMenu.focusNext(); - } - // handle right/left arrows - else if (pressed === KeyCode.RightArrow || pressed === KeyCode.LeftArrow) { - const index = this.contextMenuService.contextMenu.getFocus(); - const currAction = this.contextMenuService.contextMenu.getAction(index); - if (!currAction || currAction.type !== MenuItemType.Submenu) { - return false; - } - - if (pressed === KeyCode.RightArrow) { - const opened = this.contextMenuService.contextMenu.tryOpenSubmenu(); - return opened; - } else { - return false; - } - } - // enter - else if (pressed === KeyCode.Enter) { - const hasFocus = this.contextMenuService.contextMenu.hasFocus(); - if (!hasFocus) { - this.contextMenuService.contextMenu.destroy(); - return false; - } - this.contextMenuService.contextMenu.runFocus(); - } - - // make sure to re-focus back to editor - view.focus(); - - // tell the editor we handled this event, stop propagation. - e.preventDefault(); - return true; - - }, undefined, Priority.High)); - - /** - * Whenever current textblock back to empty state, destroy the slash - * command. - */ - bucket.register(this.extension.onDidContentChange(() => { - const { $from } = view.state.selection; - const isEmptyBlock = ProseTools.Node.isEmptyTextBlock($from.parent); - if (isEmptyBlock) { - this.contextMenuService.contextMenu.destroy(); - } - })); - - /** - * Destroy slash command whenever the selection changes to other blocks. - */ - bucket.register(this.extension.onDidSelectionChange(e => { - const menu = this.contextMenuService.contextMenu; - const triggeredNode = this._triggeredNode; - if (!triggeredNode) { - return; - } - - // obtain current selection's node info - const currSelection = e.transaction.selection; - const $current = currSelection.$from; - const mappedPos = e.transaction.mapping.map(triggeredNode.pos); - - const isSameNode = ( - $current.parent.type.name === triggeredNode.nodeType && - $current.before() === mappedPos && - $current.depth === triggeredNode.depth - ); - - if (!isSameNode) { - menu.destroy(); - } - })); - } - - private __trackCurrentNode(view: ProseEditorView): void { - const { state } = view; - const $pos = state.selection.$from; - this._triggeredNode = { - pos: $pos.before(), - depth: $pos.depth, - nodeType: $pos.parent.type.name, - }; - } -} - -// region - SlashMenuRenderer - -type SlashOnClickEvent = { - readonly type: string; - readonly name: string; - readonly attr: ProseAttrs; -}; - -class SlashMenuRenderer extends Disposable { - - private readonly _onMenuDestroy = this.__register(new Emitter()); - public readonly onMenuDestroy = this._onMenuDestroy.registerListener; - - private readonly _onClick = this.__register(new Emitter()); - public readonly onClick = this._onClick.registerListener; - - constructor( - private readonly editorWidget: IEditorWidget, - private readonly contextMenuService: IContextMenuService, - private readonly i18nService: II18nService, - ) { - super(); - } - - public show(position?: IPosition): void { - if (!position) { - return; - } - const { overlayContainer: parentElement } = this.editorWidget.view.editor; - - this.contextMenuService.showContextMenuCustom({ - getActions: () => this.__obtainSlashCommandContent(), - getContext: () => undefined, - getAnchor: () => ({ x: position.left, y: position.top, height: 24 }), - getExtraContextMenuClassName: () => 'editor-slash-command', - primaryAlignment: AnchorPrimaryAxisAlignment.Vertical, - verticalPosition: AnchorVerticalPosition.Below, - - /** - * We need to capture the blur event to prevent auto destroy. We - * will handle destruction by ourselves. - */ - onBeforeDestroy: (cause) => { - const shouldPrevent = cause === 'blur'; - return shouldPrevent; - }, - // clean up - onDestroy: () => { - this._onMenuDestroy.fire(); - }, - }, parentElement); - } - - private __obtainSlashCommandContent(): MenuAction[] { - const nodes = this.__obtainValidContent(); - - // convert each node into menu action - return nodes.map(nodeName => { - // heading: submenu - if (nodeName === TokenEnum.Heading) { - return this.__getHeadingActions(); - } - // general case - const resolvedName = getTokenReadableName(this.i18nService, nodeName); - return new SimpleMenuAction({ - enabled: true, - id: resolvedName, - callback: () => this._onClick.fire({ - type: nodeName, - name: resolvedName, - attr: {}, - }), - }); - }); - } - - private __obtainValidContent(): string[] { - const blocks = this.editorWidget.model.getRegisteredDocumentNodes(); - const ordered = this.__filterContent(blocks, CONTENT_FILTER); - return ordered; - } - - private __filterContent(unordered: string[], expectOrder: string[]): string[] { - const ordered: string[] = []; - const unorderedSet = new Set(unordered); - for (const name of expectOrder) { - if (unorderedSet.has(name)) { - ordered.push(name); - unorderedSet.delete(name); - } else { - console.warn(`[SlashCommandExtension] missing node: ${name}`); - } - } - return ordered; - } - - private __getHeadingActions(): SubmenuAction { - const heading = getTokenReadableName(this.i18nService, TokenEnum.Heading); - return new SubmenuAction( - Arrays.range(1, 7).map(level => this.__getHeadingAction(heading, level)), - { enabled: true, id: heading } - ); - } - - private __getHeadingAction(name: string, level: number): MenuAction { - return new SimpleMenuAction({ - enabled: true, - id: `${name} ${level}`, - callback: () => this._onClick.fire({ - type: TokenEnum.Heading, - name: name, - attr: { level: level }, - }), - }); - } -} - -const CONTENT_FILTER = [ - TokenEnum.Paragraph, - TokenEnum.Blockquote, - TokenEnum.Heading, - TokenEnum.Image, - TokenEnum.List, - TokenEnum.Table, - TokenEnum.CodeBlock, - TokenEnum.MathBlock, - TokenEnum.HTML, - TokenEnum.HorizontalRule, -]; - -// region - SlashMenuController - -class SlashMenuController { - - constructor( - private readonly editorWidget: IEditorWidget, - ) {} - - public onClick(event: SlashOnClickEvent): void { - const { type, name, attr } = event; - const view = this.editorWidget.view.editor.internalView; - const state = view.state; - let tr = state.tr; - - const prevStart = tr.selection.$from.start(); - const $pos = state.doc.resolve(prevStart); - - // create an empy node with given type - const node = Markdown.Create.empty(state, type, attr); - if (!node) { - ErrorHandler.onUnexpectedError(new Error(`Cannot create node (${name})`)); - return; - } - - // replace the current node with the new node - tr = ProseTools.Position.replaceWithNode(state, $pos, node); - - // find next selectable text - const newStart = tr.mapping.map(prevStart); - const $newStart = tr.doc.resolve(newStart + 1); - const selection = ProseTextSelection.findFrom($newStart, 1, true); - if (selection) { - tr = tr.setSelection(selection); - } - - // update - view.dispatch(tr); - } -} \ No newline at end of file diff --git a/src/editor/contrib/snippet/snippet.contrib.ts b/src/editor/contrib/snippet/snippet.contrib.ts new file mode 100644 index 000000000..c882c980b --- /dev/null +++ b/src/editor/contrib/snippet/snippet.contrib.ts @@ -0,0 +1,128 @@ +import { TokenEnum, MarkEnum } from "src/editor/common/markdown"; +import { IEditorSnippetExtension } from "src/editor/contrib/snippet/snippet"; +import { CodeBlockAttrs } from "src/editor/model/documentNode/node/codeBlock/codeBlock"; +import { HeadingAttrs } from "src/editor/model/documentNode/node/heading"; + +export function registerDefaultSnippet(extension: IEditorSnippetExtension): void { + + extension.registerRule("emDashRule", /--$/, "—"); + extension.registerRule("ellipsisRule", /\.\.\.$/, "…"); + extension.registerRule("openDoubleQuoteRule", /(?:^|[\s{[(<'"\u2018\u201C])(")$/, "“"); + extension.registerRule("closeDoubleQuoteRule", /"$/, "”"); + extension.registerRule("openSingleQuoteRule", /(?:^|[\s{[(<'"\u2018\u201C])(')$/, "‘"); + extension.registerRule("closeSingleQuoteRule", /'$/, "’"); + + // Heading Rule: Matches "#" followed by a space + extension.registerRule("headingRule", /^(#{1,6})\s$/, + { + type: 'node', + nodeType: TokenEnum.Heading, + whenReplace: 'type', + getAttribute: (match): HeadingAttrs => { + return { + level: match[1]?.length, + }; + }, + wrapStrategy: 'WrapTextBlock' + } + ); + + // Blockquote Rule: Matches ">" followed by a space + extension.registerRule("blockquoteRule", /^>\s$/, + { + type: 'node', + nodeType: TokenEnum.Blockquote, + whenReplace: 'type', + wrapStrategy: 'WrapBlock' + } + ); + + // Code Block Rule: Matches triple backticks + extension.registerRule("codeBlockRule", /^```(.*)\s*$/, + { + type: 'node', + nodeType: TokenEnum.CodeBlock, + whenReplace: 'enter', + getAttribute: (match): CodeBlockAttrs => { + const lang = match[1]; + return { + lang: lang ?? 'Unknown', + }; + }, + wrapStrategy: 'WrapTextBlock' + } + ); + + extension.registerRule("orderedListRule", /^(\d+)\.\s$/, + { + type: 'node', + nodeType: TokenEnum.List, + whenReplace: 'type', + getAttribute: (match) => { + if (match && match[1]) { + return { ordered: true, start: parseInt(match[1]) }; + } + return { ordered: true, start: 1, }; + }, + shouldJoinWithBefore: (match, prevNode) => { + if (match && match[1]) { + return prevNode.type.name === TokenEnum.List && prevNode.attrs["order"] + 1 === +match[1]; + } + return false; + }, + wrapStrategy: 'WrapBlock' + } + ); + + extension.registerRule("bulletListRule", /^\s*([-+*])\s$/, + { + type: 'node', + nodeType: TokenEnum.List, + whenReplace: 'type', + wrapStrategy: 'WrapBlock' + } + ); + + extension.registerRule("strongRule", /\*\*(.+?)\*\*$/, { + type: 'mark', + markType: MarkEnum.Strong, + whenReplace: 'type', + preventMarkInheritance: true, + }); + + extension.registerRule("emphasisRule", /\*(.+?)\*$/, { + type: 'mark', + markType: MarkEnum.Em, + whenReplace: 'type', + preventMarkInheritance: true, + }); + + const ESCAPE_REGEX = /`(?![`]{2})((?:\\`|[^`])+?)`(?![`]{2})$/; + extension.registerRule("codespanRule", ESCAPE_REGEX, { + type: 'mark', + markType: MarkEnum.Codespan, + whenReplace: 'type', + preventMarkInheritance: true, + }); + + extension.registerRule("mathBlockRule", /^\$\$$/, + { + type: 'node', + nodeType: TokenEnum.MathBlock, + whenReplace: 'enter', + wrapStrategy: 'ReplaceBlock', + } + ); + + extension.registerRule("mathInlineRule", /\$(.+?)\$$/, { + type: 'node', + nodeType: TokenEnum.MathInline, + whenReplace: 'type', + wrapStrategy: 'ReplaceBlock', + getAttribute: (matched) => { + return { + text: matched[1], + }; + } + }); +} diff --git a/src/editor/contrib/inputRuleExtension/inputRuleExtension.ts b/src/editor/contrib/snippet/snippet.ts similarity index 53% rename from src/editor/contrib/inputRuleExtension/inputRuleExtension.ts rename to src/editor/contrib/snippet/snippet.ts index f94298ae1..aafcba554 100644 --- a/src/editor/contrib/inputRuleExtension/inputRuleExtension.ts +++ b/src/editor/contrib/snippet/snippet.ts @@ -8,72 +8,101 @@ import { Dictionary, isString } from "src/base/common/utilities/type"; import { ProseEditorView, ProseNode, ProseResolvedPos, ProseTextSelection } from "src/editor/common/proseMirror"; import { KeyCode } from "src/base/common/keyboard"; import { TokenEnum } from "src/editor/common/markdown"; -import { IInputRule, InputRule, registerDefaultInputRules } from "src/editor/contrib/inputRuleExtension/editorInputRules"; +import { ISnippetRule, SnippetRule } from "src/editor/contrib/snippet/snippetRule"; import { IInstantiationService, IServiceProvider } from "src/platform/instantiation/common/instantiation"; +import { ILogService } from "src/base/common/logger"; +import { registerDefaultSnippet } from "src/editor/contrib/snippet/snippet.contrib"; + +// region - replacement /** - * Defines the replacement behavior for an input rule. An input rule replacement + * Defines the replacement behavior for an snippet rule. An snippet rule replacement * can either be: * 1. a direct string replacement or * 2. an object that specifies complicated replacement rule. */ -export type InputRuleReplacement = - | string - | { - /** - * Specifies the type of node to create when replacing. - */ - readonly nodeType: string | TokenEnum; - - /** - * Determines the wrapping strategy to use when applying the input rule. - * - `WrapBlock`: Wraps the matched content as a block-level element. - * - `WrapTextBlock`: Wraps the matched content as a text block within a block-level container. - */ - readonly wrapStrategy: 'WrapBlock' | 'WrapTextBlock'; - - /** - * Determines when should the replacement happens. - * - `type`: Any keyboard typing will try to match content. - * - `enter`: Only when pressing the key `enter` will try to match content. - */ - readonly whenReplace: 'type' | 'enter'; - - /** - * @description A function that generates node attributes based on the - * matched text. The attributes will eventually used for constructing - * the node instance ({@link ProseNode}). - * @param matchedText The matched text. - * @returns A dictionary of attributes for the new node. - * - * @note If not defined, the attributes of the generated node would be `null`. - */ - readonly getNodeAttribute?: (matchedText: RegExpExecArray, serviceProvider: IServiceProvider) => Dictionary; - - /** - * @description A predicate function that determines if the new node - * should join with the preceding node. - * @param matchedText The matched text. - * @param prevNode The previous node in the document, used to determine - * if a join is appropriate. - * @returns Returns `true` if the new node should join with `prevNode`, - * otherwise `false`. - * - * @note If not defined, as long as {@link canJoin} returns true, the - * node will be joined with previous node. - */ - readonly shouldJoinWithBefore?: (matchedText: RegExpExecArray, prevNode: ProseNode) => boolean; - }; +export type SnippetReplacement = + | string + | MarkSnippetReplacement + | NodeSnippetReplacement; + +type SnippetReplacementBase = { + readonly type: string; + + /** + * Determines when should the replacement happens. + * - `type`: Any keyboard typing will try to match content. + * - `enter`: Only when pressing the key `enter` will try to match content. + */ + readonly whenReplace: 'type' | 'enter'; + + /** + * @description A function that generates node/mark attributes based on the + * matched text. The attributes will eventually used for constructing the + * node/mark instance ({@link ProseNode}). + * @param matchedText The matched text. + * @returns A dictionary of attributes for the new node/mark. + * + * @note If not defined, the attributes of the generated node/mark would be + * `null`. + */ + readonly getAttribute?: (matchedText: RegExpExecArray, serviceProvider: IServiceProvider) => Dictionary; +}; + +export type MarkSnippetReplacement = SnippetReplacementBase & { + readonly type: 'mark'; + + /** + * Specifies the type of mark to create when replacing. + */ + readonly markType: string; + + /** + * After the mark is applied, should the following typed text inherit this + * mark. + */ + readonly preventMarkInheritance: boolean; +}; + +export type NodeSnippetReplacement = SnippetReplacementBase & { + readonly type: 'node'; + /** + * Specifies the type of node to create when replacing. + */ + readonly nodeType: string | TokenEnum; + + /** + * Determines the wrapping strategy to use when applying the snippet rule. + * - `WrapBlock`: Wraps the matched content as a block-level element. + * - `WrapTextBlock`: Wraps the matched content as a text block within a block-level container. + * - `ReplaceBlock`: Replace the matched block as a new block-level element. + */ + readonly wrapStrategy: 'WrapBlock' | 'WrapTextBlock' | 'ReplaceBlock'; + + /** + * @description A predicate function that determines if the new node + * should join with the preceding node. + * @param matchedText The matched text. + * @param prevNode The previous node in the document, used to determine + * if a join is appropriate. + * @returns Returns `true` if the new node should join with `prevNode`, + * otherwise `false`. + * + * @note If not defined, as long as {@link canJoin} returns true, the + * node will be joined with previous node. + */ + readonly shouldJoinWithBefore?: (matchedText: RegExpExecArray, prevNode: ProseNode) => boolean; +}; /** - * An interface only for {@link EditorInputRuleExtension}. + * An interface only for {@link EditorSnippetExtension}. */ -export interface IEditorInputRuleExtension extends IEditorExtension { +export interface IEditorSnippetExtension extends IEditorExtension { - readonly id: EditorExtensionIDs.InputRule; + readonly id: EditorExtensionIDs.Snippet; /** - * @description Registers a new input rule. + * @description Registers a new snippet rule. * @param id A unique identifier for the rule. * @param pattern The regular expression pattern that triggers this rule. * @param replacement The replacement behavior when the pattern is matched. @@ -82,34 +111,36 @@ export interface IEditorInputRuleExtension extends IEditorExtension { * @returns Returns `true` if the rule was registered successfully, `false` * if a rule with the same ID or same {@link RegExp} already exists. */ - registerRule(id: string, pattern: RegExp, replacement: InputRuleReplacement): boolean; + registerRule(id: string, pattern: RegExp, replacement: SnippetReplacement): boolean; /** - * @description Un-registers an input rule by its unique identifier. + * @description Un-registers an snippet rule by its unique identifier. * @param id The identifier of the rule to remove. * @returns Returns `true` if the rule was found and removed, otherwise `false`. */ unregisterRule(id: string): boolean; /** - * @description Retrieves a registered input rule by its ID. + * @description Retrieves a registered snippet rule by its ID. * @param id The identifier of the desired rule. */ - getRuleByID(id: string): IInputRule | undefined; + getRuleByID(id: string): ISnippetRule | undefined; /** - * @description Retrieves all registered input rules. + * @description Retrieves all registered snippet rules. */ - getAllRules(): IInputRule[]; + getAllRules(): ISnippetRule[]; } -export class EditorInputRuleExtension extends EditorExtension implements IEditorInputRuleExtension { +// region - snippet + +export class EditorSnippetExtension extends EditorExtension implements IEditorSnippetExtension { // [field] - public override readonly id = EditorExtensionIDs.InputRule; + public override readonly id = EditorExtensionIDs.Snippet; - private readonly _rules: Map = new Map(); + private readonly _rules: Map = new Map(); private readonly MAX_TEXT_BEFORE = 100; // [constructor] @@ -117,15 +148,17 @@ export class EditorInputRuleExtension extends EditorExtension implements IEditor constructor( editorWidget: IEditorWidget, @IInstantiationService private readonly instantiationService: IInstantiationService, + @ILogService private readonly logService: ILogService, ) { super(editorWidget); - registerDefaultInputRules(this); + registerDefaultSnippet(this); this.__register(this.onTextInput(e => { const handled = this.__handleTextInput(e.view, e.from, e.to, e.text); if (handled) { e.preventDefault(); + return true; } })); @@ -134,6 +167,7 @@ export class EditorInputRuleExtension extends EditorExtension implements IEditor const handled = this.__handleEnter(e.view); if (handled) { e.preventDefault(); + return true; } } })); @@ -141,18 +175,18 @@ export class EditorInputRuleExtension extends EditorExtension implements IEditor // [public methods] - public registerRule(id: string, pattern: RegExp, replacement: InputRuleReplacement): boolean { + public registerRule(id: string, pattern: RegExp, replacement: SnippetReplacement): boolean { if (this._rules.has(id)) { - console.warn(`InputRule with id "${id}" already exists.`); + console.warn(`SnippetRule with id "${id}" already exists.`); return false; } if ([...this._rules.values()].some(rule => rule.pattern.source === pattern.source)) { - console.warn(`InputRule with pattern "${pattern}" already exists.`); + console.warn(`SnippetRule with pattern "${pattern}" already exists.`); return false; } - const rule = new InputRule(id, pattern, replacement, this.instantiationService); + const rule = new SnippetRule(id, pattern, replacement, this.instantiationService); this._rules.set(id, rule); return true; } @@ -161,11 +195,11 @@ export class EditorInputRuleExtension extends EditorExtension implements IEditor return this._rules.delete(id); } - public getRuleByID(id: string): IInputRule | undefined { + public getRuleByID(id: string): ISnippetRule | undefined { return this._rules.get(id); } - public getAllRules(): IInputRule[] { + public getAllRules(): ISnippetRule[] { return Array.from(this._rules.values()); } @@ -225,6 +259,7 @@ export class EditorInputRuleExtension extends EditorExtension implements IEditor const tr = rule.onMatch(state, match, start, end); if (!tr) { + this.logService.warn('EditorSnippet', `Unable to achiece replacement for matched snippet rule: ${rule.id}.`); continue; } diff --git a/src/editor/contrib/snippet/snippetRule.ts b/src/editor/contrib/snippet/snippetRule.ts new file mode 100644 index 000000000..1f555b27d --- /dev/null +++ b/src/editor/contrib/snippet/snippetRule.ts @@ -0,0 +1,234 @@ +import { EditorState, Transaction } from "prosemirror-state"; +import { canJoin, findWrapping } from "prosemirror-transform"; +import { panic } from "src/base/common/utilities/panic"; +import { ProseNodeSelection } from "src/editor/common/proseMirror"; +import { SnippetReplacement, MarkSnippetReplacement, NodeSnippetReplacement } from "src/editor/contrib/snippet/snippet"; +import { IInstantiationService } from "src/platform/instantiation/common/instantiation"; + +/** + * Represents an individual snippet rule. + */ +export interface ISnippetRule { + /** + * Unique identifier for the snippet rule. + */ + readonly id: string; + + /** + * Regular expression pattern that triggers the rule when matched in the + * editor. + */ + readonly pattern: RegExp; + + /** + * Defines the replacement strategy, either as a string or as a configuration + * object that specifies the `nodeType` to wrap around the matched text. + */ + readonly replacement: SnippetReplacement; +} + +/** + * @internal + * Internal representation of an snippet rule. + */ +export class SnippetRule implements ISnippetRule { + + // [fields] + + public readonly id: string; + public readonly pattern: RegExp; + public readonly replacement: SnippetReplacement; + + private readonly _replacementString?: string; + private readonly _replacementObject?: Exclude; + + public readonly onMatch: ( + state: EditorState, + match: RegExpExecArray, + start: number, + end: number + ) => Transaction | null; + + // [constructor] + + constructor(id: string, pattern: RegExp, replacement: SnippetReplacement, private readonly instantiationService: IInstantiationService) { + this.id = id; + this.pattern = pattern; + this.replacement = replacement; + + if (typeof this.replacement === 'string') { + this._replacementString = this.replacement; + this.onMatch = this.__onSimpleStringMatch; + } + else if (this.replacement.type === 'node') { + this._replacementObject = this.replacement; + if (this.replacement.wrapStrategy === 'WrapTextBlock') { + this.onMatch = this.__textblockTypeSnippetRule; + } + else if (this.replacement.wrapStrategy === 'ReplaceBlock') { + this.onMatch = this.__replaceBlockSnippetRule; + } + else { + this.onMatch = this.__wrappingSnippetRule; + } + } + else if (this.replacement.type === 'mark') { + this._replacementObject = this.replacement; + this.onMatch = this.__markSnippetRule; + } else { + panic(`Invalid replacement type: ${this.replacement['type']}`); + } + } + + // [private methods] + + private __onSimpleStringMatch( + state: EditorState, + match: RegExpMatchArray, + start: number, + end: number + ): Transaction | null { + let insert = this._replacementString!; + if (match[1]) { + const offset = match[0].lastIndexOf(match[1]); + insert += match[0].slice(offset + match[1].length); + start += offset; + const cutOff = start - end; + if (cutOff > 0) { + insert = match[0].slice(offset - cutOff, offset) + insert; + start = end; + } + } + return state.tr.insertText(insert, start, end); + } + + private __markSnippetRule( + state: EditorState, + match: RegExpExecArray, + start: number, + end: number + ): Transaction | null { + const replacement = this.replacement as MarkSnippetReplacement; + const markType = state.schema.marks[replacement.markType]; + if (!markType) { + console.warn(`Mark type "${replacement.markType}" not found`); + return null; + } + + const text = match[1]!; + const tr = state.tr + .delete(start, end) + .insertText(text, start); + + const attrs = replacement.getAttribute?.(match, this.instantiationService); + const mark = markType.create(attrs); + tr.addMark(start, start + text.length, mark); + + if (replacement.preventMarkInheritance) { + tr.setStoredMarks([]); + } + + return tr; + } + + private __wrappingSnippetRule( + state: EditorState, + match: RegExpExecArray, + start: number, + end: number + ): Transaction | null { + const replacement = this._replacementObject as NodeSnippetReplacement; + const nodeType = state.schema.nodes[replacement.nodeType]; + if (!nodeType) { + console.warn(`[EditorSnippetExtension] Node type "${replacement.nodeType}" not found in schema.`); + return null; + } + + const attrs = replacement.getAttribute?.(match, this.instantiationService); + const tr = state.tr.delete(start, end); + const $start = tr.doc.resolve(start); + const range = $start.blockRange(); + + const wrapping = range && findWrapping(range, nodeType, attrs); + if (!wrapping) { + return null; + } + tr.wrap(range!, wrapping); + + const before = tr.doc.resolve(start - 1).nodeBefore; + if ( + before && + before.type === nodeType && + canJoin(tr.doc, start - 1) && + (!replacement.shouldJoinWithBefore || replacement.shouldJoinWithBefore(match, before)) + ) { + tr.join(start - 1); + } + + return tr; + } + + private __textblockTypeSnippetRule( + state: EditorState, + match: RegExpExecArray, + start: number, + end: number + ): Transaction | null { + const replacement = this._replacementObject as NodeSnippetReplacement; + const nodeType = state.schema.nodes[replacement.nodeType]; + if (!nodeType) { + console.warn(`[EditorSnippetExtension] Node type "${replacement.nodeType}" not found in schema.`); + return null; + } + + const attrs = replacement.getAttribute?.(match, this.instantiationService); + const $start = state.doc.resolve(start); + if (!$start.node(-1).canReplaceWith($start.index(-1), $start.indexAfter(-1), nodeType)) { + return null; + } + + return state.tr + .delete(start, end) + .setBlockType(start, start, nodeType, attrs); + } + + private __replaceBlockSnippetRule( + state: EditorState, + match: RegExpExecArray, + start: number, + end: number + ): Transaction | null { + const replacement = this.replacement as NodeSnippetReplacement; + const nodeType = state.schema.nodes[replacement.nodeType]; + if (!nodeType) { + console.warn(`Node type "${replacement.nodeType}" not found`); + return null; + } + + if (nodeType.isInline) { + const attrs = replacement.getAttribute?.(match, this.instantiationService); + const newNode = nodeType.create(attrs); + const tr = state.tr + .delete(start, end) + .insert(start, newNode); + return tr; + } + + const $pos = state.doc.resolve(start); + const blockRange = $pos.blockRange(); + if (!blockRange) { + return null; + } + + const attrs = replacement.getAttribute?.(match, this.instantiationService); + const newNode = nodeType.create(attrs); + const tr = state.tr.replaceWith(blockRange.start, blockRange.end, newNode); + + // select it + const newPos = tr.doc.resolve(blockRange.start); + const selection = ProseNodeSelection.near(newPos); + tr.setSelection(selection); + + return tr; + } +} \ No newline at end of file diff --git a/src/editor/editorWidget.ts b/src/editor/editorWidget.ts index 5d67b93e3..6f8219eb4 100644 --- a/src/editor/editorWidget.ts +++ b/src/editor/editorWidget.ts @@ -6,7 +6,7 @@ import { ILogService } from "src/base/common/logger"; import { Constructor, isDefined } from "src/base/common/utilities/type"; import { IInstantiationService } from "src/platform/instantiation/common/instantiation"; import { IBrowserLifecycleService, ILifecycleService } from "src/platform/lifecycle/browser/browserLifecycleService"; -import { IEditorModel } from "src/editor/common/model"; +import { IEditorModel, IModelBuildData } from "src/editor/common/model"; import { EditorType, IEditorView } from "src/editor/common/view"; import { BasicEditorOption, EDITOR_OPTIONS_DEFAULT, EditorOptionsType, IEditorWidgetOptions, toJsonEditorOption } from "src/editor/common/editorConfiguration"; import { EditorModel } from "src/editor/model/editorModel"; @@ -20,7 +20,9 @@ import { assert, errorToMessage } from "src/base/common/utilities/panic"; import { AsyncResult, err, ok, Result } from "src/base/common/result"; import { EditorDragState } from "src/editor/common/cursorDrop"; import { EditorViewModel } from "src/editor/viewModel/editorViewModel"; -import { IEditorViewModel } from "src/editor/common/viewModel"; +import { IEditorViewModel, IViewModelBuildData } from "src/editor/common/viewModel"; +import { IEditorInputEmulator } from "src/editor/view/inputEmulator"; +import { KeyCode } from "src/base/common/keyboard"; // region - [interface] @@ -30,17 +32,17 @@ import { IEditorViewModel } from "src/editor/common/viewModel"; export interface IEditorWidget extends IProseEventBroadcaster, Pick + | 'save' + >, + Pick { - /** * Is the editor initialized. if not, access to model, viewModel and view * will panic. @@ -158,9 +160,6 @@ export class EditorWidget extends Disposable implements IEditorWidget { // region - [model events] - private readonly _onDidStateChange = this.__register(RelayEmitter.createPriority()); - public readonly onDidStateChange = this._onDidStateChange.registerListener; - private readonly _onDidDirtyChange = this.__register(RelayEmitter.createPriority()); public readonly onDidDirtyChange = this._onDidDirtyChange.registerListener; @@ -310,7 +309,7 @@ export class EditorWidget extends Disposable implements IEditorWidget { get initialized(): boolean { return !!this._model; } - get model(): IEditorModel { return assert(this._model); } + get model(): IEditorModel { return this.__assertModel(); } get viewModel(): IEditorViewModel { return assert(this._viewModel); } get view(): IEditorView { return assert(this._view); } @@ -320,46 +319,33 @@ export class EditorWidget extends Disposable implements IEditorWidget { // region - [public] public async open(source: URI): Promise> { - const currSource = this._model?.source; - if (currSource && URI.equals(source, currSource)) { + // same source, do nothing. + if (this._model?.source && URI.equals(source, this._model.source)) { return ok(); } + // cleanup first this.__detachData(); - const extensionList = this._extensions.getExtensions(); // model - this._model = this.instantiationService.createInstance(EditorModel, source, this._options.getOptions()); - const initState = await this._model.build(extensionList); - - // unexpected behavior, we need to let the user know. - if (initState.isErr()) { - const error = new Error(`Editor: Cannot open editor at '${URI.toFsPath(source)}'. ${errorToMessage(initState.unwrapErr(), false)}`); - this._model.dispose(); - return err(error); - } + return (await this.__createModel(source)).andThen(([model, modelData]) => { + this._model = model; + const extensions = this._extensions.getExtensions(); - const initData = initState.unwrap(); + // view-model + this._viewModel = this.__createViewModel(extensions); + const viewModelData = this._viewModel.build(modelData); - // view-model - this._viewModel = this.instantiationService.createInstance(EditorViewModel, this._model); - - // view - this._view = this.instantiationService.createInstance( - EditorView, - this._container.raw, - this._viewModel, - initData, - extensionList, - this._options.getOptions(), - ); + // view + this._view = this.__createView(extensions, viewModelData); - // listeners - this.__registerMVVMListeners(this._model, this._viewModel, this._view); + // listeners + this.__registerMVVMListeners(this._model, this._viewModel, this._view); - // cache data - this._editorData = this.__register(new EditorData(this._model, this._viewModel, this._view, undefined)); - return ok(); + // cache data + this._editorData = this.__register(new EditorData(this._model, this._viewModel, this._view, undefined)); + return ok(); + }); } public isOpened(): boolean { @@ -402,19 +388,25 @@ export class EditorWidget extends Disposable implements IEditorWidget { // region - [model] - get source(): URI { return this.__assertModel().source; } + get source(): URI { return this.model.source; } get dirty(): boolean { return assert(this._model).dirty; } - public insertAt(textOffset: number, text: string): void { - return this.__assertModel().insertAt(textOffset, text); + public destroy(): void { + return this.dispose(); } + + // region - [viewModel] - public deleteAt(textOffset: number, length: number): void { - return this.__assertModel().deleteAt(textOffset, length); + + + // region - [View] + + public type(text: string, from?: number, to?: number): void { + this.view.type(text, from, to); } - public destroy(): void { - return this.dispose(); + public keydown(code: KeyCode, alt?: boolean, shift?: boolean, ctrl?: boolean, meta?: boolean): void { + this.view.keydown(code, alt, shift, ctrl, meta); } // region - [private] @@ -438,6 +430,49 @@ export class EditorWidget extends Disposable implements IEditorWidget { return assert(this._view, '[EditorWidget] EditorView is not initialized.'); } + private async __createModel(source: URI): Promise> { + const model = this.instantiationService.createInstance( + EditorModel, + source, + this._options.getOptions(), + ); + const build = await model.build(); + + // unexpected behavior, we need to let the user know. + if (build.isErr()) { + const error = new Error(`Cannot open editor at '${URI.toFsPath(source)}'. ${errorToMessage(build.unwrapErr(), false)}`); + return err(error); + } + const modelData = build.unwrap(); + + return ok([model, modelData]); + } + + private __createViewModel(extensions: EditorExtension[]): EditorViewModel { + return this.instantiationService.createInstance( + EditorViewModel, + this.model, + extensions, + ); + } + + private __createView(extensions: EditorExtension[], viewModelData: IViewModelBuildData): EditorView { + const inputEmulator: IEditorInputEmulator = { + type: e => this._onTextInput.fire(e), + keydown: e => this._onKeydown.fire(e), + }; + + return this.instantiationService.createInstance( + EditorView, + this._container.raw, + this.viewModel, + viewModelData.state, + extensions, + inputEmulator, + this._options.getOptions(), + ); + } + private __registerListeners(): void { this.__register(this.lifecycleService.onBeforeQuit(() => this._options.saveOptions())); @@ -452,11 +487,13 @@ export class EditorWidget extends Disposable implements IEditorWidget { private __registerMVVMListeners(model: IEditorModel, viewModel: IEditorViewModel, view: IEditorView): void { // binding to the model - this._onDidStateChange.setInput(model.onDidStateChange); this._onDidDirtyChange.setInput(model.onDidDirtyChange); this._onDidSave.setInput(model.onDidSave); this._onDidSaveError.setInput(model.onDidSaveError); + // binding to the viewModel + // TODO + // binding to the view this._onDidBlur.setInput(this.view.onDidBlur); this._onDidFocus.setInput(this.view.onDidFocus); diff --git a/src/editor/model/documentNode/documentNode.ts b/src/editor/model/documentNode/documentNode.ts index f510e788c..0a4e9b4e8 100644 --- a/src/editor/model/documentNode/documentNode.ts +++ b/src/editor/model/documentNode/documentNode.ts @@ -77,7 +77,7 @@ export interface IDocumentNode): void; /** * An option that defines how the serialization behavior of {@link DocumentMark}. @@ -91,7 +91,7 @@ abstract class DocumentNodeBase): void; // serializer public abstract readonly serializer: TNode extends ProseMark ? IDocumentMarkSerializationOptions : Serializer; @@ -100,10 +100,10 @@ abstract class DocumentNodeBase extends DocumentNodeBase {} +export abstract class DocumentNode extends DocumentNodeBase {} /** * @class A document mark that represents a mark. Such as 'strong', 'emphasis', * 'link' and so on. */ -export abstract class DocumentMark extends DocumentNodeBase {} \ No newline at end of file +export abstract class DocumentMark extends DocumentNodeBase {} \ No newline at end of file diff --git a/src/editor/model/documentNode/documentNodeProvider.ts b/src/editor/model/documentNode/documentNodeProvider.ts index 45a24960d..8acc2273f 100644 --- a/src/editor/model/documentNode/documentNodeProvider.ts +++ b/src/editor/model/documentNode/documentNodeProvider.ts @@ -2,6 +2,7 @@ import { IO } from "src/base/common/utilities/functional"; import { panic } from "src/base/common/utilities/panic"; import { Mutable } from "src/base/common/utilities/type"; import { TokenEnum, MarkEnum } from "src/editor/common/markdown"; +import { EditorToken } from "src/editor/common/model"; import { ProseNodeType, ProseMarkType } from "src/editor/common/proseMirror"; import { DocumentNode, DocumentMark } from "src/editor/model/documentNode/documentNode"; import { Codespan } from "src/editor/model/documentNode/mark/codespan"; @@ -20,7 +21,6 @@ import { LineBreak } from "src/editor/model/documentNode/node/lineBreak"; import { List, ListItem } from "src/editor/model/documentNode/node/list"; import { MathBlock } from "src/editor/model/documentNode/node/mathBlock"; import { Paragraph } from "src/editor/model/documentNode/node/paragraph"; -import { Space } from "src/editor/model/documentNode/node/space"; import { Text } from "src/editor/model/documentNode/node/text"; import { EditorSchema, TOP_NODE_NAME } from "src/editor/model/schema"; import { IInstantiationService } from "src/platform/instantiation/common/instantiation"; @@ -57,7 +57,6 @@ export class DocumentNodeProvider { */ register: () => { // nodes - provider.registerNode(instantiationService.createInstance(Space)); provider.registerNode(instantiationService.createInstance(Text)); provider.registerNode(instantiationService.createInstance(Escape)); provider.registerNode(instantiationService.createInstance(Paragraph)); @@ -105,11 +104,11 @@ export class DocumentNodeProvider { this._marks.set(mark.name, mark); } - public getNode(name: TokenEnum | string): DocumentNode | undefined { + public getNode(name: TokenEnum | string): DocumentNode | undefined { return this._nodes.get(name); } - public getMark(name: MarkEnum | string): DocumentMark | undefined { + public getMark(name: MarkEnum | string): DocumentMark | undefined { return this._marks.get(name); } diff --git a/src/editor/model/documentNode/mark/mathInline.ts b/src/editor/model/documentNode/mark/mathInline.ts index 4fbc8838f..732d27285 100644 --- a/src/editor/model/documentNode/mark/mathInline.ts +++ b/src/editor/model/documentNode/mark/mathInline.ts @@ -63,6 +63,9 @@ export class MathInline extends DocumentNode { group: 'inline', inline: true, content: undefined, + draggable: true, + selectable: true, + atom: true, attrs: { text: { default: '' }, }, @@ -76,7 +79,7 @@ export class MathInline extends DocumentNode { }; } - public parseFromToken(state: IDocumentParseState, status: IParseTokenStatus): void { + public parseFromToken(state: IDocumentParseState, status: IParseTokenStatus): void { const token = status.token; state.activateNode(this.ctor, status, { attrs: { diff --git a/src/editor/model/documentNode/node/blockquote.ts b/src/editor/model/documentNode/node/blockquote.ts index 26938f2be..d1589eefc 100644 --- a/src/editor/model/documentNode/node/blockquote.ts +++ b/src/editor/model/documentNode/node/blockquote.ts @@ -2,12 +2,10 @@ import { TokenEnum } from "src/editor/common/markdown"; import { EditorTokens } from "src/editor/common/model"; import { GetProseAttrs, ProseNode, ProseNodeSpec } from "src/editor/common/proseMirror"; import { DocumentNode, IParseTokenStatus } from "src/editor/model/documentNode/documentNode"; -import { createDomOutputFromOptions } from "../../schema"; import { IDocumentParseState } from "src/editor/model/parser"; import { IMarkdownSerializerState } from "src/editor/model/serializer"; -import { Strings } from "src/base/common/utilities/string"; -import { assert } from "src/base/common/utilities/panic"; import { memoize } from "src/base/common/memoization"; +import { createDomOutputFromOptions } from "src/editor/model/schema"; export type BlockquoteAttrs = { // noop for now @@ -29,7 +27,6 @@ export class Blockquote extends DocumentNode { content: 'block+', defining: true, attrs: { - delimiters: { default: [] }, } satisfies GetProseAttrs, toDOM: () => { return createDomOutputFromOptions({ @@ -43,10 +40,9 @@ export class Blockquote extends DocumentNode { public parseFromToken(state: IDocumentParseState, status: IParseTokenStatus): void { const { token } = status; - const delimiters = this.__getDelimitersFromRaw(state, token.raw); state.activateNode(this.ctor, status, { - attrs: { delimiters: delimiters } + attrs: {} }); if (token.tokens) { @@ -57,64 +53,6 @@ export class Blockquote extends DocumentNode { } public serializer = (state: IMarkdownSerializerState, node: ProseNode) => { - const delimiters = node.attrs['delimiters'] as string[]; - state.serializeDelimitedBlock(delimiters, node); + state.wrapBlock(node, () => state.serializeBlock(node), "> "); }; - - // [private methods] - - private __getDelimitersFromRaw(state: IDocumentParseState, raw: string): string[] { - const delimiters: string[] = []; - const activeNode = assert(state.getActiveNode()); - const outMost = (activeNode.name !== TokenEnum.Blockquote); - - for (const { line, isLastLine } of Strings.iterateLines(raw)) { - if (line === '') { - continue; - } - - /** - * For cases like '> p1\n
', the second line '
' is - * considered part of the blockquote. This line could logically - * contain any text. - * - * If the last line includes the character `>`, it can interfere with - * the algorithm's parsing. To avoid this, special handling is required. - */ - if (isLastLine && /^ {0,3}>/.test(line) === false) { - delimiters.push(''); - continue; - } - - let delimiter = ''; - let startIndex = 0; - - // edge case: append the spaces before the first level '>' to the delimiter - if (outMost) { - const { index: firstLevelIdx, str: spaces } = Strings.substringUntilChar2(line, '>', 0); - delimiter += spaces; - delimiter += '>'; - startIndex = firstLevelIdx + 1; - } else { - delimiter = '>'; - startIndex = 1; - } - - // normal case: append the spaces after the '>' to the delimiter - const { index: nextCharIdx, char: nextChar } = Strings.firstNonSpaceChar(line, startIndex); - - // empty line, skip it. - if (nextChar === '') { - delimiters.push(delimiter); - continue; - } - - // we append the spaces before the nextChar - const space = line.slice(startIndex, nextCharIdx); - delimiter += space; - delimiters.push(delimiter); - } - - return delimiters; - } } \ No newline at end of file diff --git a/src/editor/model/documentNode/node/codeBlock/codeBlock.ts b/src/editor/model/documentNode/node/codeBlock/codeBlock.ts index c6972b674..4f6b8ebcc 100644 --- a/src/editor/model/documentNode/node/codeBlock/codeBlock.ts +++ b/src/editor/model/documentNode/node/codeBlock/codeBlock.ts @@ -1,7 +1,5 @@ import 'src/editor/model/documentNode/node/codeBlock/codeBlock.scss'; import { memoize } from "src/base/common/memoization"; -import { Strings } from "src/base/common/utilities/string"; -import { isString } from "src/base/common/utilities/type"; import { CodeEditorView, minimalSetup } from "src/editor/common/codeMirror"; import { TokenEnum } from "src/editor/common/markdown"; import { EditorTokens } from "src/editor/common/model"; @@ -17,12 +15,6 @@ export type CodeBlockAttrs = { readonly view?: CodeEditorView; }; -const enum CodeBlockFenceType { - WaveLine = 'waveLine', - backTick = 'backTick', - Indent = 'indent', -} - // region - CodeBlock /** @@ -41,18 +33,13 @@ export class CodeBlock extends DocumentNode { public getSchema(): ProseNodeSpec { return { group: 'block', - content: 'text*', marks: '', // disallow any marks code: true, defining: true, + content: 'text*', attrs: >{ view: { default: CodeBlock.createView('') }, lang: { default: '' }, - fenceType: { default: CodeBlockFenceType.WaveLine }, - fenceLength: { default: 3 }, - hasEndFence: { default: true }, - hasSingleEndOfLine: { default: false }, - mismatchFence: { default: undefined }, }, toDOM: (node) => { const { view, lang } = node.attrs; @@ -72,22 +59,11 @@ export class CodeBlock extends DocumentNode { public parseFromToken(state: IDocumentParseState, status: IParseTokenStatus): void { const { token } = status; - const fenceResult = __resolveFenceStatus(token); - const attrs = { view: CodeBlock.createView(token.text), lang: token.lang, text: token.text, - fenceType: fenceResult.type, - fenceLength: fenceResult.length, }; - - if (fenceResult.type !== CodeBlockFenceType.Indent) { - attrs['hasEndFence'] = fenceResult.hasEndFence; - attrs['hasSingleEndOfLine'] = fenceResult.hasSingleEndOfLine; - attrs['mismatchFence'] = fenceResult.mismatchFence; - } - state.activateNode(this.ctor, status, { attrs: attrs }); state.deactivateNode(); } @@ -95,44 +71,20 @@ export class CodeBlock extends DocumentNode { public serializer = (state: IMarkdownSerializerState, node: ProseNode, parent: ProseNode, index: number) => { const lang = node.attrs['lang'] as string; const view = node.attrs['view'] as CodeEditorView; - const fenceType = node.attrs['fenceType'] as CodeBlockFenceType; - const fenceLength = node.attrs['fenceLength'] as number; - const hasEndFence = node.attrs['hasEndFence'] as boolean; - const hasSingleEndOfLine = node.attrs['hasSingleEndOfLine'] as boolean; - const mismatchFence = node.attrs['mismatchFence'] as string | undefined; - // when the ending fence mismatched, it should be serialized as plain text. - const textContent = - view.state.doc.toString() - + (isString(mismatchFence) ? mismatchFence : ''); - - // indent type has no fences - if (fenceType === CodeBlockFenceType.Indent) { - const indent = ' '; - state.setDefaultDelimiter(indent); + const textContent = view.state.doc.toString(); + + const fence = '`'.repeat(3); + state.write(fence + lang); + + if (textContent.length > 0) { + state.text('\n'); state.text(textContent, false); - state.setDefaultDelimiter(''); - } - // normal fence - else { - const fenceChar = fenceType === CodeBlockFenceType.WaveLine ? '~' : '`'; - const beginFence = fenceChar.repeat(fenceLength); - state.write(beginFence + lang); - - if (textContent.length > 0) { - state.text('\n'); - state.text(textContent, false); - } - - if (hasEndFence) { - state.write('\n'); - state.write(beginFence); - } - - if (hasSingleEndOfLine) { - state.text('\n', false); - } } + + state.write('\n'); + state.write(fence); + state.closeBlock(node); }; @@ -151,7 +103,7 @@ export class CodeBlock extends DocumentNode { const langLabel = document.createElement('span'); langLabel.classList.add('code-lang'); - langLabel.textContent = lang || 'Unknown'; + langLabel.textContent = lang; const copyButton = document.createElement('button'); copyButton.classList.add('code-copy'); @@ -168,69 +120,3 @@ export class CodeBlock extends DocumentNode { return header; } } - - -function __resolveFenceStatus(token: EditorTokens.CodeBlock): - { - type: CodeBlockFenceType.Indent, - length: undefined - } | { - type: CodeBlockFenceType.backTick | CodeBlockFenceType.WaveLine, - length: number, - hasEndFence: boolean, - hasSingleEndOfLine: boolean, - mismatchFence?: string - } -{ - if (token.codeBlockStyle === 'indented') { - return { type: CodeBlockFenceType.Indent, length: undefined }; - } - - const fenceRegex = /^(`{3,}|~{3,})[a-zA-Z0-9]*\s*$/gm; - const match = token.raw.match(fenceRegex); - - // default - if (!match) { - return { type: CodeBlockFenceType.backTick, length: 3, hasEndFence: true, hasSingleEndOfLine: false }; - } - - const hasSingleEndOfLine = token.raw.at(-1) === '\n'; - const beginFenceMatch = match[0]; - const endFenceMatch = match[1]; - - const beginFirstChar = beginFenceMatch.at(0)!; - const fenceType = (beginFirstChar === '~') ? CodeBlockFenceType.WaveLine : CodeBlockFenceType.backTick; - const beginFence = Strings.substringUntilNotChar(beginFenceMatch, beginFirstChar); - - if (!endFenceMatch) { - return { - type: fenceType, - length: beginFence.length, - hasEndFence: false, - hasSingleEndOfLine: hasSingleEndOfLine, - }; - } - - // has end fence, we check is they are the type and equal length - const endFirstChar = endFenceMatch.at(0)!; - const endFence = Strings.substringUntilNotChar(endFenceMatch, endFirstChar); - - // doesn't match the same fence type or length, indicates no end fence. - if (beginFirstChar !== endFirstChar || beginFence.length !== endFence.length) { - return { - type: fenceType, - length: beginFence.length, - hasEndFence: false, - hasSingleEndOfLine: hasSingleEndOfLine, - mismatchFence: endFence, - }; - } - - // fences match - return { - type: (beginFence.at(0) === '~') ? CodeBlockFenceType.WaveLine : CodeBlockFenceType.backTick, - length: beginFence.length, - hasEndFence: true, - hasSingleEndOfLine: hasSingleEndOfLine, - }; -} \ No newline at end of file diff --git a/src/editor/model/documentNode/node/list.ts b/src/editor/model/documentNode/node/list.ts index 604fa9660..d86d34179 100644 --- a/src/editor/model/documentNode/node/list.ts +++ b/src/editor/model/documentNode/node/list.ts @@ -1,5 +1,5 @@ import { memoize } from "src/base/common/memoization"; -import { Strings } from "src/base/common/utilities/string"; +import { isString } from "src/base/common/utilities/type"; import { TokenEnum } from "src/editor/common/markdown"; import { EditorTokens } from "src/editor/common/model"; import { GetProseAttrs, ProseNode, ProseNodeSpec } from "src/editor/common/proseMirror"; @@ -17,6 +17,13 @@ export type ListAttrs = { * @default false */ readonly tight?: boolean; + + /** + * If {@link ordered=true}, this indicates the starting number of an ordered + * list. + * @default 1 + */ + readonly start?: number; }; /** @@ -38,7 +45,6 @@ export class List extends DocumentNode { ordered: { default: false }, tight: { default: false }, start: { default: 1, }, - bullet: { default: '*', } }, toDOM(node) { const { ordered, tight } = node.attrs; @@ -55,8 +61,7 @@ export class List extends DocumentNode { attrs: { ordered: token.ordered, tight: !token.loose, - start: token.start, - bullet: Strings.firstNonSpaceChar(token.raw, 0).char, + start: isString(token.start) ? 1 : token.start, } }); state.parseTokens(status.level + 1, token.items, token); @@ -65,12 +70,12 @@ export class List extends DocumentNode { public serializer = (state: IMarkdownSerializerState, node: ProseNode) => { const isOrdered = node.attrs['ordered'] as boolean; - const bullet = node.attrs['bullet'] as string; - const start = node.attrs['start'] as string; + const tight = node.attrs['tight'] as boolean; + const start = node.attrs['start'] as number; // un-ordered if (isOrdered === false) { - state.serializeList(node, ' ', () => (bullet + ' ')); + state.serializeList(node, ' ', () => ('* ')); } // ordered else { diff --git a/src/editor/model/documentNode/node/mathBlock.ts b/src/editor/model/documentNode/node/mathBlock.ts index 160edcb06..506163cb8 100644 --- a/src/editor/model/documentNode/node/mathBlock.ts +++ b/src/editor/model/documentNode/node/mathBlock.ts @@ -60,6 +60,7 @@ export class MathBlock extends DocumentNode { content: undefined, draggable: true, selectable: true, + atom: true, attrs: { text: { default: '' }, } satisfies GetProseAttrs, diff --git a/src/editor/model/documentNode/node/paragraph.ts b/src/editor/model/documentNode/node/paragraph.ts index 235050761..aca92beee 100644 --- a/src/editor/model/documentNode/node/paragraph.ts +++ b/src/editor/model/documentNode/node/paragraph.ts @@ -47,7 +47,13 @@ export class Paragraph extends DocumentNode { } public serializer = (state: IMarkdownSerializerState, node: ProseNode, parent: ProseNode, index: number) => { - state.serializeInline(node); + if (node.childCount > 0) { + state.serializeInline(node); + } + // If empty paragraph, simulate as a `space`. + else { + state.write('\n'); + } state.closeBlock(node); }; } \ No newline at end of file diff --git a/src/editor/model/documentNode/node/space.ts b/src/editor/model/documentNode/node/space.ts deleted file mode 100644 index dd02e4f4d..000000000 --- a/src/editor/model/documentNode/node/space.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { TokenEnum } from "src/editor/common/markdown"; -import { EditorTokens } from "src/editor/common/model"; -import { ProseNode, ProseNodeSpec } from "src/editor/common/proseMirror"; -import { DocumentNode, IParseTokenStatus } from "src/editor/model/documentNode/documentNode"; -import { createDomOutputFromOptions } from "../../schema"; -import { IDocumentParseState } from "src/editor/model/parser"; -import { IMarkdownSerializerState } from "src/editor/model/serializer"; -import { memoize } from "src/base/common/memoization"; - -/** - * @class An empty space block. Represented in the DOM as an empty `

` - * element. - */ -export class Space extends DocumentNode { - - constructor() { - super(TokenEnum.Space); - } - - @memoize - public getSchema(): ProseNodeSpec { - return { - group: 'block', - content: 'inline*', - toDOM: () => { - return createDomOutputFromOptions({ - type: 'node', - tagName: 'p', - editable: true, - }); - } - }; - } - - public parseFromToken(state: IDocumentParseState, status: IParseTokenStatus): void { - state.activateNode(this.ctor, status, {}); - state.deactivateNode(); - } - - public serializer = (state: IMarkdownSerializerState, node: ProseNode, parent: ProseNode, index: number) => { - state.text(''); - state.closeBlock(node); - }; -} \ No newline at end of file diff --git a/src/editor/model/editorModel.ts b/src/editor/model/editorModel.ts index 260fbf8c1..89acffd49 100644 --- a/src/editor/model/editorModel.ts +++ b/src/editor/model/editorModel.ts @@ -2,37 +2,21 @@ import { Disposable } from "src/base/common/dispose"; import { Emitter } from "src/base/common/event"; import { DataBuffer } from "src/base/common/files/buffer"; import { URI } from "src/base/common/files/uri"; -import { defaultLog, ILogService } from "src/base/common/logger"; -import { AsyncResult, ok } from "src/base/common/result"; +import { ILogService } from "src/base/common/logger"; +import { AsyncResult, ok, Result } from "src/base/common/result"; +import { Blocker } from "src/base/common/utilities/async"; import { assert } from "src/base/common/utilities/panic"; import { EditorOptionsType } from "src/editor/common/editorConfiguration"; -import { IEditorExtension } from "src/editor/common/editorExtension"; -import { IEditorModel } from "src/editor/common/model"; +import { EditorToken, IEditorModel, IModelBuildData, IPieceTable } from "src/editor/common/model"; import { IEditorPosition } from "src/editor/common/position"; -import { ProseEditorState, ProseNode, ProseTransaction } from "src/editor/common/proseMirror"; import { IMarkdownLexer, IMarkdownLexerOptions, MarkdownLexer } from "src/editor/model/markdownLexer"; -import { DocumentNodeProvider } from "src/editor/model/documentNode/documentNodeProvider"; -import { DocumentParser, IDocumentParser } from "src/editor/model/parser"; -import { buildSchema, EditorSchema } from "src/editor/model/schema"; -import { MarkdownSerializer } from "src/editor/model/serializer"; +import { TextBufferBuilder } from "src/editor/model/textBuffer/textBufferBuilder"; import { IFileService } from "src/platform/files/common/fileService"; -import { history } from "prosemirror-history"; -import { IOnDidContentChangeEvent } from "src/editor/view/proseEventBroadcaster"; -import { IInstantiationService } from "src/platform/instantiation/common/instantiation"; export class EditorModel extends Disposable implements IEditorModel { // [events] - private readonly _onDidBuild = this.__register(new Emitter({ onFire: () => this.setDirty(false) })); - public readonly onDidBuild = this._onDidBuild.registerListener; - - private readonly _onTransaction = this.__register(new Emitter({ onFire: () => this.setDirty(true) })); - public readonly onTransaction = this._onTransaction.registerListener; - - private readonly _onDidStateChange = this.__register(new Emitter({ onFire: () => this.setDirty(true) })); - public readonly onDidStateChange = this._onDidStateChange.registerListener; - private readonly _onDidSave = this.__register(new Emitter({ onFire: () => this.setDirty(false) })); public readonly onDidSave = this._onDidSave.registerListener; @@ -44,15 +28,10 @@ export class EditorModel extends Disposable implements IEditorModel { // [fields] - private readonly _options: EditorOptionsType; // The configuration of the editor - private readonly _source: URI; // The source file the model is about to read and parse. - private readonly _schema: EditorSchema; // An object that defines how a view is organized. - private readonly _lexer: IMarkdownLexer; // Responsible for parsing the raw text into tokens. - private readonly _nodeProvider: DocumentNodeProvider; // Stores all the legal document node. - private readonly _docParser: IDocumentParser; // Parser that parses the given token into a legal view based on the schema. - private readonly _docSerializer: MarkdownSerializer; // Serializer that transforms the prosemirror document back to raw string. - - private _editorState?: ProseEditorState; // A reference to the prosemirror state. + private readonly _options: EditorOptionsType; // The configuration of the editor + private readonly _source: URI; // The reading target. + private readonly _lexer: IMarkdownLexer; // Responsible for parsing the raw text into tokens. + private _textBuffer?: IPieceTable; // The SSOT (Single Source of Truth) for the text. private _dirty: boolean; // Indicates if the file has unsaved changes. Modify this through `this.setDirty()` // [constructor] @@ -60,103 +39,74 @@ export class EditorModel extends Disposable implements IEditorModel { constructor( source: URI, options: EditorOptionsType, - @IFileService private readonly fileService: IFileService, @ILogService private readonly logService: ILogService, - @IInstantiationService instantiationService: IInstantiationService, + @IFileService private readonly fileService: IFileService, ) { super(); - this._source = source; this._options = options; + this._source = source; this._dirty = false; - this._lexer = new MarkdownLexer(this.__initLexerOptions(options)); - - this._nodeProvider = DocumentNodeProvider.create(instantiationService).register(); - this._schema = buildSchema(this._nodeProvider); - this._docParser = this.__register(new DocumentParser(this._schema, this._nodeProvider, /* options */)); - this.__register(this._docParser.onLog(event => defaultLog(logService, event.level, 'EditorView', event.message, event.error, event.additional))); - this._docSerializer = new MarkdownSerializer(this._nodeProvider, { strict: true, escapeExtraCharacters: undefined, }); - logService.debug('EditorModel', 'Constructed'); } // [getter / setter] get source(): URI { return this._source; } - get schema(): EditorSchema { return this._schema; } - get state(): ProseEditorState | undefined { return this._editorState; } get dirty(): boolean { return this._dirty; } + get textBuffer(): IPieceTable { return assert(this._textBuffer, 'Model not built yet.'); } // [public methods] - public build(extensions: IEditorExtension[]): AsyncResult { - return this.__buildModel(this._source, extensions) - .map(state => { - this._editorState = state; - this._onDidBuild.fire(state); - return state; - }); - } - - public insertAt(textOffset: number, text: string): void { - const state = assert(this._editorState); - const document = this.__tokenizeAndParse(text); - const newTr = state.tr.insert(textOffset, document); - this._onTransaction.fire(newTr); - } - - public deleteAt(textOffset: number, length: number): void { - const state = assert(this._editorState); - const newTr = state.tr.delete(textOffset, textOffset + length); - this._onTransaction.fire(newTr); + public build(): AsyncResult { + this.logService.debug('EditorModel', `Start building at: ${URI.toString(this.source)}`); + return this.__buildTextBuffer(this.source) + .andThen(textBuffer => this.__tokenizeBuffer(textBuffer)) + .andThen(tokens => ok({ tokens: tokens, })); } public getContent(): string[] { - const state = assert(this._editorState); - const raw = this._docSerializer.serialize(state.doc); - return raw.split('\n'); // TODO + return this.textBuffer.getContent(); } public getRawContent(): string { - const state = assert(this._editorState); - const raw = this._docSerializer.serialize(state.doc); - return raw; // TODO + return this.textBuffer.getRawContent(); } public getLine(lineNumber: number): string { - return ''; // TODO + return this.textBuffer.getLine(lineNumber); } public getRawLine(lineNumber: number): string { - return ''; // TODO + return this.textBuffer.getRawLine(lineNumber); } public getLineLength(lineNumber: number): number { - return -1; // TODO + return this.textBuffer.getLineLength(lineNumber); } public getRawLineLength(lineNumber: number): number { - return -1; // TODO + return this.textBuffer.getRawLineLength(lineNumber); } public getLineCount(): number { - return -1; // TODO + return this.textBuffer.getLineCount(); } public getOffsetAt(lineNumber: number, lineOffset: number): number { - return -1; // TODO + return this.textBuffer.getOffsetAt(lineNumber, lineOffset); } public getPositionAt(textOffset: number): IEditorPosition { - return undefined!; // TODO + return this.textBuffer.getPositionAt(textOffset); } public getCharCodeByOffset(textOffset: number): number { - return -1; // TODO + return this.textBuffer.getCharcodeByOffset(textOffset); } public getCharCodeByLine(lineNumber: number, lineOffset: number): number { - return -1; // TODO + return this.textBuffer.getCharcodeByLine(lineNumber, lineOffset); } public setDirty(value: boolean): void { @@ -172,8 +122,7 @@ export class EditorModel extends Disposable implements IEditorModel { return AsyncResult.ok(); } - const state = assert(this._editorState); - const serialized = this._docSerializer.serialize(state.doc); + const serialized = this.getRawContent(); const buffer = DataBuffer.fromString(serialized); return this.fileService.writeFile(this._source, buffer, { create: true, overwrite: true, unlock: false }) @@ -187,77 +136,44 @@ export class EditorModel extends Disposable implements IEditorModel { }); } - public getRegisteredDocumentNodes(): string[] { - return this._nodeProvider.getRegisteredNodes().map(each => each.name); - } - - public getRegisteredDocumentNodesBlock(): string[] { - const nodes = this._nodeProvider.getRegisteredNodes(); - const blocks: string[] = []; - for (const node of nodes) { - if (!node.getSchema().inline) { - blocks.push(node.name); - } - } - return blocks; - } - - public getRegisteredDocumentNodesInline(): string[] { - const nodes = this._nodeProvider.getRegisteredNodes(); - const blocks: string[] = []; - for (const node of nodes) { - if (node.getSchema().inline === true) { - blocks.push(node.name); - } - } - return blocks; - } - // [private methods] - public __onDidStateChange(event: IOnDidContentChangeEvent): void { - const newState = event.view.state; - this._editorState = newState; - this._onDidStateChange.fire(); - } - - private __tokenizeAndParse(raw: string): ProseNode { - const tokens = this._lexer.lex(raw); - console.log(tokens); // TEST - - const doc = this._docParser.parse(tokens); - console.log(doc); // TEST - - // console.log(this._docSerializer.serialize(doc)); // TEST - return doc; - } - private __initLexerOptions(options: EditorOptionsType): IMarkdownLexerOptions { return { baseURI: options.baseURI.value, }; } - private __buildModel(source: URI, extensions: IEditorExtension[]): AsyncResult { - this.logService.debug('EditorModel', `Start building at: ${URI.toString(source)}`); - - return this.__readFileRaw(source) - .andThen(raw => { - const document = this.__tokenizeAndParse(raw); - const state = ProseEditorState.create({ - schema: this._schema, - doc: document, - plugins: [ - ...extensions.map(extension => extension.getViewExtension()), - history({ depth: 500 }), - ], + private __buildTextBuffer(source: URI): AsyncResult { + return this.fileService + .readFileStream(source, {}) + .andThen(async ready => { + const builder = new TextBufferBuilder(); + const blocker = new Blocker(); + const stream = ready.flow(); + + stream.on('data', data => { + builder.receive(data.toString()); + }); + + stream.on('end', () => { + stream.destroy(); + builder.build(); + const textBuffer = builder.create(); + blocker.resolve(textBuffer); + }); + + stream.on('error', (error) => { + stream.destroy(); + blocker.reject(error); }); - return ok(state); + + this._textBuffer = await blocker.waiting(); + return this._textBuffer; }); } - private __readFileRaw(source: URI): AsyncResult { - return this.fileService.readFile(source, {}) - .map(buffer => buffer.toString()); + private __tokenizeBuffer(buffer: IPieceTable): Result { + return ok(this._lexer.lex(buffer.getRawContent())); } } \ No newline at end of file diff --git a/src/editor/model/parser.ts b/src/editor/model/parser.ts index f85f4df49..1df2482f5 100644 --- a/src/editor/model/parser.ts +++ b/src/editor/model/parser.ts @@ -1,4 +1,4 @@ -import { Disposable, IDisposable } from "src/base/common/dispose"; +import { Disposable } from "src/base/common/dispose"; import { Emitter, Register } from "src/base/common/event"; import { ILogEvent, LogLevel } from "src/base/common/logger"; import { Stack } from "src/base/common/structures/stack"; @@ -45,6 +45,15 @@ export interface IDocumentParser { * @param type The token type. */ isTokenIgnored(type: TokenEnum | MarkEnum | string): boolean; + + /** + * @description Register a converter that transfroms a token into another + * one. + * @param from The input token type name. + * @param converter The converter function. + */ + registerMapToken(from: string, converter: (from: EditorToken) => EditorToken): void; + mapToken(token: EditorToken): EditorToken; } /** @@ -57,6 +66,7 @@ export class DocumentParser extends Disposable implements IDocumentParser { private readonly _state: DocumentParseState; private readonly _ignored: Set; + private readonly _mapping: Map EditorToken>; // [event] @@ -70,6 +80,7 @@ export class DocumentParser extends Disposable implements IDocumentParser { ) { super(); this._ignored = new Set(); + this._mapping = new Map(); this._state = this.__register(new DocumentParseState(this, schema, nodeProvider)); this.onLog = this._state.onLog; } @@ -83,6 +94,14 @@ export class DocumentParser extends Disposable implements IDocumentParser { return documentRoot; } + public registerMapToken(from: string, converter: (from: EditorToken) => EditorToken): void { + this._mapping.set(from, converter); + } + + public mapToken(token: EditorToken): EditorToken { + return this._mapping.get(token.type)?.(token) ?? token; + } + public ignoreToken(type: TokenEnum | MarkEnum | string, ignore?: boolean): void { // toggling @@ -297,7 +316,7 @@ class DocumentParseState extends Disposable implements IDocumentParseState { public parseTokens(level: number, tokens: EditorToken[], parent: EditorToken | null): void { for (let i = 0; i < tokens.length; i++) { - const token = tokens[i]!; + const token = this._parser.mapToken(tokens[i]!); const name = token.type; if (this._parser.isTokenIgnored(name)) { diff --git a/src/editor/model/serializer.ts b/src/editor/model/serializer.ts index 825716ad0..a37bd71bb 100644 --- a/src/editor/model/serializer.ts +++ b/src/editor/model/serializer.ts @@ -147,6 +147,7 @@ export interface IMarkdownSerializerState { serializeBlock(parent: ProseNode): void; serializeInline(parent: ProseNode, fromBlockStart?: boolean): void; serializeList(node: ProseNode, delimiter: string, firstDelimiter: (index: number) => string): void; + wrapBlock(node: ProseNode, f: () => void, delimiter: string, firstDelimiter?: string): void; write(content?: string): void; text(text: string, escape?: boolean): void; escaping(str: string, startOfLine?: boolean): string; @@ -155,6 +156,7 @@ export interface IMarkdownSerializerState { serializeDelimitedBlock(delimiters: string[], parent: ProseNode): void; setDelimiterIncrements(delimiters: string[]): void; setDefaultDelimiter(delimiter: string): void; + incrementDefaultDelimiter(increment: string): string; } /** @@ -341,6 +343,10 @@ class MarkdownSerializerState implements IMarkdownSerializerState { this._delimiter.setDefault(delimiter); } + public incrementDefaultDelimiter(increment: string): string { + return this._delimiter.incrementDefault(increment); + } + // [private methods] /** diff --git a/src/editor/view/editorView.ts b/src/editor/view/editorView.ts index f38dc8ef6..bcd3cc8d4 100644 --- a/src/editor/view/editorView.ts +++ b/src/editor/view/editorView.ts @@ -8,6 +8,9 @@ import { IEditorModel } from 'src/editor/common/model'; import { ProseEditorState } from 'src/editor/common/proseMirror'; import { IEditorViewModel } from 'src/editor/common/viewModel'; import { RichTextView } from 'src/editor/view/richTextView'; +import { IEditorInputEmulator } from 'src/editor/view/inputEmulator'; +import { IInstantiationService, InstantiationService } from 'src/platform/instantiation/common/instantiation'; +import { KeyCode } from 'src/base/common/keyboard'; export class ViewContext { constructor( @@ -18,6 +21,8 @@ export class ViewContext { ) {} } +// region - EditorView + export class EditorView extends Disposable implements IEditorView { // [fields] @@ -83,8 +88,10 @@ export class EditorView extends Disposable implements IEditorView { viewModel: IEditorViewModel, initState: ProseEditorState, extensions: IEditorExtension[], + inputEmulator: IEditorInputEmulator, options: EditorOptionsType, @ILogService logService: ILogService, + @IInstantiationService instantiationService: IInstantiationService, ) { super(); @@ -97,12 +104,11 @@ export class EditorView extends Disposable implements IEditorView { // the centre that integrates the editor-related functionalities const editorElement = document.createElement('div'); editorElement.className = 'editor-container'; - this._view = this.__register(new RichTextView(editorElement, this._container, context, initState, extensions)); + this._view = this.__register(instantiationService.createInstance(RichTextView, editorElement, this._container, context, initState, extensions, inputEmulator)); - // forward: start listening events from model - this.__registerEventFromModel(); - this.__registerEventToModel(); - + // forward: start listening events from view model + this.__registerEventFromViewModel(); + this.__registerEventToViewModel(); // render this._container.appendChild(editorElement); @@ -118,6 +124,11 @@ export class EditorView extends Disposable implements IEditorView { return this._view; } + public override dispose(): void { + super.dispose(); + this._container.remove(); + } + public isEditable(): boolean { return this._view.isEditable(); } @@ -138,35 +149,34 @@ export class EditorView extends Disposable implements IEditorView { return this._view.isDestroyed(); } - public override dispose(): void { - super.dispose(); - this._container.remove(); + public type(text: string, from?: number, to?: number): void { + this._view.type(text, from, to); + } + + public keydown(code: KeyCode, alt?: boolean, shift?: boolean, ctrl?: boolean, meta?: boolean): void { + this._view.keydown(code, alt, shift, ctrl, meta); } // [private helper methods] - private __registerEventFromModel(): void { + private __registerEventFromViewModel(): void { const viewModel = this._ctx.viewModel; - this.__register(viewModel.onDidBuild(newState => { - this._view.render(newState); - })); - - this.__register(viewModel.onDidChangeModelState(tr => { + this.__register(viewModel.onDidContentChange(event => { + const { tr } = event; this._view.internalView.dispatch(tr); })); } - private __registerEventToModel(): void { + private __registerEventToViewModel(): void { const viewModel = this._ctx.viewModel; /** * Since in Prosemirror whenever the content of the document changes, * the old {@link ProseEditorState} is no longer valid. Therefore we - * need to inform {@link IEditorModel} to update its state. + * need to inform view model to update its state. */ - this.__register(this._view.onDidContentChange(e => { - viewModel.onDidViewContentChange(e); - })); + this.__register(this._view.onDidContentChange(e => viewModel.updateViewChange(e))); + this.__register(this._view.onDidSelectionChange(e => viewModel.updateViewChange(e))); } } \ No newline at end of file diff --git a/src/editor/view/inputEmulator.ts b/src/editor/view/inputEmulator.ts new file mode 100644 index 000000000..9e91edc0f --- /dev/null +++ b/src/editor/view/inputEmulator.ts @@ -0,0 +1,22 @@ +import { IOnKeydownEvent, IOnTextInputEvent } from "src/editor/view/proseEventBroadcaster"; + + +/** + * A delegate to simulate certain user's input programmatically. + * + * Consider the following difference: + * 1. The workflow of user input might be: + * Keyboard Event -> Event Broadcasting -> Extension Handing -> Document Updates + * 2. The workflow of program input: + * function call -> Document Updates + * + * This is a problem for program input, the event is not broadcasting, so the + * extensions and others cannot be notified. + * + * This why we need a delegate to emulate related-functions by also broadcasting + * them. + */ +export interface IEditorInputEmulator { + type(event: IOnTextInputEvent): void; + keydown(event: IOnKeydownEvent): void; +} \ No newline at end of file diff --git a/src/editor/view/media/richTextView.scss b/src/editor/view/media/richTextView.scss index b5494a16f..a13ea649e 100644 --- a/src/editor/view/media/richTextView.scss +++ b/src/editor/view/media/richTextView.scss @@ -103,7 +103,6 @@ & img { max-width: 100%; height: auto; - display: block; margin: 4px 0; } diff --git a/src/editor/view/richTextView.ts b/src/editor/view/richTextView.ts index e3fc1513e..c31275c22 100644 --- a/src/editor/view/richTextView.ts +++ b/src/editor/view/richTextView.ts @@ -4,6 +4,9 @@ import { ProseEditorState, ProseEditorView } from "src/editor/common/proseMirror import { ViewContext } from "src/editor/view/editorView"; import { EditorViewProxy, IEditorViewProxy } from "src/editor/view/editorViewProxy"; import { IEditorExtension } from 'src/editor/common/editorExtension'; +import { IEditorInputEmulator } from 'src/editor/view/inputEmulator'; +import { IOnTextInputEvent } from 'src/editor/view/proseEventBroadcaster'; +import { createStandardKeyboardEvent, Keyboard, KeyCode } from 'src/base/common/keyboard'; /** * An interface only for {@link RichTextView}. @@ -33,6 +36,8 @@ export class RichTextView extends EditorViewProxy implements IRichTextView { protected readonly _container: HTMLElement; protected readonly _editorContainer: HTMLElement; protected readonly _context: ViewContext; + + private readonly _inputEmulator: IEditorInputEmulator; // [constructor] @@ -42,6 +47,7 @@ export class RichTextView extends EditorViewProxy implements IRichTextView { context: ViewContext, editorState: ProseEditorState, extensions: IEditorExtension[], + inputEmulator: IEditorInputEmulator, ) { overlayContainer.classList.add('editor-base', 'rich-text'); @@ -59,12 +65,94 @@ export class RichTextView extends EditorViewProxy implements IRichTextView { this._editorContainer = overlayContainer; this._container = domEventElement; this._context = context; - } + this._inputEmulator = inputEmulator; - // [public methods] + // send latest data back to viewModel after initialization + context.viewModel.updateViewChange({ + view: view, + transaction: view.state.tr, + }); + } + // [getter] + get container() { return this._container; } get overlayContainer() { return this._editorContainer; } + + // [public methods] + + public type(text: string, from?: number, to?: number): void { + // TODO: perf + for (const c of text) { + this.__type(c, from, to); + } + } + + public keydown(code: KeyCode, alt?: boolean, shift?: boolean, ctrl?: boolean, meta?: boolean): void { + const key = Keyboard.toString(code); + const keyCode = Keyboard.toKeyCodeBrowser(code); + + const browserEvent = new KeyboardEvent('keydown', { + key: key, + code: key, + + keyCode: keyCode, + charCode: keyCode, + which: keyCode, + + altKey: alt, + shiftKey: shift, + ctrlKey: ctrl, + metaKey: meta, + + bubbles: true, + cancelable: true, + }); + + this._inputEmulator.keydown({ + view: this._view, + event: createStandardKeyboardEvent(browserEvent), + preventDefault: () => {}, + }); + } // [private helper methods] + + private __type(text: string, from?: number, to?: number): void { + // case 0: typing nothing. + if (text === '') { + return; + } + + // case 1: typing '\n', we emulate pressing `enter` on keydown. + if (text === '\n') { + this.keydown(KeyCode.Enter); + return; + } + + // general case + const { state } = this._view; + from ??= state.selection.from; + to ??= state.selection.to; + + let prevented = false; + const event: IOnTextInputEvent = { + view: this._view, + text, + from, + to, + preventDefault: () => prevented = true, + }; + this._inputEmulator.type(event); + if (prevented) { + return; + } + + // default behavior on typing + const tr = state.tr.insertText(text, from, to); + const newState = state.apply(tr); + + // render it + this.render(newState); + } } diff --git a/src/editor/view/widget/palette/askAIProvider.ts b/src/editor/view/widget/palette/askAIProvider.ts new file mode 100644 index 000000000..6ccde42ac --- /dev/null +++ b/src/editor/view/widget/palette/askAIProvider.ts @@ -0,0 +1,65 @@ +import { MenuAction, SimpleMenuAction } from "src/base/browser/basic/menu/menuItem"; +import { IEditorWidget } from "src/editor/editorWidget"; +import { IAITextService } from "src/platform/ai/common/aiText"; +import { II18nService } from "src/platform/i18n/browser/i18nService"; + +export class AskAIProvider { + + constructor( + private readonly editorWidget: IEditorWidget, + @II18nService private readonly i18nService: II18nService, + @IAITextService private readonly aiTextService: IAITextService, + ) {} + + public getContent(): MenuAction[] { + const contents: MenuAction[] = []; + + [ + { name: 'Continue Writing', prompt: 'Continue writing based on the given content.' }, + { name: 'Make a Summary', prompt: 'Make a summary of the given content.' }, + { name: 'Make a Outline', prompt: 'Make a outline of the givne content.' }, + ] + .forEach(({ name, prompt }) => { + contents.push(new SimpleMenuAction({ + id: name, + enabled: true, + callback: () => { + /** + * // TEST + */ + + const content = this.editorWidget.model.getRawContent(); + + let responseContent = ''; + + (async () => { + await this.aiTextService.sendRequestStream({ + messages: [ + { + role: 'system', + content: prompt, + }, + { + role: 'user', + content: content, + } + ], + model: 'gpt-4o', + stream: true, + }, (response) => { + if (response.primaryMessage.content) { + this.editorWidget.type(response.primaryMessage.content); + responseContent += response.primaryMessage.content; + console.log(response.primaryMessage.content); + } + }).unwrap(); + + console.log(responseContent); + })(); + }, + })); + }); + + return contents; + } +} \ No newline at end of file diff --git a/src/editor/view/widget/palette/blockInsertProvider.ts b/src/editor/view/widget/palette/blockInsertProvider.ts new file mode 100644 index 000000000..2be58eea3 --- /dev/null +++ b/src/editor/view/widget/palette/blockInsertProvider.ts @@ -0,0 +1,133 @@ +import { MenuAction, SimpleMenuAction, SubmenuAction } from "src/base/browser/basic/menu/menuItem"; +import { ErrorHandler } from "src/base/common/error"; +import { Arrays } from "src/base/common/utilities/array"; +import { Markdown, TokenEnum, getTokenReadableName } from "src/editor/common/markdown"; +import { ProseAttrs, ProseTextSelection } from "src/editor/common/proseMirror"; +import { ProseTools } from "src/editor/common/proseUtility"; +import { IEditorWidget } from "src/editor/editorWidget"; +import { II18nService } from "src/platform/i18n/browser/i18nService"; + +export class BlockInsertProvider { + + constructor( + private readonly editorWidget: IEditorWidget, + @II18nService private readonly i18nService: II18nService, + ) {} + + // [public methods] + + public getContent(): MenuAction[] { + const nodes = this.__obtainValidContent(); + + // convert each node into menu action + return nodes.map(nodeName => { + // heading: submenu + if (nodeName === TokenEnum.Heading) { + return this.__getHeadingActions(); + } + // general case + const resolvedName = getTokenReadableName(this.i18nService, nodeName); + return new SimpleMenuAction({ + enabled: true, + id: resolvedName, + callback: () => this.insertEmptyBlock({ + type: nodeName, + name: resolvedName, + attr: {}, + }), + }); + }); + } + + public insertEmptyBlock(event: OnBlockInsertEvent): void { + const { type, name, attr } = event; + const view = this.editorWidget.view.editor.internalView; + const state = view.state; + let tr = state.tr; + + const prevStart = tr.selection.$from.start(); + const $pos = state.doc.resolve(prevStart); + + // create an empy node with given type + const node = Markdown.Create.empty(state, type, attr); + if (!node) { + ErrorHandler.onUnexpectedError(new Error(`Cannot create node (${name})`)); + return; + } + + // replace the current node with the new node + tr = ProseTools.Position.replaceWithNode(state, $pos, node); + + // find next selectable text + const newStart = tr.mapping.map(prevStart); + const $newStart = tr.doc.resolve(newStart + 1); + const selection = ProseTextSelection.findFrom($newStart, 1, true); + if (selection) { + tr = tr.setSelection(selection); + } + + // update + view.dispatch(tr); + } + + // [private methods] + + private __obtainValidContent(): string[] { + const blocks = this.editorWidget.viewModel.getRegisteredDocumentNodes(); + const ordered = this.__filterContent(blocks, CONTENT_FILTER); + return ordered; + } + + private __filterContent(unordered: string[], expectOrder: string[]): string[] { + const ordered: string[] = []; + const unorderedSet = new Set(unordered); + for (const name of expectOrder) { + if (unorderedSet.has(name)) { + ordered.push(name); + unorderedSet.delete(name); + } else { + console.warn(`[SlashCommandExtension] missing node: ${name}`); + } + } + return ordered; + } + + private __getHeadingActions(): SubmenuAction { + const heading = getTokenReadableName(this.i18nService, TokenEnum.Heading); + return new SubmenuAction( + Arrays.range(1, 7).map(level => this.__getHeadingAction(heading, level)), + { enabled: true, id: heading } + ); + } + + private __getHeadingAction(name: string, level: number): MenuAction { + return new SimpleMenuAction({ + enabled: true, + id: `${name} ${level}`, + callback: () => this.insertEmptyBlock({ + type: TokenEnum.Heading, + name: name, + attr: { level: level }, + }), + }); + } +} + +type OnBlockInsertEvent = { + readonly type: string; + readonly name: string; + readonly attr: ProseAttrs; +}; + +const CONTENT_FILTER = [ + TokenEnum.Paragraph, + TokenEnum.Blockquote, + TokenEnum.Heading, + TokenEnum.Image, + TokenEnum.List, + TokenEnum.Table, + TokenEnum.CodeBlock, + TokenEnum.MathBlock, + TokenEnum.HTML, + TokenEnum.HorizontalRule, +]; \ No newline at end of file diff --git a/src/editor/contrib/slashCommandExtension/slashCommand.scss b/src/editor/view/widget/palette/palette.scss similarity index 81% rename from src/editor/contrib/slashCommandExtension/slashCommand.scss rename to src/editor/view/widget/palette/palette.scss index afe3755f4..68465c30b 100644 --- a/src/editor/contrib/slashCommandExtension/slashCommand.scss +++ b/src/editor/view/widget/palette/palette.scss @@ -1,6 +1,7 @@ -.editor-slash-command.context-menu { + +.editor-palette.context-menu { max-height: 260px; - overflow-y: scroll; + overflow-y: auto; &::-webkit-scrollbar { width: 10px; diff --git a/src/editor/view/widget/palette/palette.ts b/src/editor/view/widget/palette/palette.ts new file mode 100644 index 000000000..7f5bb81cf --- /dev/null +++ b/src/editor/view/widget/palette/palette.ts @@ -0,0 +1,296 @@ +import "src/editor/view/widget/palette/palette.scss"; +import { AnchorPrimaryAxisAlignment, AnchorVerticalPosition } from "src/base/browser/basic/contextMenu/contextMenu"; +import { MenuAction, MenuItemType } from "src/base/browser/basic/menu/menuItem"; +import { Disposable, DisposableBucket, IDisposable, safeDisposable } from "src/base/common/dispose"; +import { Emitter, Priority } from "src/base/common/event"; +import { KeyCode } from "src/base/common/keyboard"; +import { IO } from "src/base/common/utilities/functional"; +import { IPosition } from "src/base/common/utilities/size"; +import { ProseEditorView } from "src/editor/common/proseMirror"; +import { ProseTools } from "src/editor/common/proseUtility"; +import { IEditorWidget } from "src/editor/editorWidget"; +import { II18nService } from "src/platform/i18n/browser/i18nService"; +import { IContextMenuService } from "src/workbench/services/contextMenu/contextMenuService"; + +// region - EditorPalette + +export interface IEditorPaletteOptions { + + readonly contentProvider: IO; +} + +/** + * {@link EditorPalette} // TODO + */ +export class EditorPalette extends Disposable { + + // [event] + + get onDestroy() { return this._renderer.onMenuDestroy; } + + // [field] + + private readonly _renderer: PaletteRenderer; + private readonly _keyboardController: PaletteKeyboardController; + + // [constructor] + + constructor( + private readonly editorWidget: IEditorWidget, + options: IEditorPaletteOptions, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + @II18nService i18nService: II18nService, + ) { + super(); + this._renderer = this.__register(new PaletteRenderer(editorWidget, options, contextMenuService, i18nService)); + this._keyboardController = this.__register(new PaletteKeyboardController(editorWidget, this)); + + // always back to normal + this.__register(this._renderer.onMenuDestroy(() => { + this._keyboardController.unlisten(); + editorWidget.view.editor.focus(); + })); + + // menu click logic + this.__register(this.contextMenuService.contextMenu.onActionRun(e => { + contextMenuService.contextMenu.destroy(); + })); + } + + // [public methods] + + public render(position: IPosition): void { + this._renderer.show(position); + this._keyboardController.listen(this.editorWidget.view.editor.internalView); + } + + public focusPrev(): void { + this.contextMenuService.contextMenu.focusPrev(); + } + + public focusNext(): void { + this.contextMenuService.contextMenu.focusNext(); + } + + public getFocus(): number { + return this.contextMenuService.contextMenu.getFocus(); + } + + public getAction(indexOrID: number | string): MenuAction | undefined { + return this.contextMenuService.contextMenu.getAction(indexOrID); + } + + public tryOpenSubmenu(): boolean { + return this.contextMenuService.contextMenu.tryOpenSubmenu(); + } + + public hasFocus(): boolean { + return this.contextMenuService.contextMenu.hasFocus(); + } + + public destroy(): void { + this.contextMenuService.contextMenu.destroy(); + } + + public runFocus(): void { + this.contextMenuService.contextMenu.runFocus(); + } +} + +// region - PaletteRenderer + +class PaletteRenderer extends Disposable { + + private readonly _onMenuDestroy = this.__register(new Emitter()); + public readonly onMenuDestroy = this._onMenuDestroy.registerListener; + + constructor( + private readonly editorWidget: IEditorWidget, + private readonly options: IEditorPaletteOptions, + private readonly contextMenuService: IContextMenuService, + private readonly i18nService: II18nService, + ) { + super(); + } + + public show(position?: IPosition): void { + if (!position) { + return; + } + const { overlayContainer: parentElement } = this.editorWidget.view.editor; + + this.contextMenuService.showContextMenuCustom({ + getActions: () => this.options.contentProvider(), + getContext: () => undefined, + getAnchor: () => ({ x: position.left, y: position.top, height: 24 }), + getExtraContextMenuClassName: () => 'editor-palette', + primaryAlignment: AnchorPrimaryAxisAlignment.Vertical, + verticalPosition: AnchorVerticalPosition.Below, + + /** + * We need to capture the blur event to prevent auto destroy. We + * will handle destruction by ourselves. + */ + onBeforeDestroy: (cause) => { + const shouldPrevent = cause === 'blur'; + return shouldPrevent; + }, + // clean up + onDestroy: () => { + this._onMenuDestroy.fire(); + }, + }, parentElement); + } +} + +// region - PaletteKeyboardController + +class PaletteKeyboardController implements IDisposable { + + private _ongoing?: IDisposable; + + private _triggeredNode?: { + readonly pos: number; + readonly depth: number; + readonly nodeType: string; + }; + + constructor( + private readonly editorWidget: IEditorWidget, + private readonly palette: EditorPalette, + ) { + this._triggeredNode = undefined; + } + + public dispose(): void { + this._ongoing?.dispose(); + this._ongoing = undefined; + this._triggeredNode = undefined; + } + + public unlisten(): void { + this.dispose(); + } + + /** + * Invoked whenever a menu is rendererd, we handle the keyboard logic here. + */ + public listen(view: ProseEditorView): void { + this.__trackCurrentNode(view); + + this._ongoing?.dispose(); + const bucket = (this._ongoing = safeDisposable(new DisposableBucket())); + + /** + * Capture certain key down we handle it by ourselves. + * @note Registered with {@link Priority.High} + */ + bucket.register(this.editorWidget.onKeydown(e => { + const pressed = e.event.key; + const captureKey = [ + KeyCode.UpArrow, + KeyCode.DownArrow, + KeyCode.LeftArrow, + KeyCode.RightArrow, + + KeyCode.Escape, + KeyCode.Enter, + ]; + + // do nothing if non-capture key pressed. + if (!captureKey.includes(pressed)) { + return false; + } + + // escape: destroy the palette + if (pressed === KeyCode.Escape) { + this.palette.destroy(); + } + // handle up/down arrows + else if (pressed === KeyCode.UpArrow) { + this.palette.focusPrev(); + } else if (pressed === KeyCode.DownArrow) { + this.palette.focusNext(); + } + // handle right/left arrows + else if (pressed === KeyCode.RightArrow || pressed === KeyCode.LeftArrow) { + const index = this.palette.getFocus(); + const currAction = this.palette.getAction(index); + if (!currAction || currAction.type !== MenuItemType.Submenu) { + return false; + } + + if (pressed === KeyCode.RightArrow) { + const opened = this.palette.tryOpenSubmenu(); + return opened; + } else { + return false; + } + } + // enter + else if (pressed === KeyCode.Enter) { + const hasFocus = this.palette.hasFocus(); + if (!hasFocus) { + this.palette.destroy(); + return false; + } + this.palette.runFocus(); + } + + // make sure to re-focus back to editor + view.focus(); + + // tell the editor we handled this event, stop propagation. + e.preventDefault(); + return true; + + }, undefined, Priority.High)); + + /** + * Whenever current textblock back to empty state, destroy the palette. + */ + bucket.register(this.editorWidget.onDidContentChange(() => { + const { $from } = view.state.selection; + const isEmptyBlock = ProseTools.Node.isEmptyTextBlock($from.parent); + if (isEmptyBlock) { + this.palette.destroy(); + } + })); + + /** + * Destroy palette whenever the selection changes to other blocks. + */ + bucket.register(this.editorWidget.onDidSelectionChange(e => { + const menu = this.palette; + const triggeredNode = this._triggeredNode; + if (!triggeredNode) { + return; + } + + // obtain current selection's node info + const currSelection = e.transaction.selection; + const $current = currSelection.$from; + const mappedPos = e.transaction.mapping.map(triggeredNode.pos); + + const isSameNode = ( + $current.parent.type.name === triggeredNode.nodeType && + $current.before() === mappedPos && + $current.depth === triggeredNode.depth + ); + + if (!isSameNode) { + menu.destroy(); + } + })); + } + + private __trackCurrentNode(view: ProseEditorView): void { + const { state } = view; + const $pos = state.selection.$from; + this._triggeredNode = { + pos: $pos.before(), + depth: $pos.depth, + nodeType: $pos.parent.type.name, + }; + } +} \ No newline at end of file diff --git a/src/editor/viewModel/editorViewModel.ts b/src/editor/viewModel/editorViewModel.ts index f4ff4f9b9..a507abaf4 100644 --- a/src/editor/viewModel/editorViewModel.ts +++ b/src/editor/viewModel/editorViewModel.ts @@ -1,46 +1,138 @@ +import { history } from "prosemirror-history"; import { Disposable } from "src/base/common/dispose"; import { Emitter } from "src/base/common/event"; -import { IEditorModel } from "src/editor/common/model"; -import { ProseEditorState, ProseTransaction } from "src/editor/common/proseMirror"; -import { IEditorViewModel } from "src/editor/common/viewModel"; +import { defaultLog, ILogService } from "src/base/common/logger"; +import { IEditorExtension } from "src/editor/common/editorExtension"; +import { TokenEnum } from "src/editor/common/markdown"; +import { EditorToken, IEditorModel, IModelBuildData } from "src/editor/common/model"; +import { ProseEditorState, ProseNode } from "src/editor/common/proseMirror"; +import { IEditorViewModel, IViewModelBuildData, IViewModelChangeEvent } from "src/editor/common/viewModel"; +import { DocumentNodeProvider } from "src/editor/model/documentNode/documentNodeProvider"; +import { DocumentParser, IDocumentParser } from "src/editor/model/parser"; +import { buildSchema, EditorSchema } from "src/editor/model/schema"; +import { MarkdownSerializer } from "src/editor/model/serializer"; import { IOnDidContentChangeEvent } from "src/editor/view/proseEventBroadcaster"; +import { IInstantiationService } from "src/platform/instantiation/common/instantiation"; export class EditorViewModel extends Disposable implements IEditorViewModel { // [events] - private readonly _onDidChangeModelState = this.__register(new Emitter()); - public readonly onDidChangeModelState = this._onDidChangeModelState.registerListener; - - private readonly _onDidBuild = this.__register(new Emitter()); - public readonly onDidBuild = this._onDidBuild.registerListener; + private readonly _onDidContentChange = this.__register(new Emitter()); + public readonly onDidContentChange = this._onDidContentChange.registerListener; // [fields] private readonly _model: IEditorModel; + private _viewState: ProseEditorState; + + private readonly _schema: EditorSchema; // An object that defines how a view is organized. + private readonly _nodeProvider: DocumentNodeProvider; // Stores all the legal document node. + private readonly _docParser: IDocumentParser; // Parser that parses the given token into a legal view based on the schema. + private readonly _docSerializer: MarkdownSerializer; // Serializer that transforms the prosemirror document back to raw string. // [constructor] constructor( model: IEditorModel, + private readonly extensions: IEditorExtension[], + @IInstantiationService instantiationService: IInstantiationService, + @ILogService logService: ILogService, ) { super(); this._model = model; + this._viewState = new ProseEditorState(); + + this._nodeProvider = DocumentNodeProvider.create(instantiationService).register(); + this._schema = buildSchema(this._nodeProvider); + this._docParser = this.__register(new DocumentParser(this._schema, this._nodeProvider, /* options */)); + this._docSerializer = new MarkdownSerializer(this._nodeProvider, { strict: true, escapeExtraCharacters: undefined, }); + this.__register(this._docParser.onLog(event => defaultLog(logService, event.level, 'EditorView', event.message, event.error, event.additional))); this.__registerListeners(); } + // [getter] + + get schema(): EditorSchema { return this._schema; } + // [public methods] - public onDidViewContentChange(e: IOnDidContentChangeEvent): void { + public build(e: IModelBuildData): IViewModelBuildData { + const { tokens } = e; + + const document = this.__parse(tokens); + const state = ProseEditorState.create({ + schema: this._schema, + doc: document, + plugins: [ + ...this.extensions.map(extension => extension.getViewExtension()), + history({ depth: 500 }), + ], + }); + + return { + state: state, + }; + } + + public updateViewChange(e: IOnDidContentChangeEvent): void { this._model.setDirty(true); - this._model.__onDidStateChange(e); + this._viewState = e.view.state; + // TODO: convert View changes to EditorModel changes + } + + public getRegisteredDocumentNodes(): string[] { + return this._nodeProvider.getRegisteredNodes().map(each => each.name); + } + + public getRegisteredDocumentNodesBlock(): string[] { + const nodes = this._nodeProvider.getRegisteredNodes(); + const blocks: string[] = []; + for (const node of nodes) { + if (!node.getSchema().inline) { + blocks.push(node.name); + } + } + return blocks; + } + + public getRegisteredDocumentNodesInline(): string[] { + const nodes = this._nodeProvider.getRegisteredNodes(); + const blocks: string[] = []; + for (const node of nodes) { + if (node.getSchema().inline === true) { + blocks.push(node.name); + } + } + return blocks; } // [private methods] private __registerListeners(): void { - this.__register(this._model.onDidBuild(state => this._onDidBuild.fire(state))); - this.__register(this._model.onTransaction(tr => this._onDidChangeModelState.fire(tr))); + + /** + * Mapping token: {@link TokenEnum.Space} to {@link TokenEnum.Paragraph} + * Because `space` are just special cases for `paragraph`. + */ + this._docParser.registerMapToken(TokenEnum.Space, (from) => { + return { + type: TokenEnum.Paragraph, + text: '', + raw: '', + tokens: [] + }; + }); + } + + private __parse(tokens: EditorToken[]): ProseNode { + console.log(tokens); // TEST + + const doc = this._docParser.parse(tokens); + console.log(doc); // TEST + + // console.log(this._docSerializer.serialize(doc)); // TEST + return doc; } } \ No newline at end of file diff --git a/src/workbench/services/contextMenu/contextMenuService.ts b/src/workbench/services/contextMenu/contextMenuService.ts index cd45e976e..e84d141e2 100644 --- a/src/workbench/services/contextMenu/contextMenuService.ts +++ b/src/workbench/services/contextMenu/contextMenuService.ts @@ -1,6 +1,6 @@ import { ContextMenuView, IAnchor, IContextMenu, IContextMenuDelegate, IContextMenuDelegateBase } from "src/base/browser/basic/contextMenu/contextMenu"; import { DomUtility, EventType } from "src/base/browser/basic/dom"; -import { DomEmitter } from "src/base/common/event"; +import { DomEmitter, Register } from "src/base/common/event"; import { IMenu, IMenuActionRunEvent, Menu, MenuWithSubmenu } from "src/base/browser/basic/menu/menu"; import { CheckMenuAction, MenuAction, MenuItemType, MenuSeparatorAction, SimpleMenuAction, SubmenuAction } from "src/base/browser/basic/menu/menuItem"; import { Disposable, DisposableBucket, IDisposable } from "src/base/common/dispose"; @@ -106,6 +106,9 @@ export interface IContextMenuService extends IService { * no context menu is presented. */ readonly contextMenu: { + readonly onActionRun: Register; + readonly onDidActionRun: Register; + /** * @description Destroy the current context menu. */ @@ -221,6 +224,8 @@ export class ContextMenuService extends Disposable implements IContextMenuServic getFocus: ensureExist(() => this._internalDelegate?.getFocus()), getAction: ensureExist((arg: number | string) => this._internalDelegate?.getAction(arg)), tryOpenSubmenu: ensureExist((index?: number) => this._internalDelegate?.tryOpenSubmenu(index)), + onActionRun: ensureExist(() => this._internalDelegate?.onBeforeActionRun), + onDidActionRun: ensureExist(() => this._internalDelegate?.onDidActionRun), }; } @@ -349,6 +354,11 @@ export class ContextMenuService extends Disposable implements IContextMenuServic class __ContextMenuDelegate implements IContextMenuDelegate { + // [event] + + get onBeforeActionRun() { return this._menu?.onBeforeRun; } + get onDidActionRun() { return this._menu?.onDidRun; } + // [fields] private _menu?: MenuWithSubmenu; diff --git a/test/editor/view/contrib/editorCommands.test.ts b/test/editor/contrib/editorCommands.test.ts similarity index 99% rename from test/editor/view/contrib/editorCommands.test.ts rename to test/editor/contrib/editorCommands.test.ts index 9211cbcf6..c13fd2059 100644 --- a/test/editor/view/contrib/editorCommands.test.ts +++ b/test/editor/contrib/editorCommands.test.ts @@ -2,7 +2,7 @@ import * as assert from 'assert'; import { ProseUtilsTest } from 'test/editor/view/editorHelpers'; import ist from 'ist'; import { ProseEditorState, ProseNode, ProseNodeSelection, ProseSchema, ProseSelection, ProseTextSelection } from 'src/editor/common/proseMirror'; -import { EditorCommandBase, EditorCommands } from 'src/editor/contrib/commandExtension/editorCommands'; +import { EditorCommandBase, EditorCommands } from 'src/editor/contrib/command/editorCommands'; import { nullObject } from 'test/utils/helpers'; const { doc, p, blockquote, hr, ul, li } = ProseUtilsTest.defaultNodes;