From 781e03c50c5f89a4660c1129ce3233bd458415f0 Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Fri, 19 Jun 2026 17:07:18 +0200 Subject: [PATCH] publish codemirrot helix package flow --- .gitea/workflows/publish-codemirror-helix.yml | 48 ++ .gitignore | 3 + bun.lock | 84 +++ package.json | 17 +- packages/codemirror-helix/.gitignore | 10 + packages/codemirror-helix/README.md | 69 ++ packages/codemirror-helix/package.json | 51 ++ packages/codemirror-helix/src/commands.ts | 593 ++++++++++++++++++ packages/codemirror-helix/src/index.ts | 51 ++ packages/codemirror-helix/src/keymap.ts | 562 +++++++++++++++++ packages/codemirror-helix/src/motions.ts | 180 ++++++ packages/codemirror-helix/src/prompt.ts | 83 +++ packages/codemirror-helix/src/state.ts | 87 +++ packages/codemirror-helix/src/textobjects.ts | 127 ++++ packages/codemirror-helix/src/view.ts | 123 ++++ packages/codemirror-helix/tsconfig.json | 19 + .../_components/Form/Fields/MdeFormField.tsx | 127 ++-- .../Form/Fields/codemirror/mdxAutocomplete.ts | 85 +++ 18 files changed, 2265 insertions(+), 54 deletions(-) create mode 100644 .gitea/workflows/publish-codemirror-helix.yml create mode 100644 packages/codemirror-helix/.gitignore create mode 100644 packages/codemirror-helix/README.md create mode 100644 packages/codemirror-helix/package.json create mode 100644 packages/codemirror-helix/src/commands.ts create mode 100644 packages/codemirror-helix/src/index.ts create mode 100644 packages/codemirror-helix/src/keymap.ts create mode 100644 packages/codemirror-helix/src/motions.ts create mode 100644 packages/codemirror-helix/src/prompt.ts create mode 100644 packages/codemirror-helix/src/state.ts create mode 100644 packages/codemirror-helix/src/textobjects.ts create mode 100644 packages/codemirror-helix/src/view.ts create mode 100644 packages/codemirror-helix/tsconfig.json create mode 100644 src/app/_components/Form/Fields/codemirror/mdxAutocomplete.ts diff --git a/.gitea/workflows/publish-codemirror-helix.yml b/.gitea/workflows/publish-codemirror-helix.yml new file mode 100644 index 0000000..57b3dcf --- /dev/null +++ b/.gitea/workflows/publish-codemirror-helix.yml @@ -0,0 +1,48 @@ +name: Publish codemirror-helix + +# Releases the packages/codemirror-helix workspace package to npm. +# Trigger by pushing a tag like `codemirror-helix-v0.1.0` whose version +# matches packages/codemirror-helix/package.json. +on: + push: + tags: + - "codemirror-helix-v*" + +jobs: + publish: + runs-on: x86 + + defaults: + run: + working-directory: packages/codemirror-helix + + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - uses: actions/setup-node@v4 + with: + node-version: 24 + registry-url: "https://registry.npmjs.org" + + # Workspace install must happen at the monorepo root. + - name: Install dependencies + working-directory: . + run: bun install --frozen-lockfile + + - name: Check tag matches package version + run: | + PACKAGE_VERSION="$(node -p "require('./package.json').version")" + TAG_NAME="${GITHUB_REF_NAME:-${GITHUB_REF#refs/tags/}}" + test "codemirror-helix-v${PACKAGE_VERSION}" = "${TAG_NAME}" + + - name: Build + run: bun run build + + - name: Publish + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index fe96094..3e63c7f 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ yarn-error.log* /.clerk/ .worktrees .claudesession + +# built workspace packages (rebuilt by helix:build / package CI) +/packages/*/dist diff --git a/bun.lock b/bun.lock index d849f8c..60da04a 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,13 @@ "@ai-sdk/openai": "^3.0.67", "@ai-sdk/react": "^3.0.195", "@clerk/nextjs": "^7.4.2", + "@codemirror/autocomplete": "^6.20.3", + "@codemirror/commands": "^6.10.3", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/language": "^6.12.3", + "@codemirror/search": "^6.5.6", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.43.1", "@electric-sql/pglite": "^0.4.6", "@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/util": "^0.12.2", @@ -57,6 +64,7 @@ "@trpc/react-query": "^11.17.0", "@trpc/server": "^11.17.0", "@types/mdx": "^2.0.14", + "@uiw/react-codemirror": "^4.25.10", "@uiw/react-md-editor": "^4.1.1", "@uploadthing/react": "^7.3.3", "@vercel/speed-insights": "^2.0.0", @@ -64,6 +72,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "codemirror-helix": "workspace:*", "date-fns": "^4.4.0", "date-format": "^4.0.14", "drizzle-orm": "^0.45.2", @@ -130,6 +139,25 @@ "vitest": "^4.1.8", }, }, + "packages/codemirror-helix": { + "name": "codemirror-helix", + "version": "0.1.0", + "devDependencies": { + "@codemirror/commands": "^6.10.3", + "@codemirror/language": "^6.12.3", + "@codemirror/search": "^6.5.6", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.43.1", + "typescript": "^5.6.0", + }, + "peerDependencies": { + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + }, + }, }, "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], @@ -272,6 +300,30 @@ "@clerk/shared": ["@clerk/shared@4.19.0", "", { "dependencies": { "@tanstack/query-core": "^5.100.6", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.7" }, "peerDependencies": { "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-27tZG3/PAuuOQAKFpKGzFlio2FC/t/0TcSRl6UNlYap85BJRPnnYdo3PaVunNbKYKOpaFwJlwgcD2cKB7l/qWw=="], + "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g=="], + + "@codemirror/commands": ["@codemirror/commands@6.10.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q=="], + + "@codemirror/lang-css": ["@codemirror/lang-css@6.3.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/css": "^1.1.7" } }, "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg=="], + + "@codemirror/lang-html": ["@codemirror/lang-html@6.4.11", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.12" } }, "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw=="], + + "@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="], + + "@codemirror/lang-markdown": ["@codemirror/lang-markdown@6.5.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.3.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/markdown": "^1.0.0" } }, "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw=="], + + "@codemirror/language": ["@codemirror/language@6.12.3", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA=="], + + "@codemirror/lint": ["@codemirror/lint@6.9.7", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg=="], + + "@codemirror/search": ["@codemirror/search@6.7.1", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA=="], + + "@codemirror/state": ["@codemirror/state@6.6.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ=="], + + "@codemirror/theme-one-dark": ["@codemirror/theme-one-dark@6.1.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/highlight": "^1.0.0" } }, "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA=="], + + "@codemirror/view": ["@codemirror/view@6.43.1", "", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw=="], + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], "@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="], @@ -496,6 +548,22 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="], + + "@lezer/css": ["@lezer/css@1.3.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg=="], + + "@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="], + + "@lezer/html": ["@lezer/html@1.3.13", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg=="], + + "@lezer/javascript": ["@lezer/javascript@1.5.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA=="], + + "@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="], + + "@lezer/markdown": ["@lezer/markdown@1.6.4", "", { "dependencies": { "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0" } }, "sha512-N0SxazMj4k65DBfaf1azqtMZd6u7MqluP84/NZnB/io8Td9aleFmAhz9hcbvSfsxT5tdYlJ5qgv5aMJGY4zEtA=="], + + "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="], + "@mdx-js/loader": ["@mdx-js/loader@3.1.1", "", { "dependencies": { "@mdx-js/mdx": "^3.0.0", "source-map": "^0.7.0" }, "peerDependencies": { "webpack": ">=5" }, "optionalPeers": ["webpack"] }, "sha512-0TTacJyZ9mDmY+VefuthVshaNIyCGZHJG2fMnGaDttCt8HmjUF7SizlHJpaCDoGnN635nK1wpzfpx/Xx5S4WnQ=="], "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], @@ -914,8 +982,12 @@ "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], + "@uiw/codemirror-extensions-basic-setup": ["@uiw/codemirror-extensions-basic-setup@4.25.10", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-P3vytLlpE62KYSWrMUnwDCv2lvaQDuDZzyj03mHntuHo5bSl34fRZpjTY3kQTPGuXHxkGSYpoPFFj+hMTqaaMQ=="], + "@uiw/copy-to-clipboard": ["@uiw/copy-to-clipboard@1.0.21", "", {}, "sha512-apdzZJyJC/IEj21N22ry1H022pgpSA+FwNKxmvOGQ9rUMdyRbHf1nZq2UwqnfGOuTr7iOKLzNgWsCJtAwyAZpw=="], + "@uiw/react-codemirror": ["@uiw/react-codemirror@4.25.10", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@codemirror/commands": "^6.1.0", "@codemirror/state": "^6.1.1", "@codemirror/theme-one-dark": "^6.0.0", "@uiw/codemirror-extensions-basic-setup": "4.25.10", "codemirror": "^6.0.0" }, "peerDependencies": { "@codemirror/view": ">=6.0.0", "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-DzgSMwM5qzB7v1FIb4gEeriYt67iiay756/HIOM9mAbeOVK0MO7rqefHf0O5c0269pJKMW7AH9FjclExD23V9w=="], + "@uiw/react-markdown-preview": ["@uiw/react-markdown-preview@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.17.2", "@uiw/copy-to-clipboard": "~1.0.12", "react-markdown": "~10.1.0", "rehype-attr": "~4.0.0", "rehype-autolink-headings": "~7.1.0", "rehype-ignore": "^2.0.0", "rehype-prism-plus": "~2.0.0", "rehype-raw": "^7.0.0", "rehype-rewrite": "~4.0.0", "rehype-slug": "~6.0.0", "remark-gfm": "~4.0.0", "remark-github-blockquote-alert": "^1.0.0", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JjvcHveT6glhlJYJx1XGBZij6wkw+VwREV6Z6m/GpsjPPdLjF1x8nlPBSB/ATyUF4lD7C8ttMkCqVH9N9XMgEA=="], "@uiw/react-md-editor": ["@uiw/react-md-editor@4.1.1", "", { "dependencies": { "@babel/runtime": "^7.14.6", "@uiw/react-markdown-preview": "^5.2.0", "rehype": "~13.0.0", "rehype-prism-plus": "~2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-yZqV5twN/sSfpce4cO/1bqy16o7v2oW324VNh2gcnsSpzOr2jEHpchM+ElD1y+ivUmFXcDZ5Ky5TOuPZg4qL6g=="], @@ -1132,6 +1204,10 @@ "code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="], + "codemirror": ["codemirror@6.0.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw=="], + + "codemirror-helix": ["codemirror-helix@workspace:packages/codemirror-helix"], + "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], "collect-v8-coverage": ["collect-v8-coverage@1.0.3", "", {}, "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw=="], @@ -1166,6 +1242,8 @@ "create-require": ["create-require@1.1.1", "", {}, "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="], + "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "css-selector-parser": ["css-selector-parser@3.3.0", "", {}, "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g=="], @@ -2268,6 +2346,8 @@ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="], + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], @@ -2422,6 +2502,8 @@ "vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], "walker": ["walker@1.0.8", "", { "dependencies": { "makeerror": "1.0.12" } }, "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ=="], @@ -2554,6 +2636,8 @@ "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "codemirror-helix/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "conf/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], "conf/json-schema-typed": ["json-schema-typed@7.0.3", "", {}, "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A=="], diff --git a/package.json b/package.json index 2c246ae..01d65a8 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,10 @@ "version": "0.1.0", "private": true, "type": "module", + "workspaces": ["packages/*"], "scripts": { - "build": "next build", + "build": "bun run helix:build && next build", + "helix:build": "bun run --cwd packages/codemirror-helix build", "build:ffmpeg-worker": "bun build node_modules/@ffmpeg/ffmpeg/dist/esm/worker.js --target browser --format esm --minify --outfile public/ffmpeg/worker.js", "check": "biome check .", "check:unsafe": "biome check --write --unsafe .", @@ -13,7 +15,7 @@ "db:migrate": "drizzle-kit migrate", "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio", - "dev": "next dev --turbo", + "dev": "bun run helix:build && next dev --turbo", "preview": "next build && next start", "start": "next start", "typecheck": "tsc --noEmit", @@ -23,6 +25,13 @@ "@ai-sdk/openai": "^3.0.67", "@ai-sdk/react": "^3.0.195", "@clerk/nextjs": "^7.4.2", + "@codemirror/autocomplete": "^6.20.3", + "@codemirror/commands": "^6.10.3", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/language": "^6.12.3", + "@codemirror/search": "^6.5.6", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.43.1", "@electric-sql/pglite": "^0.4.6", "@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/util": "^0.12.2", @@ -31,9 +40,9 @@ "@fortawesome/react-fontawesome": "^3.3.1", "@gsap/react": "^2.1.2", "@hookform/resolvers": "^5.4.0", + "@mdx-js/loader": "^3.1.1", "@mdx-js/mdx": "^3.1.1", "@mdx-js/react": "^3.1.1", - "@mdx-js/loader": "^3.1.1", "@neondatabase/serverless": "^1.1.0", "@next/mdx": "^16.2.9", "@radix-ui/react-accordion": "^1.2.12", @@ -72,7 +81,9 @@ "@trpc/react-query": "^11.17.0", "@trpc/server": "^11.17.0", "@types/mdx": "^2.0.14", + "@uiw/react-codemirror": "^4.25.10", "@uiw/react-md-editor": "^4.1.1", + "codemirror-helix": "workspace:*", "@uploadthing/react": "^7.3.3", "@vercel/speed-insights": "^2.0.0", "ai": "^6.0.193", diff --git a/packages/codemirror-helix/.gitignore b/packages/codemirror-helix/.gitignore new file mode 100644 index 0000000..095fb13 --- /dev/null +++ b/packages/codemirror-helix/.gitignore @@ -0,0 +1,10 @@ +# build output (rebuilt by `bun run build` / helix:build / publish CI) +dist/ + +# dependencies +node_modules/ + +# logs / caches +*.log +.DS_Store +*.tsbuildinfo diff --git a/packages/codemirror-helix/README.md b/packages/codemirror-helix/README.md new file mode 100644 index 0000000..7da16c5 --- /dev/null +++ b/packages/codemirror-helix/README.md @@ -0,0 +1,69 @@ +# codemirror-helix + +[Helix](https://helix-editor.com/)-style modal editing for [CodeMirror 6](https://codemirror.net/). + +Selection-first editing with multiple selections, Normal/Insert/Select modes, +goto & match modes, textobjects, surround, registers, counts, and search. + +> A **core motions subset** — broad coverage of everyday Helix keys, not full +> parity. No tree-sitter textobjects, LSP gotos, macros, or jumplist. + +## Install + +```sh +bun add codemirror-helix +# peers: @codemirror/{state,view,commands,language,search} +``` + +## Usage + +```ts +import { EditorView, basicSetup } from "codemirror"; +import { helix } from "codemirror-helix"; + +new EditorView({ + doc: "hello world", + extensions: [basicSetup, helix()], + parent: document.body, +}); +``` + +`helix()` needs a selection drawer for multi-cursor rendering — `basicSetup` +(or `drawSelection()`) covers it. + +### Options + +```ts +helix({ + startInInsert: false, // start in Normal mode (default) + statusBar: true, // show the bottom mode line + // Let an open autocomplete popup eat the first Escape instead of leaving Insert: + escapeGuard: (state) => completionStatus(state) === "active", +}); +``` + +## Keys + +Starts in **Normal** mode. The status line shows the mode, pending count, +register, and selection count. + +| Group | Keys | +| --- | --- | +| Modes | `i`/`a` insert before/after, `I`/`A` line start/end, `o`/`O` open line, `v` select (extend), `Esc` normal | +| Motion | `h j k l`, `w W b B e E`, `f t F T {char}`, `Alt-.` repeat find, `Home`/`End`, counts (`3w`) | +| Goto `g` | `gg`/`Ng` line, `ge` end, `gh`/`gl` line ends, `gs` first non-blank, `gt`/`gc`/`gb` view top/center/bottom | +| Select | `x` line (repeat extends), `X` line bounds, `%` all, `;` collapse, `Alt-;` flip, `Alt-:` forward, `,` keep primary, `Alt-,` remove primary, `(`/`)` rotate, `_` trim | +| Multi | `s` select regex, `S` split, `K`/`Alt-K` keep/remove, `C` copy selection below, `Alt-C` above | +| Match `m` | `mm` matching bracket, `mi{o}`/`ma{o}` inside/around (`w W p ( [ { < " ' \` m`), `ms{c}` surround, `md{c}` delete, `mr{c}{c}` replace | +| Edit | `d` delete, `c` change, `y` yank, `p`/`P` paste, `R` replace w/ register, `r{c}` replace char, `~`/`` ` ``/`Alt-`` ` `` case, `J` join, `>`/`<` indent, `u`/`U` undo/redo | +| Registers | `"{c}` select register for the next yank/delete/paste | +| Search | `/` `?` search, `n`/`N` next/prev, `*` search selection | +| View | `zz`/`zt`/`zb` center/top/bottom, `Ctrl-d`/`Ctrl-u` half page, `Ctrl-f`/`Ctrl-b` page | +| Clipboard | `space y` copy, `space p`/`space P` paste (system clipboard) | + +`s`/`S`/`K`/`Alt-K` open a prompt and preview live as you type the regex; +`Enter` commits, `Esc` restores the original selection. + +## License + +MIT diff --git a/packages/codemirror-helix/package.json b/packages/codemirror-helix/package.json new file mode 100644 index 0000000..322feae --- /dev/null +++ b/packages/codemirror-helix/package.json @@ -0,0 +1,51 @@ +{ + "name": "codemirror-helix", + "version": "0.1.0", + "description": "Helix-editor-style modal editing for CodeMirror 6 (multiple selections, select mode, goto/match modes, textobjects, surround, registers, search).", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": ["dist", "README.md"], + "sideEffects": false, + "scripts": { + "build": "tsc -p tsconfig.json", + "prepublishOnly": "tsc -p tsconfig.json" + }, + "keywords": [ + "codemirror", + "codemirror6", + "helix", + "modal", + "vim", + "editor", + "keybindings" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "https://gitea.gregorlohaus.com/gregor/gregorlohaus.com", + "directory": "packages/codemirror-helix" + }, + "peerDependencies": { + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + }, + "devDependencies": { + "@codemirror/commands": "^6.10.3", + "@codemirror/language": "^6.12.3", + "@codemirror/search": "^6.5.6", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.43.1", + "typescript": "^5.6.0" + } +} diff --git a/packages/codemirror-helix/src/commands.ts b/packages/codemirror-helix/src/commands.ts new file mode 100644 index 0000000..f2e2519 --- /dev/null +++ b/packages/codemirror-helix/src/commands.ts @@ -0,0 +1,593 @@ +import { + EditorSelection, + type ChangeSpec, + type EditorState, + type SelectionRange, +} from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { indentLess, indentMore, redo, undo } from "@codemirror/commands"; +import { findNext, findPrevious, SearchQuery, setSearchQuery } from "@codemirror/search"; +import { + clearPending, + getHelix, + helixEffect, + readRegister, + setRegister, + type HelixMode, +} from "./state"; +import * as M from "./motions"; + +const settle = { ...clearPending() }; + +function clamp(n: number, state: EditorState): number { + return Math.max(0, Math.min(n, state.doc.length)); +} + +function rangesText(state: EditorState, ranges: readonly SelectionRange[]): string { + return ranges.map((r) => state.doc.sliceString(r.from, r.to)).join("\n"); +} + +function maybeClipboard(register: string | null, text: string) { + if ((register === "+" || register === "*") && typeof navigator !== "undefined" && navigator.clipboard) { + void navigator.clipboard.writeText(text).catch(() => {}); + } +} + +/** After leaving an edit, choose the resting mode. */ +function restMode(current: HelixMode, target?: HelixMode): HelixMode { + if (target) return target; + return current === "select" ? "normal" : current; +} + +// --- motion application ------------------------------------------------------ + +export type PointFn = (state: EditorState, pos: number) => number; + +export interface MotionOpts { + extend: boolean; + span: boolean; // word-like: selects the traversed span in normal mode + count: number; +} + +export function moveByPoint(view: EditorView, fn: PointFn, opts: MotionOpts) { + const { state } = view; + const selection = EditorSelection.create( + state.selection.ranges.map((r) => { + let head = r.head; + for (let i = 0; i < Math.max(1, opts.count); i++) head = fn(state, head); + head = clamp(head, state); + const anchor = opts.extend ? r.anchor : opts.span ? r.head : head; + return EditorSelection.range(clamp(anchor, state), head); + }), + state.selection.mainIndex, + ); + view.dispatch({ selection, scrollIntoView: true }); +} + +export function moveVertical(view: EditorView, forward: boolean, opts: { extend: boolean; count: number }) { + const selection = EditorSelection.create( + view.state.selection.ranges.map((r) => { + let cur: SelectionRange = EditorSelection.cursor(r.head); + for (let i = 0; i < Math.max(1, opts.count); i++) cur = view.moveVertically(cur, forward); + return opts.extend + ? EditorSelection.range(r.anchor, cur.head) + : EditorSelection.cursor(cur.head); + }), + view.state.selection.mainIndex, + ); + view.dispatch({ selection, scrollIntoView: true }); +} + +// --- insertion entry points -------------------------------------------------- + +export function enterInsert(view: EditorView, posOf: (state: EditorState, r: SelectionRange) => number) { + const { state } = view; + const selection = EditorSelection.create( + state.selection.ranges.map((r) => EditorSelection.cursor(clamp(posOf(state, r), state))), + state.selection.mainIndex, + ); + view.dispatch({ selection, effects: helixEffect.of({ mode: "insert", ...settle }) }); +} + +export function openLine(view: EditorView, above: boolean) { + const { state } = view; + const seen = new Set(); + const changes: ChangeSpec[] = []; + for (const r of state.selection.ranges) { + const line = state.doc.lineAt(r.head); + if (seen.has(line.number)) continue; + seen.add(line.number); + const indent = line.text.match(/^\s*/)?.[0] ?? ""; + changes.push(above ? { from: line.from, insert: `${indent}\n` } : { from: line.to, insert: `\n${indent}` }); + } + const changeSet = state.changes(changes); + // Place a cursor on each newly opened line. + const selection = EditorSelection.create( + [...seen].map((n) => { + const line = state.doc.line(n); + const pos = above ? line.from : line.to; + const mapped = changeSet.mapPos(pos, 1); + return EditorSelection.cursor(mapped); + }), + ); + view.dispatch({ + changes: changeSet, + selection, + effects: helixEffect.of({ mode: "insert", ...settle }), + scrollIntoView: true, + }); +} + +// --- delete / change / yank / paste ----------------------------------------- + +interface DeleteOpts { + insert?: boolean; // change (c) +} + +export function deleteSelection(view: EditorView, opts: DeleteOpts = {}) { + const { state } = view; + const helix = getHelix(state); + const specs = state.selection.ranges.map((r) => ({ + from: r.from, + to: r.empty ? Math.min(r.to + 1, state.doc.length) : r.to, + })); + const yanked = specs.map((s) => state.doc.sliceString(s.from, s.to)).join("\n"); + const changeSet = state.changes(specs.map((s) => ({ from: s.from, to: s.to, insert: "" }))); + const selection = EditorSelection.create( + specs.map((s) => EditorSelection.cursor(changeSet.mapPos(s.from, -1))), + state.selection.mainIndex, + ); + view.dispatch({ + changes: changeSet, + selection, + effects: [ + setRegister.of({ name: helix.register ?? '"', text: yanked }), + helixEffect.of({ mode: opts.insert ? "insert" : restMode(helix.mode), ...settle }), + ], + scrollIntoView: true, + }); + maybeClipboard(helix.register, yanked); +} + +export function yankSelection(view: EditorView) { + const { state } = view; + const helix = getHelix(state); + const text = rangesText(state, state.selection.ranges); + view.dispatch({ + effects: [ + setRegister.of({ name: helix.register ?? '"', text }), + helixEffect.of({ mode: restMode(helix.mode), ...settle }), + ], + }); + maybeClipboard(helix.register, text); +} + +export function paste(view: EditorView, before: boolean) { + const { state } = view; + const helix = getHelix(state); + const text = readRegister(state, helix.register); + if (!text) return; + const linewise = text.endsWith("\n"); + const changes: { from: number; to: number; insert: string }[] = []; + for (const r of state.selection.ranges) { + if (linewise) { + const line = state.doc.lineAt(before ? r.from : r.to); + if (before) changes.push({ from: line.from, to: line.from, insert: text }); + else changes.push({ from: line.to, to: line.to, insert: `\n${text.replace(/\n$/, "")}` }); + } else { + const at = before ? r.from : r.to; + changes.push({ from: at, to: at, insert: text }); + } + } + const changeSet = state.changes(changes); + view.dispatch({ + changes: changeSet, + selection: state.selection.map(changeSet), + effects: helixEffect.of(settle), + scrollIntoView: true, + }); +} + +/** R: replace selections with the register contents. */ +export function replaceWithYank(view: EditorView) { + const { state } = view; + const helix = getHelix(state); + const text = readRegister(state, helix.register); + if (!text) return; + const changes = state.selection.ranges.map((r) => ({ from: r.from, to: r.to, insert: text })); + const changeSet = state.changes(changes); + view.dispatch({ + changes: changeSet, + selection: state.selection.map(changeSet), + effects: helixEffect.of(settle), + scrollIntoView: true, + }); +} + +/** r{char}: replace every selected character with `char`. */ +export function replaceChar(view: EditorView, char: string) { + if (char.length !== 1) return; + const { state } = view; + const changes = state.selection.ranges + .map((r) => { + const from = r.from; + const to = r.empty ? Math.min(r.to + 1, state.doc.length) : r.to; + const text = state.doc.sliceString(from, to).replace(/[^\n]/g, char); + return { from, to, insert: text }; + }) + .filter((c) => c.to > c.from); + if (!changes.length) return; + view.dispatch({ changes, effects: helixEffect.of(settle) }); +} + +type CaseMode = "toggle" | "lower" | "upper"; + +export function changeCase(view: EditorView, mode: CaseMode) { + const { state } = view; + const changes = state.selection.ranges + .map((r) => { + const from = r.from; + const to = r.empty ? Math.min(r.to + 1, state.doc.length) : r.to; + const text = state.doc.sliceString(from, to); + const next = + mode === "lower" + ? text.toLowerCase() + : mode === "upper" + ? text.toUpperCase() + : text.replace(/[a-zA-Z]/g, (c) => (c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase())); + return { from, to, insert: next }; + }) + .filter((c) => c.to > c.from); + if (!changes.length) return; + view.dispatch({ changes, effects: helixEffect.of(settle) }); +} + +/** J: join the lines spanned by each selection into one. */ +export function joinLines(view: EditorView) { + const { state } = view; + const changes: ChangeSpec[] = []; + for (const r of state.selection.ranges) { + const startLine = state.doc.lineAt(r.from).number; + const endLine = state.doc.lineAt(r.to).number; + const last = Math.max(endLine, startLine + 1); + for (let n = startLine; n < last && n < state.doc.lines; n++) { + const line = state.doc.line(n); + const nextLine = state.doc.line(n + 1); + const trimmed = nextLine.text.match(/^\s*/)?.[0].length ?? 0; + changes.push({ from: line.to, to: nextLine.from + trimmed, insert: " " }); + } + } + if (!changes.length) return; + view.dispatch({ changes, effects: helixEffect.of(settle), scrollIntoView: true }); +} + +export function indent(view: EditorView, less: boolean) { + (less ? indentLess : indentMore)(view); + view.dispatch({ effects: helixEffect.of(settle) }); +} + +export function undoCmd(view: EditorView) { + undo(view); +} +export function redoCmd(view: EditorView) { + redo(view); +} + +// --- selection manipulation -------------------------------------------------- + +function setSelection(view: EditorView, ranges: SelectionRange[], mainIndex?: number) { + if (!ranges.length) return; + view.dispatch({ + selection: EditorSelection.create(ranges, mainIndex ?? ranges.length - 1), + scrollIntoView: true, + }); +} + +export function collapseToCursor(view: EditorView) { + setSelection( + view, + view.state.selection.ranges.map((r) => EditorSelection.cursor(r.head)), + view.state.selection.mainIndex, + ); +} + +export function flipSelections(view: EditorView) { + setSelection( + view, + view.state.selection.ranges.map((r) => EditorSelection.range(r.head, r.anchor)), + view.state.selection.mainIndex, + ); +} + +export function ensureForward(view: EditorView) { + setSelection( + view, + view.state.selection.ranges.map((r) => EditorSelection.range(r.from, r.to)), + view.state.selection.mainIndex, + ); +} + +export function keepPrimary(view: EditorView) { + const main = view.state.selection.main; + setSelection(view, [main], 0); +} + +export function removePrimary(view: EditorView) { + const { ranges, mainIndex } = view.state.selection; + if (ranges.length < 2) return; + const remaining = ranges.filter((_, i) => i !== mainIndex); + setSelection(view, remaining, Math.min(mainIndex, remaining.length - 1)); +} + +export function rotatePrimary(view: EditorView, forward: boolean) { + const { ranges, mainIndex } = view.state.selection; + if (ranges.length < 2) return; + const next = (mainIndex + (forward ? 1 : -1) + ranges.length) % ranges.length; + setSelection(view, [...ranges], next); +} + +export function selectAll(view: EditorView) { + setSelection(view, [EditorSelection.range(0, view.state.doc.length)], 0); +} + +export function trimSelections(view: EditorView) { + const { state } = view; + const ranges: SelectionRange[] = []; + for (const r of state.selection.ranges) { + const text = state.doc.sliceString(r.from, r.to); + const lead = text.match(/^\s*/)?.[0].length ?? 0; + const trail = text.match(/\s*$/)?.[0].length ?? 0; + const from = r.from + lead; + const to = Math.max(from, r.to - trail); + ranges.push(EditorSelection.range(from, to)); + } + setSelection(view, ranges, state.selection.mainIndex); +} + +/** x: extend each selection to whole line(s); repeat grows downward. */ +export function selectLine(view: EditorView, count: number) { + const { state } = view; + const ranges = state.selection.ranges.map((r) => { + const startLine = state.doc.lineAt(r.from); + let endLine = state.doc.lineAt(r.to); + const from = startLine.from; + let to = Math.min(endLine.to + 1, state.doc.length); + const whole = r.from === from && r.to === to; + const steps = whole ? Math.max(1, count) : Math.max(0, count - 1); + for (let i = 0; i < steps && endLine.number < state.doc.lines; i++) { + endLine = state.doc.line(endLine.number + 1); + to = Math.min(endLine.to + 1, state.doc.length); + } + return EditorSelection.range(from, to); + }); + setSelection(view, ranges, state.selection.mainIndex); +} + +/** X: extend selections to cover full lines without crossing into the next. */ +export function extendToLineBounds(view: EditorView) { + const { state } = view; + const ranges = state.selection.ranges.map((r) => + EditorSelection.range(state.doc.lineAt(r.from).from, state.doc.lineAt(r.to).to), + ); + setSelection(view, ranges, state.selection.mainIndex); +} + +/** C / Alt-C: copy each selection onto the next/previous line at the same columns. */ +export function copySelectionToLine(view: EditorView, below: boolean) { + const { state } = view; + const additions: SelectionRange[] = []; + for (const r of state.selection.ranges) { + const anchorLine = state.doc.lineAt(r.anchor); + const headLine = state.doc.lineAt(r.head); + const anchorCol = r.anchor - anchorLine.from; + const headCol = r.head - headLine.from; + const targetAnchorNo = anchorLine.number + (below ? 1 : -1); + const targetHeadNo = headLine.number + (below ? 1 : -1); + if (targetAnchorNo < 1 || targetAnchorNo > state.doc.lines) continue; + if (targetHeadNo < 1 || targetHeadNo > state.doc.lines) continue; + const ta = state.doc.line(targetAnchorNo); + const th = state.doc.line(targetHeadNo); + additions.push( + EditorSelection.range( + Math.min(ta.from + anchorCol, ta.to), + Math.min(th.from + headCol, th.to), + ), + ); + } + if (!additions.length) return; + const all = [...state.selection.ranges, ...additions]; + setSelection(view, all, all.length - 1); +} + +function restoreBase(view: EditorView, base: EditorSelection) { + view.dispatch({ selection: base, scrollIntoView: true }); +} + +/** + * s: keep only the regex matches found within each selection of `base`. + * `base` is the selection captured when the prompt opened, so this can be + * called live on every keystroke without compounding. + */ +export function selectRegexInSelections( + view: EditorView, + pattern: string, + base: EditorSelection = view.state.selection, +) { + if (!pattern) return restoreBase(view, base); + let re: RegExp; + try { + re = new RegExp(pattern, "g"); + } catch { + return; // incomplete/invalid regex: leave the last good preview in place + } + const { state } = view; + const ranges: SelectionRange[] = []; + for (const r of base.ranges) { + const text = state.doc.sliceString(r.from, r.to); + for (const m of text.matchAll(re)) { + const from = r.from + (m.index ?? 0); + const to = from + m[0].length; + ranges.push(EditorSelection.range(from, Math.max(from, to))); + if (m[0].length === 0) re.lastIndex++; + } + } + if (ranges.length) setSelection(view, ranges, ranges.length - 1); + else restoreBase(view, base); +} + +/** S: split each selection of `base` on the regex, keeping the pieces between. */ +export function splitOnRegex( + view: EditorView, + pattern: string, + base: EditorSelection = view.state.selection, +) { + if (!pattern) return restoreBase(view, base); + let re: RegExp; + try { + re = new RegExp(pattern, "g"); + } catch { + return; + } + const { state } = view; + const ranges: SelectionRange[] = []; + for (const r of base.ranges) { + const text = state.doc.sliceString(r.from, r.to); + let last = 0; + for (const m of text.matchAll(re)) { + const idx = m.index ?? 0; + ranges.push(EditorSelection.range(r.from + last, r.from + idx)); + last = idx + m[0].length; + if (m[0].length === 0) re.lastIndex++; + } + ranges.push(EditorSelection.range(r.from + last, r.to)); + } + const filtered = ranges.filter((r) => r.to >= r.from); + if (filtered.length) setSelection(view, filtered, filtered.length - 1); + else restoreBase(view, base); +} + +/** Keep (or remove) selections of `base` whose text matches the regex. */ +export function filterSelections( + view: EditorView, + pattern: string, + keep: boolean, + base: EditorSelection = view.state.selection, +) { + if (!pattern) return restoreBase(view, base); + let re: RegExp; + try { + re = new RegExp(pattern); + } catch { + return; + } + const { state } = view; + const ranges = base.ranges.filter((r) => { + const matches = re.test(state.doc.sliceString(r.from, r.to)); + return keep ? matches : !matches; + }); + if (ranges.length) setSelection(view, ranges, ranges.length - 1); + else restoreBase(view, base); +} + +// --- surround ---------------------------------------------------------------- + +const SURROUND_PAIRS: Record = { + "(": ["(", ")"], + ")": ["(", ")"], + "[": ["[", "]"], + "]": ["[", "]"], + "{": ["{", "}"], + "}": ["{", "}"], + "<": ["<", ">"], + ">": ["<", ">"], +}; + +function surroundPair(ch: string): [string, string] { + return SURROUND_PAIRS[ch] ?? [ch, ch]; +} + +/** ms{char}: wrap each selection in the chosen pair. */ +export function surroundAdd(view: EditorView, ch: string) { + const [open, close] = surroundPair(ch); + const { state } = view; + const changes: ChangeSpec[] = []; + for (const r of state.selection.ranges) { + changes.push({ from: r.from, insert: open }); + changes.push({ from: r.to, insert: close }); + } + const changeSet = state.changes(changes); + view.dispatch({ + changes: changeSet, + selection: state.selection.map(changeSet), + effects: helixEffect.of(settle), + }); +} + +/** md{char}: remove the surrounding pair around each selection. */ +export function surroundDelete(view: EditorView, ch: string) { + const [open, close] = surroundPair(ch); + const { state } = view; + const changes: { from: number; to: number; insert: string }[] = []; + for (const r of state.selection.ranges) { + const openPos = M.findOpen(state, r.head, open, close); + const closePos = openPos === null ? null : M.findClose(state, openPos, open, close); + if (openPos === null || closePos === null) continue; + changes.push({ from: openPos, to: openPos + 1, insert: "" }); + changes.push({ from: closePos, to: closePos + 1, insert: "" }); + } + if (!changes.length) return; + view.dispatch({ changes, effects: helixEffect.of(settle) }); +} + +/** mr{from}{to}: replace the surrounding pair. */ +export function surroundReplace(view: EditorView, fromCh: string, toCh: string) { + const [open, close] = surroundPair(fromCh); + const [newOpen, newClose] = surroundPair(toCh); + const { state } = view; + const changes: { from: number; to: number; insert: string }[] = []; + for (const r of state.selection.ranges) { + const openPos = M.findOpen(state, r.head, open, close); + const closePos = openPos === null ? null : M.findClose(state, openPos, open, close); + if (openPos === null || closePos === null) continue; + changes.push({ from: openPos, to: openPos + 1, insert: newOpen }); + changes.push({ from: closePos, to: closePos + 1, insert: newClose }); + } + if (!changes.length) return; + view.dispatch({ changes, effects: helixEffect.of(settle) }); +} + +// --- search ------------------------------------------------------------------ + +export function runSearch(view: EditorView, query: string, reverse: boolean) { + if (!query) return; + view.dispatch({ effects: setSearchQuery.of(new SearchQuery({ search: query, regexp: true })) }); + (reverse ? findPrevious : findNext)(view); +} + +export function searchNext(view: EditorView, reverse: boolean) { + (reverse ? findPrevious : findNext)(view); +} + +export function searchSelection(view: EditorView, reverse: boolean) { + const text = view.state.sliceDoc(view.state.selection.main.from, view.state.selection.main.to); + if (!text) return; + view.dispatch({ + effects: setSearchQuery.of(new SearchQuery({ search: text, regexp: false })), + }); + (reverse ? findPrevious : findNext)(view); +} + +// --- view scrolling ---------------------------------------------------------- + +export function scrollTo(view: EditorView, y: "center" | "start" | "end") { + const pos = view.state.selection.main.head; + view.dispatch({ effects: EditorView.scrollIntoView(pos, { y }) }); +} + +export function halfPage(view: EditorView, forward: boolean, extend: boolean) { + const lineHeight = view.defaultLineHeight || 16; + const lines = Math.max(1, Math.floor(view.dom.clientHeight / lineHeight / 2)); + moveVertical(view, forward, { extend, count: lines }); +} + +export { restMode }; diff --git a/packages/codemirror-helix/src/index.ts b/packages/codemirror-helix/src/index.ts new file mode 100644 index 0000000..eaba805 --- /dev/null +++ b/packages/codemirror-helix/src/index.ts @@ -0,0 +1,51 @@ +import { EditorState, type Extension } from "@codemirror/state"; +import { search } from "@codemirror/search"; +import { helixState, registersField } from "./state"; +import { blockCursor, helixStatusPanel, helixTheme, modeEditorClass } from "./view"; +import { helixPrompt } from "./prompt"; +import { helixKeymap, type HelixOptions } from "./keymap"; + +export interface HelixConfig extends HelixOptions { + /** Render the bottom status line. Default: true. */ + statusBar?: boolean; + /** Start the editor in Insert mode instead of Normal. Default: false. */ + startInInsert?: boolean; +} + +/** + * Helix-style modal editing for CodeMirror 6. + * + * Add to a CodeMirror instance's `extensions`. Requires a selection drawer + * (CodeMirror's `basicSetup` or `drawSelection()`) for multi-cursor rendering. + * + * ```ts + * import { basicSetup } from "codemirror"; + * import { helix } from "codemirror-helix"; + * + * new EditorView({ extensions: [basicSetup, helix()], parent }); + * ``` + */ +export function helix(config: HelixConfig = {}): Extension { + return [ + EditorState.allowMultipleSelections.of(true), + helixState.init(() => ({ + mode: config.startInInsert ? "insert" : "normal", + pending: null, + count: 0, + register: null, + lastFind: null, + })), + registersField, + search(), + helixKeymap(config), + blockCursor, + modeEditorClass, + helixTheme, + ...(config.statusBar === false ? [] : [helixStatusPanel]), + helixPrompt, + ]; +} + +export { getMode, getHelix, helixState } from "./state"; +export type { HelixMode, HelixStateValue } from "./state"; +export type { HelixOptions } from "./keymap"; diff --git a/packages/codemirror-helix/src/keymap.ts b/packages/codemirror-helix/src/keymap.ts new file mode 100644 index 0000000..57d6e7d --- /dev/null +++ b/packages/codemirror-helix/src/keymap.ts @@ -0,0 +1,562 @@ +import { EditorSelection, Prec, type EditorState, type Extension } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { + clearPending, + getHelix, + helixEffect, + type HelixStateValue, + type Pending, +} from "./state"; +import * as C from "./commands"; +import * as M from "./motions"; +import { textObject } from "./textobjects"; +import { openPrompt } from "./prompt"; + +export interface HelixOptions { + /** + * Return true to let a pressed Escape fall through to other handlers instead + * of leaving Insert mode — e.g. when an autocompletion popup is open. + */ + escapeGuard?: (state: EditorState) => boolean; +} + +function reset(view: EditorView, extra: Partial = {}) { + view.dispatch({ effects: helixEffect.of({ ...clearPending(), ...extra }) }); +} + +function setPending(view: EditorView, pending: Pending) { + view.dispatch({ effects: helixEffect.of({ pending }) }); +} + +// --- find char --------------------------------------------------------------- + +function doFind(view: EditorView, kind: "f" | "t" | "F" | "T", char: string, helix: HelixStateValue) { + const fn: C.PointFn = (state, pos) => { + const found = M.findCharOnLine(state, pos, char, kind); + return found === null ? pos : found; + }; + C.moveByPoint(view, fn, { extend: helix.mode === "select", span: false, count: helix.count || 1 }); +} + +// --- goto mode --------------------------------------------------------------- + +function gotoAbsolute(view: EditorView, pos: number, select: boolean) { + const anchor = select ? view.state.selection.main.anchor : pos; + view.dispatch({ selection: EditorSelection.range(anchor, pos), scrollIntoView: true }); +} + +function doGoto(view: EditorView, key: string, helix: HelixStateValue) { + const select = helix.mode === "select"; + const { state } = view; + switch (key) { + case "h": + C.moveByPoint(view, (s, p) => s.doc.lineAt(p).from, { extend: select, span: false, count: 1 }); + break; + case "l": + C.moveByPoint(view, (s, p) => s.doc.lineAt(p).to, { extend: select, span: false, count: 1 }); + break; + case "s": + C.moveByPoint(view, (s, p) => M.firstNonBlank(s, s.doc.lineAt(p).number), { + extend: select, + span: false, + count: 1, + }); + break; + case "g": + gotoAbsolute(view, helix.count > 0 ? state.doc.line(Math.min(helix.count, state.doc.lines)).from : 0, select); + break; + case "e": + gotoAbsolute(view, state.doc.length, select); + break; + case "t": + gotoAbsolute(view, state.doc.lineAt(view.viewport.from).from, select); + break; + case "b": + gotoAbsolute(view, state.doc.lineAt(view.viewport.to).from, select); + break; + case "c": + gotoAbsolute(view, state.doc.lineAt(Math.floor((view.viewport.from + view.viewport.to) / 2)).from, select); + break; + default: + break; + } +} + +// --- match mode -------------------------------------------------------------- + +function doMatch(view: EditorView, key: string, helix: HelixStateValue) { + switch (key) { + case "m": + C.moveByPoint( + view, + (s, p) => { + const m = M.matchingBracket(s, p); + return m === null ? p : m; + }, + { extend: helix.mode === "select", span: false, count: 1 }, + ); + reset(view); + return; + case "i": + setPending(view, { type: "textobject", around: false }); + return; + case "a": + setPending(view, { type: "textobject", around: true }); + return; + case "s": + setPending(view, { type: "surround-add" }); + return; + case "d": + setPending(view, { type: "surround-delete" }); + return; + case "r": + setPending(view, { type: "surround-replace" }); + return; + default: + reset(view); + } +} + +function doTextObject(view: EditorView, key: string, around: boolean) { + const { state } = view; + const ranges = state.selection.ranges.map((r) => { + const span = textObject(state, r, key, around); + return span ? EditorSelection.range(span.from, span.to) : r; + }); + view.dispatch({ + selection: EditorSelection.create(ranges, state.selection.mainIndex), + scrollIntoView: true, + }); +} + +// --- view mode (z) ----------------------------------------------------------- + +function doView(view: EditorView, key: string) { + if (key === "z") C.scrollTo(view, "center"); + else if (key === "t") C.scrollTo(view, "start"); + else if (key === "b") C.scrollTo(view, "end"); +} + +// --- space menu (clipboard subset) ------------------------------------------ + +function doSpace(view: EditorView, key: string) { + const text = view.state.sliceDoc(view.state.selection.main.from, view.state.selection.main.to); + if (key === "y") { + if (typeof navigator !== "undefined" && navigator.clipboard && text) { + void navigator.clipboard.writeText(text).catch(() => {}); + } + } else if (key === "p" || key === "P") { + if (typeof navigator !== "undefined" && navigator.clipboard) { + void navigator.clipboard.readText().then((clip) => { + if (!clip) return; + const changes = view.state.selection.ranges.map((r) => { + const at = key === "P" ? r.from : r.to; + return { from: at, to: at, insert: clip }; + }); + const set = view.state.changes(changes); + view.dispatch({ changes: set, selection: view.state.selection.map(set) }); + }); + } + } +} + +// --- pending dispatch -------------------------------------------------------- + +function handlePending(view: EditorView, pending: Pending, key: string, helix: HelixStateValue) { + switch (pending.type) { + case "register": + if (key.length === 1) view.dispatch({ effects: helixEffect.of({ register: key, pending: null }) }); + else reset(view); + return; + case "find": + if (key.length === 1) { + doFind(view, pending.kind, key, helix); + reset(view, { lastFind: { kind: pending.kind, char: key } }); + } else reset(view); + return; + case "replace": + if (key.length === 1) C.replaceChar(view, key); + reset(view); + return; + case "g": + doGoto(view, key, helix); + reset(view); + return; + case "z": + doView(view, key); + reset(view); + return; + case "space": + doSpace(view, key); + reset(view); + return; + case "m": + doMatch(view, key, helix); + return; + case "textobject": + doTextObject(view, key, pending.around); + reset(view); + return; + case "surround-add": + if (key.length === 1) C.surroundAdd(view, key); + reset(view); + return; + case "surround-delete": + if (key.length === 1) C.surroundDelete(view, key); + reset(view); + return; + case "surround-replace": + if (pending.from === undefined) { + view.dispatch({ effects: helixEffect.of({ pending: { type: "surround-replace", from: key } }) }); + } else { + C.surroundReplace(view, pending.from, key); + reset(view); + } + return; + } +} + +// --- terminal commands ------------------------------------------------------- + +function handleAlt(view: EditorView, key: string, helix: HelixStateValue): boolean { + switch (key) { + case ";": + C.flipSelections(view); + return true; + case ":": + C.ensureForward(view); + return true; + case ",": + C.removePrimary(view); + return true; + case "c": + case "C": + C.copySelectionToLine(view, false); + return true; + case "`": + C.changeCase(view, "upper"); + return true; + case ".": + if (helix.lastFind) doFind(view, helix.lastFind.kind, helix.lastFind.char, helix); + return true; + case "k": + case "K": { + const base = view.state.selection; + openPrompt(view, { + label: "remove:", + onChange: (p) => C.filterSelections(view, p, false, base), + onSubmit: (p) => C.filterSelections(view, p, false, base), + onCancel: () => view.dispatch({ selection: base }), + }); + return true; + } + default: + return false; + } +} + +function handleCommand(view: EditorView, key: string, event: KeyboardEvent, helix: HelixStateValue): boolean { + const extend = helix.mode === "select"; + const count = helix.count || 1; + + if (event.altKey) return handleAlt(view, key, helix); + + switch (key) { + // movement + case "h": + case "ArrowLeft": + C.moveByPoint(view, (_s, p) => Math.max(0, p - 1), { extend, span: false, count }); + return true; + case "l": + case "ArrowRight": + C.moveByPoint(view, (s, p) => Math.min(s.doc.length, p + 1), { extend, span: false, count }); + return true; + case "j": + case "ArrowDown": + C.moveVertical(view, true, { extend, count }); + return true; + case "k": + case "ArrowUp": + C.moveVertical(view, false, { extend, count }); + return true; + case "w": + C.moveByPoint(view, (s, p) => M.nextWordStart(s, p, false), { extend, span: true, count }); + return true; + case "W": + C.moveByPoint(view, (s, p) => M.nextWordStart(s, p, true), { extend, span: true, count }); + return true; + case "b": + C.moveByPoint(view, (s, p) => M.prevWordStart(s, p, false), { extend, span: true, count }); + return true; + case "B": + C.moveByPoint(view, (s, p) => M.prevWordStart(s, p, true), { extend, span: true, count }); + return true; + case "e": + C.moveByPoint(view, (s, p) => M.nextWordEnd(s, p, false), { extend, span: true, count }); + return true; + case "E": + C.moveByPoint(view, (s, p) => M.nextWordEnd(s, p, true), { extend, span: true, count }); + return true; + case "Home": + C.moveByPoint(view, (s, p) => s.doc.lineAt(p).from, { extend, span: false, count: 1 }); + return true; + case "End": + C.moveByPoint(view, (s, p) => s.doc.lineAt(p).to, { extend, span: false, count: 1 }); + return true; + + // selection manipulation + case "x": + C.selectLine(view, count); + return true; + case "X": + C.extendToLineBounds(view); + return true; + case "%": + C.selectAll(view); + return true; + case ";": + C.collapseToCursor(view); + return true; + case ",": + C.keepPrimary(view); + return true; + case "(": + C.rotatePrimary(view, false); + return true; + case ")": + C.rotatePrimary(view, true); + return true; + case "_": + C.trimSelections(view); + return true; + case "C": + C.copySelectionToLine(view, true); + return true; + + // mode switches + case "v": + view.dispatch({ effects: helixEffect.of({ mode: extend ? "normal" : "select" }) }); + return true; + case "i": + C.enterInsert(view, (_s, r) => r.from); + return true; + case "a": + C.enterInsert(view, (_s, r) => r.to); + return true; + case "I": + C.enterInsert(view, (s, r) => M.firstNonBlank(s, s.doc.lineAt(r.head).number)); + return true; + case "A": + C.enterInsert(view, (s, r) => s.doc.lineAt(r.head).to); + return true; + case "o": + C.openLine(view, false); + return true; + case "O": + C.openLine(view, true); + return true; + + // edits + case "d": + case "Delete": + C.deleteSelection(view); + return true; + case "c": + C.deleteSelection(view, { insert: true }); + return true; + case "y": + C.yankSelection(view); + return true; + case "p": + C.paste(view, false); + return true; + case "P": + C.paste(view, true); + return true; + case "R": + C.replaceWithYank(view); + return true; + case "~": + C.changeCase(view, "toggle"); + return true; + case "`": + C.changeCase(view, "lower"); + return true; + case "J": + C.joinLines(view); + return true; + case ">": + C.indent(view, false); + return true; + case "<": + C.indent(view, true); + return true; + case "u": + C.undoCmd(view); + return true; + case "U": + C.redoCmd(view); + return true; + + // search + case "/": + openPrompt(view, { label: "/", onSubmit: (q) => C.runSearch(view, q, false) }); + return true; + case "?": + openPrompt(view, { label: "?", onSubmit: (q) => C.runSearch(view, q, true) }); + return true; + case "n": + C.searchNext(view, false); + return true; + case "N": + C.searchNext(view, true); + return true; + case "*": + C.searchSelection(view, false); + return true; + + // regex selection (live preview against the selection captured here) + case "s": { + const base = view.state.selection; + openPrompt(view, { + label: "select:", + onChange: (p) => C.selectRegexInSelections(view, p, base), + onSubmit: (p) => C.selectRegexInSelections(view, p, base), + onCancel: () => view.dispatch({ selection: base }), + }); + return true; + } + case "S": { + const base = view.state.selection; + openPrompt(view, { + label: "split:", + onChange: (p) => C.splitOnRegex(view, p, base), + onSubmit: (p) => C.splitOnRegex(view, p, base), + onCancel: () => view.dispatch({ selection: base }), + }); + return true; + } + case "K": { + const base = view.state.selection; + openPrompt(view, { + label: "keep:", + onChange: (p) => C.filterSelections(view, p, true, base), + onSubmit: (p) => C.filterSelections(view, p, true, base), + onCancel: () => view.dispatch({ selection: base }), + }); + return true; + } + + case "Escape": + C.collapseToCursor(view); + view.dispatch({ effects: helixEffect.of({ mode: "normal", ...clearPending() }) }); + return true; + + default: + return false; + } +} + +function handleCtrl(view: EditorView, key: string, helix: HelixStateValue): boolean { + const extend = helix.mode === "select"; + switch (key) { + case "d": + C.halfPage(view, true, extend); + return true; + case "u": + C.halfPage(view, false, extend); + return true; + case "f": + C.moveVertical(view, true, { extend, count: 20 }); + return true; + case "b": + C.moveVertical(view, false, { extend, count: 20 }); + return true; + default: + return false; + } +} + +// --- top-level keydown ------------------------------------------------------- + +export function helixKeymap(options: HelixOptions = {}): Extension { + return Prec.highest( + EditorView.domEventHandlers({ + keydown(event, view) { + if (event.isComposing) return false; + const helix = getHelix(view.state); + + if (helix.mode === "insert") { + if (event.key === "Escape") { + if (options.escapeGuard?.(view.state)) return false; + view.dispatch({ effects: helixEffect.of({ mode: "normal", ...clearPending() }) }); + event.preventDefault(); + return true; + } + return false; + } + + if (event.metaKey) return false; + + const key = event.key; + + if (helix.pending) { + event.preventDefault(); + handlePending(view, helix.pending, key, helix); + return true; + } + + if (event.ctrlKey) { + if (handleCtrl(view, key, helix)) { + event.preventDefault(); + reset(view); + return true; + } + return false; + } + + // count digits (0 only continues an existing count) + if (!event.altKey && /^[0-9]$/.test(key) && !(key === "0" && helix.count === 0)) { + event.preventDefault(); + view.dispatch({ effects: helixEffect.of({ count: helix.count * 10 + Number(key) }) }); + return true; + } + + // prefixes awaiting another key + if (!event.altKey) { + const prefix: Record = { + g: { type: "g" }, + m: { type: "m" }, + z: { type: "z" }, + " ": { type: "space" }, + '"': { type: "register" }, + f: { type: "find", kind: "f" }, + t: { type: "find", kind: "t" }, + F: { type: "find", kind: "F" }, + T: { type: "find", kind: "T" }, + r: { type: "replace" }, + }; + const pending = prefix[key]; + if (pending) { + setPending(view, pending); + event.preventDefault(); + return true; + } + } + + if (handleCommand(view, key, event, helix)) { + event.preventDefault(); + reset(view); + return true; + } + + // Swallow stray printable keys so Normal/Select mode never types. + if (key.length === 1 || ["Enter", "Backspace", "Tab", "Delete"].includes(key)) { + event.preventDefault(); + return true; + } + return false; + }, + }), + ); +} diff --git a/packages/codemirror-helix/src/motions.ts b/packages/codemirror-helix/src/motions.ts new file mode 100644 index 0000000..594de63 --- /dev/null +++ b/packages/codemirror-helix/src/motions.ts @@ -0,0 +1,180 @@ +import type { EditorState } from "@codemirror/state"; + +export type CharClass = "space" | "word" | "punct"; + +export function charAt(state: EditorState, i: number): string { + return i >= 0 && i < state.doc.length ? state.doc.sliceString(i, i + 1) : ""; +} + +export function classOf(ch: string): CharClass { + if (ch === "" || /\s/.test(ch)) return "space"; + if (/[\p{L}\p{N}_]/u.test(ch)) return "word"; + return "punct"; +} + +// For WORD (big) motions only whitespace separates tokens. +function bigClassOf(ch: string): CharClass { + return ch === "" || /\s/.test(ch) ? "space" : "word"; +} + +function classAt(state: EditorState, i: number, big: boolean): CharClass { + const ch = charAt(state, i); + return big ? bigClassOf(ch) : classOf(ch); +} + +/** Helix `w`/`W`: move to the start of the next word. */ +export function nextWordStart(state: EditorState, pos: number, big: boolean): number { + const len = state.doc.length; + let i = pos; + if (i >= len) return len; + if (classAt(state, i, big) === "space") { + while (i < len && classAt(state, i, big) === "space") i++; + } else { + const cls = classAt(state, i, big); + while (i < len && classAt(state, i, big) === cls) i++; + while (i < len && classAt(state, i, big) === "space") i++; + } + return i; +} + +/** Helix `b`/`B`: move to the start of the previous word. */ +export function prevWordStart(state: EditorState, pos: number, big: boolean): number { + let i = pos; + if (i <= 0) return 0; + i--; + while (i > 0 && classAt(state, i, big) === "space") i--; + const cls = classAt(state, i, big); + while (i > 0 && classAt(state, i - 1, big) === cls) i--; + return i; +} + +/** Helix `e`/`E`: move to the end of the next word. */ +export function nextWordEnd(state: EditorState, pos: number, big: boolean): number { + const len = state.doc.length; + let i = pos + 1; + while (i < len && classAt(state, i, big) === "space") i++; + const cls = classAt(state, i, big); + while (i < len && classAt(state, i, big) === cls) i++; + return Math.min(i, len); +} + +export function firstNonBlank(state: EditorState, line: number): number { + const l = state.doc.line(line); + return l.from + (l.text.match(/^\s*/)?.[0].length ?? 0); +} + +/** f/t/F/T: find `target` from `pos`. Returns null if not found on the line. */ +export function findCharOnLine( + state: EditorState, + pos: number, + target: string, + kind: "f" | "t" | "F" | "T", +): number | null { + const line = state.doc.lineAt(pos); + const forward = kind === "f" || kind === "t"; + const till = kind === "t" || kind === "T"; + + if (forward) { + let i = pos + 1; + // `t` should skip an adjacent target so repeated presses advance. + if (till && charAt(state, i) === target) i++; + for (; i <= line.to; i++) { + if (charAt(state, i) === target) return till ? i - 1 : i; + } + } else { + let i = pos - 1; + if (till && charAt(state, i) === target) i--; + for (; i >= line.from; i--) { + if (charAt(state, i) === target) return till ? i + 1 : i; + } + } + return null; +} + +const PAIRS: Record = { + "(": { open: "(", close: ")" }, + ")": { open: "(", close: ")" }, + "[": { open: "[", close: "]" }, + "]": { open: "[", close: "]" }, + "{": { open: "{", close: "}" }, + "}": { open: "{", close: "}" }, + "<": { open: "<", close: ">" }, + ">": { open: "<", close: ">" }, +}; + +export function pairFor(ch: string): { open: string; close: string } | null { + return PAIRS[ch] ?? null; +} + +/** Scan forward for the close that matches an open at/after `pos` (nesting-aware). */ +export function findClose(state: EditorState, from: number, open: string, close: string): number | null { + const len = state.doc.length; + let depth = 0; + for (let i = from; i < len; i++) { + const ch = charAt(state, i); + if (ch === open) depth++; + else if (ch === close) { + depth--; + if (depth === 0) return i; + } + } + return null; +} + +/** Scan backward for the open that matches a close at/before `pos` (nesting-aware). */ +export function findOpen(state: EditorState, from: number, open: string, close: string): number | null { + let depth = 0; + for (let i = from; i >= 0; i--) { + const ch = charAt(state, i); + if (ch === close) depth++; + else if (ch === open) { + depth--; + if (depth === 0) return i; + } + } + return null; +} + +/** `mm`: position of the bracket matching the one at/under `pos`, or null. */ +export function matchingBracket(state: EditorState, pos: number): number | null { + for (const probe of [pos, pos - 1]) { + const ch = charAt(state, probe); + const pair = pairFor(ch); + if (!pair) continue; + if (ch === pair.open) return findClose2(state, probe, pair.open, pair.close); + return findOpen2(state, probe, pair.open, pair.close); + } + return null; +} + +function findClose2(state: EditorState, openPos: number, open: string, close: string): number | null { + const len = state.doc.length; + let depth = 0; + for (let i = openPos; i < len; i++) { + const ch = charAt(state, i); + if (ch === open) depth++; + else if (ch === close && --depth === 0) return i; + } + return null; +} + +function findOpen2(state: EditorState, closePos: number, open: string, close: string): number | null { + let depth = 0; + for (let i = closePos; i >= 0; i--) { + const ch = charAt(state, i); + if (ch === close) depth++; + else if (ch === open && --depth === 0) return i; + } + return null; +} + +/** Paragraph bounds (blank-line delimited) containing `pos`. */ +export function paragraphAt(state: EditorState, pos: number): { from: number; to: number } { + const { doc } = state; + let startLine = doc.lineAt(pos).number; + let endLine = startLine; + const blank = (n: number) => doc.line(n).text.trim() === ""; + while (startLine > 1 && !blank(startLine - 1)) startLine--; + while (endLine < doc.lines && !blank(endLine + 1)) endLine++; + return { from: doc.line(startLine).from, to: doc.line(endLine).to }; +} diff --git a/packages/codemirror-helix/src/prompt.ts b/packages/codemirror-helix/src/prompt.ts new file mode 100644 index 0000000..8d15788 --- /dev/null +++ b/packages/codemirror-helix/src/prompt.ts @@ -0,0 +1,83 @@ +import { StateEffect, StateField } from "@codemirror/state"; +import { EditorView, showPanel, type Panel } from "@codemirror/view"; + +export interface PromptConfig { + label: string; + initial?: string; + onSubmit: (value: string, view: EditorView) => void; + onChange?: (value: string, view: EditorView) => void; + onCancel?: (view: EditorView) => void; +} + +const openPromptEffect = StateEffect.define(); +const closePromptEffect = StateEffect.define(); + +const promptState = StateField.define({ + create: () => null, + update(value, tr) { + for (const effect of tr.effects) { + if (effect.is(openPromptEffect)) return effect.value; + if (effect.is(closePromptEffect)) return null; + } + return value; + }, +}); + +export function openPrompt(view: EditorView, config: PromptConfig) { + view.dispatch({ effects: openPromptEffect.of(config) }); + // Focus after the panel mounts. + requestAnimationFrame(() => { + const input = view.dom.querySelector(".cm-helix-prompt input"); + input?.focus(); + input?.select(); + }); +} + +function closePrompt(view: EditorView) { + view.dispatch({ effects: closePromptEffect.of(null) }); + view.focus(); +} + +function promptPanel(view: EditorView): Panel { + const config = view.state.field(promptState); + const dom = document.createElement("form"); + dom.className = "cm-helix-prompt"; + + const label = document.createElement("label"); + label.textContent = config?.label ?? ""; + const input = document.createElement("input"); + input.type = "text"; + input.value = config?.initial ?? ""; + input.spellcheck = false; + input.autocomplete = "off"; + + dom.append(label, input); + + input.addEventListener("input", () => { + view.state.field(promptState)?.onChange?.(input.value, view); + }); + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + const current = view.state.field(promptState); + closePrompt(view); + current?.onSubmit(input.value, view); + } else if (event.key === "Escape") { + event.preventDefault(); + const current = view.state.field(promptState); + closePrompt(view); + current?.onCancel?.(view); + } + }); + + return { dom, top: false }; +} + +export const helixPrompt = [ + promptState, + showPanel.from(promptState, (config) => (config ? promptPanel : null)), +]; + +export function isPromptOpen(view: EditorView): boolean { + return view.state.field(promptState) !== null; +} diff --git a/packages/codemirror-helix/src/state.ts b/packages/codemirror-helix/src/state.ts new file mode 100644 index 0000000..2619b42 --- /dev/null +++ b/packages/codemirror-helix/src/state.ts @@ -0,0 +1,87 @@ +import { StateEffect, StateField, type EditorState } from "@codemirror/state"; + +export type HelixMode = "normal" | "insert" | "select"; + +/** A multi-key prefix or a command waiting for one more keystroke. */ +export type Pending = + | { type: "g" } // goto mode + | { type: "m" } // match mode + | { type: "z" } // view mode + | { type: "space" } // space menu (minimal) + | { type: "find"; kind: "f" | "t" | "F" | "T" } // awaiting target char + | { type: "replace" } // r{char} + | { type: "register" } // "{char} + | { type: "textobject"; around: boolean } // mi{o} / ma{o} + | { type: "surround-add" } // ms{char} + | { type: "surround-delete" } // md{char} + | { type: "surround-replace"; from?: string }; // mr{from}{to} + +export interface LastFind { + kind: "f" | "t" | "F" | "T"; + char: string; +} + +export interface HelixStateValue { + mode: HelixMode; + pending: Pending | null; + count: number; // 0 = no explicit count + register: string | null; // selected register for next yank/paste/delete + lastFind: LastFind | null; +} + +const initial: HelixStateValue = { + mode: "normal", + pending: null, + count: 0, + register: null, + lastFind: null, +}; + +/** Patch the transient Helix state. */ +export const helixEffect = StateEffect.define>(); + +export const helixState = StateField.define({ + create: () => initial, + update(value, tr) { + let next = value; + for (const effect of tr.effects) { + if (effect.is(helixEffect)) next = { ...next, ...effect.value }; + } + return next; + }, +}); + +export function getHelix(state: EditorState): HelixStateValue { + return state.field(helixState); +} + +export function getMode(state: EditorState): HelixMode { + return state.field(helixState).mode; +} + +// --- registers --------------------------------------------------------------- + +export const setRegister = StateEffect.define<{ name: string; text: string }>(); + +export const registersField = StateField.define>({ + create: () => new Map(), + update(value, tr) { + let next = value; + for (const effect of tr.effects) { + if (effect.is(setRegister)) { + next = new Map(next); + next.set(effect.value.name, effect.value.text); + } + } + return next; + }, +}); + +export function readRegister(state: EditorState, name: string | null): string { + return state.field(registersField).get(name ?? '"') ?? ""; +} + +/** Clear any pending prefix / count / register selection. */ +export function clearPending(): Partial { + return { pending: null, count: 0, register: null }; +} diff --git a/packages/codemirror-helix/src/textobjects.ts b/packages/codemirror-helix/src/textobjects.ts new file mode 100644 index 0000000..6b0abb3 --- /dev/null +++ b/packages/codemirror-helix/src/textobjects.ts @@ -0,0 +1,127 @@ +import type { EditorState, SelectionRange } from "@codemirror/state"; +import { charAt, classOf, findClose, findOpen, pairFor, paragraphAt } from "./motions"; + +export interface Span { + from: number; + to: number; +} + +function wordSpan(state: EditorState, pos: number, around: boolean): Span | null { + const cls = classOf(charAt(state, pos)); + if (cls === "space") return null; + let from = pos; + let to = pos; + while (from > 0 && classOf(charAt(state, from - 1)) === cls) from--; + while (to < state.doc.length && classOf(charAt(state, to)) === cls) to++; + if (around) { + const start = to; + while (to < state.doc.length && classOf(charAt(state, to)) === "space") to++; + if (to === start) while (from > 0 && classOf(charAt(state, from - 1)) === "space") from--; + } + return { from, to }; +} + +function pairSpan( + state: EditorState, + pos: number, + open: string, + close: string, + around: boolean, +): Span | null { + // If we sit on a bracket, anchor the search to it. + const here = charAt(state, pos); + const openPos = + here === open ? pos : findOpen(state, here === close ? pos - 1 : pos, open, close); + if (openPos === null) return null; + const closePos = findClose(state, openPos, open, close); + if (closePos === null) return null; + return around ? { from: openPos, to: closePos + 1 } : { from: openPos + 1, to: closePos }; +} + +function quoteSpan(state: EditorState, pos: number, quote: string, around: boolean): Span | null { + const line = state.doc.lineAt(pos); + const positions: number[] = []; + for (let i = line.from; i <= line.to; i++) if (charAt(state, i) === quote) positions.push(i); + for (let i = 0; i + 1 < positions.length; i += 2) { + const a = positions[i]!; + const b = positions[i + 1]!; + if (pos >= a && pos <= b + 1) { + return around ? { from: a, to: b + 1 } : { from: a + 1, to: b }; + } + } + return null; +} + +function paragraphSpan(state: EditorState, pos: number, around: boolean): Span { + const span = paragraphAt(state, pos); + if (!around) return span; + let to = span.to; + while (to < state.doc.length && state.doc.lineAt(to + 1).text.trim() === "") { + to = state.doc.lineAt(to + 1).to; + } + return { from: span.from, to }; +} + +const QUOTES = new Set(['"', "'", "`"]); + +/** Innermost enclosing bracket/quote pair (Helix `mi m` / `ma m`). */ +function nearestPair(state: EditorState, pos: number, around: boolean): Span | null { + const candidates: Span[] = []; + for (const ch of ["(", "[", "{", "<"]) { + const span = pairSpan(state, pos, ch, pairFor(ch)!.close, around); + if (span) candidates.push(span); + } + for (const q of QUOTES) { + const span = quoteSpan(state, pos, q, around); + if (span) candidates.push(span); + } + if (!candidates.length) return null; + // Smallest enclosing span wins. + return candidates.sort((a, b) => b.from - a.from || a.to - b.to)[0]!; +} + +/** + * Resolve a Helix textobject for `mi{obj}` / `ma{obj}`. + * Returns null when nothing sensible encloses the cursor. + */ +export function textObject( + state: EditorState, + range: SelectionRange, + obj: string, + around: boolean, +): Span | null { + const pos = range.head > range.from ? range.head - 1 : range.head; + switch (obj) { + case "w": + case "W": + return wordSpan(state, range.head, around); + case "p": + return paragraphSpan(state, range.head, around); + case "(": + case ")": + case "b": + return pairSpan(state, pos, "(", ")", around); + case "{": + case "}": + case "B": + return pairSpan(state, pos, "{", "}", around); + case "[": + case "]": + return pairSpan(state, pos, "[", "]", around); + case "<": + case ">": + return pairSpan(state, pos, "<", ">", around); + case '"': + return quoteSpan(state, range.head, '"', around); + case "'": + return quoteSpan(state, range.head, "'", around); + case "`": + return quoteSpan(state, range.head, "`", around); + case "m": + return nearestPair(state, pos, around); + default: + return null; + } +} + +export { pairFor } from "./motions"; diff --git a/packages/codemirror-helix/src/view.ts b/packages/codemirror-helix/src/view.ts new file mode 100644 index 0000000..740b3a3 --- /dev/null +++ b/packages/codemirror-helix/src/view.ts @@ -0,0 +1,123 @@ +import { + Decoration, + EditorView, + ViewPlugin, + WidgetType, + showPanel, + type DecorationSet, + type Panel, + type ViewUpdate, +} from "@codemirror/view"; +import { getMode, helixState } from "./state"; + +class EolCursorWidget extends WidgetType { + toDOM() { + const span = document.createElement("span"); + span.className = "cm-helix-block cm-helix-block-eol"; + span.textContent = " "; + return span; + } + ignoreEvent() { + return true; + } +} + +const blockMark = Decoration.mark({ class: "cm-helix-block" }); +const selectionMark = Decoration.mark({ class: "cm-helix-selection" }); +const eolCursor = Decoration.widget({ widget: new EolCursorWidget(), side: 1 }); + +// Draws the selection ranges + a block cursor at each head while in +// normal/select mode. We render the selection ourselves (rather than relying +// on drawSelection, which the host theme can leave invisible) so multi-cursor +// selections are always clearly highlighted. +export const blockCursor = ViewPlugin.fromClass( + class { + decorations: DecorationSet; + constructor(view: EditorView) { + this.decorations = this.build(view); + } + update(update: ViewUpdate) { + if ( + update.docChanged || + update.selectionSet || + update.viewportChanged || + update.startState.field(helixState).mode !== update.state.field(helixState).mode + ) { + this.decorations = this.build(update.view); + } + } + build(view: EditorView): DecorationSet { + if (getMode(view.state) === "insert") return Decoration.none; + const deco: ReturnType[] = []; + for (const range of view.state.selection.ranges) { + if (!range.empty) deco.push(selectionMark.range(range.from, range.to)); + const pos = range.head; + const line = view.state.doc.lineAt(pos); + deco.push(pos < line.to ? blockMark.range(pos, pos + 1) : eolCursor.range(pos)); + } + return Decoration.set(deco, true); + } + }, + { decorations: (plugin) => plugin.decorations }, +); + +export const modeEditorClass = EditorView.editorAttributes.compute([helixState], (state) => ({ + class: `cm-helix cm-helix-${getMode(state)}`, +})); + +function statusPanel(view: EditorView): Panel { + const dom = document.createElement("div"); + dom.className = "cm-helix-status"; + const render = () => { + const s = view.state.field(helixState); + const label = s.mode === "insert" ? "INS" : s.mode === "select" ? "SEL" : "NOR"; + const count = s.count ? ` ${s.count}` : ""; + const reg = s.register ? ` "${s.register}` : ""; + const pend = s.pending ? ` ${s.pending.type}` : ""; + const sels = view.state.selection.ranges.length; + const multi = sels > 1 ? ` ${sels} sels` : ""; + dom.textContent = `${label}${count}${reg}${pend}${multi}`; + dom.dataset.mode = s.mode; + }; + render(); + return { + dom, + update: (u) => { + if (u.docChanged || u.selectionSet || u.transactions.length) render(); + }, + }; +} + +export const helixStatusPanel = showPanel.of(statusPanel); + +export const helixTheme = EditorView.baseTheme({ + "&.cm-helix-normal .cm-cursor, &.cm-helix-normal .cm-cursorLayer": { display: "none" }, + "&.cm-helix-select .cm-cursor, &.cm-helix-select .cm-cursorLayer": { display: "none" }, + ".cm-helix-selection": { backgroundColor: "rgba(120,160,255,0.32)" }, + ".cm-helix-block": { backgroundColor: "rgba(125,165,255,0.7)", borderRadius: "1px" }, + ".cm-helix-block-eol": { display: "inline-block", width: "0.55em" }, + ".cm-helix-status": { + padding: "1px 10px", + font: "11px ui-monospace, SFMono-Regular, Menlo, monospace", + letterSpacing: "0.05em", + }, + ".cm-helix-status[data-mode=insert]": { color: "#16a34a" }, + ".cm-helix-status[data-mode=select]": { color: "#d97706" }, + ".cm-helix-status[data-mode=normal]": { color: "#2563eb" }, + ".cm-helix-prompt": { + display: "flex", + gap: "0.5em", + alignItems: "center", + padding: "2px 10px", + font: "12px ui-monospace, SFMono-Regular, Menlo, monospace", + }, + ".cm-helix-prompt label": { opacity: 0.7 }, + ".cm-helix-prompt input": { + flex: "1", + border: "none", + outline: "none", + background: "transparent", + color: "inherit", + font: "inherit", + }, +}); diff --git a/packages/codemirror-helix/tsconfig.json b/packages/codemirror-helix/tsconfig.json new file mode 100644 index 0000000..8855c32 --- /dev/null +++ b/packages/codemirror-helix/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"] +} diff --git a/src/app/_components/Form/Fields/MdeFormField.tsx b/src/app/_components/Form/Fields/MdeFormField.tsx index 2048bf8..9227ab5 100644 --- a/src/app/_components/Form/Fields/MdeFormField.tsx +++ b/src/app/_components/Form/Fields/MdeFormField.tsx @@ -1,58 +1,72 @@ "use client"; -import MDEditor from "@uiw/react-md-editor"; +import CodeMirror from "@uiw/react-codemirror"; +import { markdown } from "@codemirror/lang-markdown"; +import { autocompletion, completionStatus } from "@codemirror/autocomplete"; +import { EditorView } from "@codemirror/view"; +import { helix } from "codemirror-helix"; import { Maximize2, Minimize2 } from "lucide-react"; -import { useEffect, useState, type ReactElement, type TextareaHTMLAttributes } from "react"; +import { useEffect, useMemo, useState, type ReactElement } from "react"; import { createPortal } from "react-dom"; import type { Control, FieldValues, Path } from "react-hook-form"; import { FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form"; import { Button } from "~/components/ui/button"; import { cn } from "~/lib/utils"; -import { ClientMdx } from "~/components/ClientMdx"; import { - InternalLinkTextarea, type AutocompleteTriggerConfig, type MdeAutocompleteSuggestion, } from "./InternalLinkTextarea"; +import { mdxCompletionSource } from "./codemirror/mdxAutocomplete"; export default function MdeFormField(params: { - control: Control, - name: Path, - label: string, - dataColorMode: "dark"|"light", - autocompleteSuggestions?: MdeAutocompleteSuggestion[], - triggerConfigs?: AutocompleteTriggerConfig[], - renderPreview?: (source: string) => ReactElement, + control: Control; + name: Path; + label: string; + dataColorMode: "dark" | "light"; + autocompleteSuggestions?: MdeAutocompleteSuggestion[]; + triggerConfigs?: AutocompleteTriggerConfig[]; + renderPreview?: (source: string) => ReactElement; }) { - const [fullscreen, setFullscreen] = useState(false) - const [mounted, setMounted] = useState(false) + const [fullscreen, setFullscreen] = useState(false); + const [mounted, setMounted] = useState(false); useEffect(() => { - setMounted(true) - }, []) + setMounted(true); + }, []); useEffect(() => { - if (!fullscreen) return - - const originalOverflow = document.body.style.overflow - document.body.style.overflow = "hidden" - + if (!fullscreen) return; + const originalOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; return () => { - document.body.style.overflow = originalOverflow - } - }, [fullscreen]) + document.body.style.overflow = originalOverflow; + }; + }, [fullscreen]); + + const extensions = useMemo( + () => [ + markdown(), + EditorView.lineWrapping, + helix({ escapeGuard: (state) => completionStatus(state) === "active" }), + autocompletion({ + override: [mdxCompletionSource(params.autocompleteSuggestions ?? [], params.triggerConfigs)], + activateOnTyping: true, + defaultKeymap: true, + }), + ], + [params.autocompleteSuggestions, params.triggerConfigs], + ); return ( { + const source: string = field.value ?? ""; const editor = (
- - {params.label} - + {params.label}
- command.name === "fullscreen" ? false : command} - components={{ - textarea: (props) => ( - )} - suggestions={params.autocompleteSuggestions ?? []} - triggerConfigs={params.triggerConfigs} - /> - ), - preview: params.renderPreview - ? (source) => params.renderPreview?.(source) ?? <> - : (source) => , - }} - /> +
+
+ field.onChange(value)} + extensions={extensions} + theme={params.dataColorMode === "dark" ? "dark" : "light"} + height={fullscreen ? "calc(100vh - 96px)" : "360px"} + basicSetup={{ + lineNumbers: false, + foldGutter: false, + highlightActiveLine: false, + highlightActiveLineGutter: false, + }} + /> +
+ {params.renderPreview && ( +
+ {params.renderPreview(source)} +
+ )} +
- ) + ); if (fullscreen && mounted) { - return createPortal(editor, document.body) + return createPortal(editor, document.body); } - return editor + return editor; }} /> - ) + ); } diff --git a/src/app/_components/Form/Fields/codemirror/mdxAutocomplete.ts b/src/app/_components/Form/Fields/codemirror/mdxAutocomplete.ts new file mode 100644 index 0000000..39acbc6 --- /dev/null +++ b/src/app/_components/Form/Fields/codemirror/mdxAutocomplete.ts @@ -0,0 +1,85 @@ +import { EditorSelection } from "@codemirror/state"; +import type { Completion, CompletionSource } from "@codemirror/autocomplete"; +import { + AUTOCOMPLETE_CURSOR_MARKER, + type AutocompleteTriggerConfig, + type MdeAutocompleteSuggestion, +} from "../InternalLinkTextarea"; + +// Re-implements the textarea autocomplete (trigger tokens like `<`, `[[`, `!`) +// as a CodeMirror completion source, driven by the exact same suggestion data +// produced by `useMdxEditorFieldProps`. + +type ResolvedTrigger = { trigger: string }; + +function resolveTriggers( + suggestions: MdeAutocompleteSuggestion[], + triggerConfigs: AutocompleteTriggerConfig[] | undefined, +): (AutocompleteTriggerConfig & ResolvedTrigger)[] { + const map = new Map(); + for (const config of triggerConfigs ?? []) map.set(config.trigger, config); + for (const suggestion of suggestions) { + if (!map.has(suggestion.trigger)) { + map.set(suggestion.trigger, { trigger: suggestion.trigger, label: suggestion.trigger }); + } + } + // Longer triggers first so `[[` wins over a hypothetical `[`. + return Array.from(map.values()).sort((a, b) => b.trigger.length - a.trigger.length); +} + +function toCompletion(suggestion: MdeAutocompleteSuggestion, triggerStart: number): Completion { + const group = suggestion.group.toLowerCase(); + const type = group === "component" ? "class" : group === "markdown" ? "keyword" : "variable"; + return { + label: suggestion.label, + detail: suggestion.detail, + type, + apply: (view, _completion, _from, to) => { + const markerIndex = suggestion.value.indexOf(AUTOCOMPLETE_CURSOR_MARKER); + const inserted = + markerIndex === -1 ? suggestion.value : suggestion.value.replace(AUTOCOMPLETE_CURSOR_MARKER, ""); + const cursor = triggerStart + (markerIndex === -1 ? inserted.length : markerIndex); + view.dispatch({ + changes: { from: triggerStart, to, insert: inserted }, + selection: EditorSelection.cursor(cursor), + }); + }, + }; +} + +export function mdxCompletionSource( + suggestions: MdeAutocompleteSuggestion[], + triggerConfigs?: AutocompleteTriggerConfig[], +): CompletionSource { + const triggers = resolveTriggers(suggestions, triggerConfigs); + + return (context) => { + const before = context.state.sliceDoc(0, context.pos); + + const active = triggers + .map((config) => ({ config, start: before.lastIndexOf(config.trigger) })) + .filter((candidate) => candidate.start !== -1) + .sort((a, b) => b.start - a.start)[0]; + + if (!active) return null; + + const queryStart = active.start + active.config.trigger.length; + const query = before.slice(queryStart); + if (query.includes("\n")) return null; + if (active.config.isQueryValid && !active.config.isQueryValid(query)) return null; + + const options = suggestions + .filter((suggestion) => suggestion.trigger === active.config.trigger) + .map((suggestion) => toCompletion(suggestion, active.start)); + + if (!options.length) return null; + + return { + from: queryStart, + to: context.pos, + options, + // Keep the popup open while the query stays a single token. + validFor: /^[^\s>\]\)]*$/, + }; + }; +}