Compare commits
5 Commits
1100e35091
...
codemirror
| Author | SHA1 | Date | |
|---|---|---|---|
| 281c3d02f2 | |||
| 781e03c50c | |||
| 81c60feed9 | |||
| 0fe62fcce5 | |||
| b5f40a4007 |
48
.gitea/workflows/publish-codemirror-helix.yml
Normal file
48
.gitea/workflows/publish-codemirror-helix.yml
Normal file
@@ -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 }}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -48,3 +48,6 @@ yarn-error.log*
|
||||
/.clerk/
|
||||
.worktrees
|
||||
.claudesession
|
||||
|
||||
# built workspace packages (rebuilt by helix:build / package CI)
|
||||
/packages/*/dist
|
||||
|
||||
84
bun.lock
84
bun.lock
@@ -8,12 +8,20 @@
|
||||
"@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",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||
"@fortawesome/react-fontawesome": "^3.3.1",
|
||||
"@gregorlohaus/codemirror-helix": "workspace:*",
|
||||
"@gsap/react": "^2.1.2",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@mdx-js/loader": "^3.1.1",
|
||||
@@ -57,6 +65,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",
|
||||
@@ -130,6 +139,25 @@
|
||||
"vitest": "^4.1.8",
|
||||
},
|
||||
},
|
||||
"packages/codemirror-helix": {
|
||||
"name": "@gregorlohaus/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=="],
|
||||
@@ -384,6 +436,8 @@
|
||||
|
||||
"@fortawesome/react-fontawesome": ["@fortawesome/react-fontawesome@3.3.1", "", { "peerDependencies": { "@fortawesome/fontawesome-svg-core": "~6 || ~7", "react": "^18.0.0 || ^19.0.0" } }, "sha512-wGnAPhfzivDwBWYmEG8MSrEXPruoiMMo48NnsRkj1NZkoaawgOijPNAiSHKMYEoCsqTBSgLTzL6EqTTWGaUR4w=="],
|
||||
|
||||
"@gregorlohaus/codemirror-helix": ["@gregorlohaus/codemirror-helix@workspace:packages/codemirror-helix"],
|
||||
|
||||
"@gsap/react": ["@gsap/react@2.1.2", "", { "peerDependencies": { "gsap": "^3.12.5", "react": ">=17" } }, "sha512-JqliybO1837UcgH2hVOM4VO+38APk3ECNrsuSM4MuXp+rbf+/2IG2K1YJiqfTcXQHH7XlA0m3ykniFYstfq0Iw=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
|
||||
@@ -496,6 +550,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 +984,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 +1206,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
@@ -2504,6 +2586,8 @@
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||
|
||||
"@gregorlohaus/codemirror-helix/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||
|
||||
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||
|
||||
17
package.json
17
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",
|
||||
"@gregorlohaus/codemirror-helix": "workspace:*",
|
||||
"@uploadthing/react": "^7.3.3",
|
||||
"@vercel/speed-insights": "^2.0.0",
|
||||
"ai": "^6.0.193",
|
||||
|
||||
10
packages/codemirror-helix/.gitignore
vendored
Normal file
10
packages/codemirror-helix/.gitignore
vendored
Normal file
@@ -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
|
||||
69
packages/codemirror-helix/README.md
Normal file
69
packages/codemirror-helix/README.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# @gregorlohaus/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 @gregorlohaus/codemirror-helix
|
||||
# peers: @codemirror/{state,view,commands,language,search}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { EditorView, basicSetup } from "codemirror";
|
||||
import { helix } from "@gregorlohaus/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
|
||||
51
packages/codemirror-helix/package.json
Normal file
51
packages/codemirror-helix/package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@gregorlohaus/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"
|
||||
}
|
||||
}
|
||||
593
packages/codemirror-helix/src/commands.ts
Normal file
593
packages/codemirror-helix/src/commands.ts
Normal file
@@ -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<number>();
|
||||
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<string, [string, string]> = {
|
||||
"(": ["(", ")"],
|
||||
")": ["(", ")"],
|
||||
"[": ["[", "]"],
|
||||
"]": ["[", "]"],
|
||||
"{": ["{", "}"],
|
||||
"}": ["{", "}"],
|
||||
"<": ["<", ">"],
|
||||
">": ["<", ">"],
|
||||
};
|
||||
|
||||
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 };
|
||||
51
packages/codemirror-helix/src/index.ts
Normal file
51
packages/codemirror-helix/src/index.ts
Normal file
@@ -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";
|
||||
562
packages/codemirror-helix/src/keymap.ts
Normal file
562
packages/codemirror-helix/src/keymap.ts
Normal file
@@ -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<HelixStateValue> = {}) {
|
||||
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<string, Pending> = {
|
||||
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;
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
180
packages/codemirror-helix/src/motions.ts
Normal file
180
packages/codemirror-helix/src/motions.ts
Normal file
@@ -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<string, { open: string; close: string }> = {
|
||||
"(": { 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 };
|
||||
}
|
||||
83
packages/codemirror-helix/src/prompt.ts
Normal file
83
packages/codemirror-helix/src/prompt.ts
Normal file
@@ -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<PromptConfig>();
|
||||
const closePromptEffect = StateEffect.define<null>();
|
||||
|
||||
const promptState = StateField.define<PromptConfig | null>({
|
||||
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<HTMLInputElement>(".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;
|
||||
}
|
||||
87
packages/codemirror-helix/src/state.ts
Normal file
87
packages/codemirror-helix/src/state.ts
Normal file
@@ -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<Partial<HelixStateValue>>();
|
||||
|
||||
export const helixState = StateField.define<HelixStateValue>({
|
||||
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<Map<string, string>>({
|
||||
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<HelixStateValue> {
|
||||
return { pending: null, count: 0, register: null };
|
||||
}
|
||||
127
packages/codemirror-helix/src/textobjects.ts
Normal file
127
packages/codemirror-helix/src/textobjects.ts
Normal file
@@ -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";
|
||||
123
packages/codemirror-helix/src/view.ts
Normal file
123
packages/codemirror-helix/src/view.ts
Normal file
@@ -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<typeof blockMark.range>[] = [];
|
||||
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",
|
||||
},
|
||||
});
|
||||
19
packages/codemirror-helix/tsconfig.json
Normal file
19
packages/codemirror-helix/tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
@@ -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 "@gregorlohaus/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<T extends FieldValues>(params: {
|
||||
control: Control<T>,
|
||||
name: Path<T>,
|
||||
label: string,
|
||||
dataColorMode: "dark"|"light",
|
||||
autocompleteSuggestions?: MdeAutocompleteSuggestion[],
|
||||
triggerConfigs?: AutocompleteTriggerConfig[],
|
||||
renderPreview?: (source: string) => ReactElement,
|
||||
control: Control<T>;
|
||||
name: Path<T>;
|
||||
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 (
|
||||
<FormField
|
||||
control={params.control}
|
||||
name={params.name}
|
||||
render={({ field }) => {
|
||||
const source: string = field.value ?? "";
|
||||
const editor = (
|
||||
<FormItem className={cn(fullscreen && "mde-form-field-fullscreen")}>
|
||||
<div className="flex shrink-0 items-center justify-between gap-2">
|
||||
<FormLabel>
|
||||
{params.label}
|
||||
</FormLabel>
|
||||
<FormLabel>{params.label}</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -64,37 +78,48 @@ export default function MdeFormField<T extends FieldValues>(params: {
|
||||
</Button>
|
||||
</div>
|
||||
<FormControl className={cn(fullscreen && "min-h-0 flex-1")}>
|
||||
<MDEditor
|
||||
className={cn(fullscreen && "mde-form-field-editor-fullscreen min-h-0 flex-1")}
|
||||
height={fullscreen ? "calc(100vh - 72px)" : undefined}
|
||||
visibleDragbar={!fullscreen}
|
||||
value={field.value ? field.value : ""}
|
||||
onChange={field.onChange}
|
||||
data-color-mode={params.dataColorMode}
|
||||
commandsFilter={(command) => command.name === "fullscreen" ? false : command}
|
||||
components={{
|
||||
textarea: (props) => (
|
||||
<InternalLinkTextarea
|
||||
{...(props as TextareaHTMLAttributes<HTMLTextAreaElement>)}
|
||||
suggestions={params.autocompleteSuggestions ?? []}
|
||||
triggerConfigs={params.triggerConfigs}
|
||||
/>
|
||||
),
|
||||
preview: params.renderPreview
|
||||
? (source) => params.renderPreview?.(source) ?? <></>
|
||||
: (source) => <ClientMdx source={source} fallback={source} />,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap gap-4",
|
||||
fullscreen ? "min-h-0 flex-1 items-stretch" : "items-start",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-[18rem] flex-1 overflow-hidden rounded-md border">
|
||||
<CodeMirror
|
||||
value={source}
|
||||
onChange={(value) => 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,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{params.renderPreview && (
|
||||
<div
|
||||
className={cn(
|
||||
"prose dark:prose-invert min-w-[18rem] max-w-none flex-1 overflow-auto rounded-md border p-4",
|
||||
fullscreen ? "min-h-0" : "max-h-[360px]",
|
||||
)}
|
||||
>
|
||||
{params.renderPreview(source)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)
|
||||
);
|
||||
|
||||
if (fullscreen && mounted) {
|
||||
return createPortal(editor, document.body)
|
||||
return createPortal(editor, document.body);
|
||||
}
|
||||
|
||||
return editor
|
||||
return editor;
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, AutocompleteTriggerConfig>();
|
||||
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>\]\)]*$/,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -7,9 +7,9 @@ import { useRef } from "react";
|
||||
|
||||
// The centerpiece is the site's own "G" mark (public/GLIcon.svg). GSAP draws the
|
||||
// outline on with a stroke-dash trick, fades the fill in, then keeps everything
|
||||
// gently alive (floating logo, counter-rotating rings). A curved arrow in the
|
||||
// lower-right corner, with its label above the tail, points at the chat FAB to
|
||||
// nudge people toward the assistant.
|
||||
// gently alive (the logo floats). A curved arrow in the lower-right corner, with
|
||||
// its label above the tail, points at the chat FAB to nudge people toward the
|
||||
// assistant.
|
||||
export default function HomeHero() {
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const gPath = useRef<SVGPathElement>(null);
|
||||
@@ -26,11 +26,6 @@ export default function HomeHero() {
|
||||
|
||||
const tl = gsap.timeline({ defaults: { ease: "power3.out" } });
|
||||
|
||||
tl.from(
|
||||
".hero-ring",
|
||||
{ scale: 0, opacity: 0, duration: 0.9, stagger: 0.12, svgOrigin: "100 100" },
|
||||
0,
|
||||
);
|
||||
if (logo) tl.to(logo, { strokeDashoffset: 0, duration: 1.3 }, 0.25);
|
||||
tl.to(".hero-fill", { fillOpacity: 1, duration: 0.6 }, "-=0.35");
|
||||
tl.from(".hero-line", { yPercent: 120, opacity: 0, duration: 0.7, stagger: 0.12 }, "-=0.25");
|
||||
@@ -43,8 +38,6 @@ export default function HomeHero() {
|
||||
|
||||
// Idle life — runs forever once the entrance has settled.
|
||||
gsap.to(".hero-logo", { y: -12, duration: 3.2, ease: "sine.inOut", yoyo: true, repeat: -1 });
|
||||
gsap.to(".hero-ring-spin", { rotation: 360, duration: 44, ease: "none", repeat: -1, svgOrigin: "100 100" });
|
||||
gsap.to(".hero-ring-spin-rev", { rotation: -360, duration: 32, ease: "none", repeat: -1, svgOrigin: "100 100" });
|
||||
gsap.to(".hero-arrow-wrap", { y: 10, duration: 1.5, ease: "sine.inOut", yoyo: true, repeat: -1 });
|
||||
},
|
||||
{ scope: root },
|
||||
@@ -55,44 +48,8 @@ export default function HomeHero() {
|
||||
ref={root}
|
||||
className="relative flex h-full w-full flex-col items-center justify-center overflow-hidden px-6 text-center"
|
||||
>
|
||||
{/* Logo + orbiting rings */}
|
||||
{/* Logo */}
|
||||
<div className="hero-logo relative h-56 w-56 sm:h-64 sm:w-64">
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full text-primary/40"
|
||||
viewBox="0 0 200 200"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
className="hero-ring hero-ring-spin"
|
||||
cx="100"
|
||||
cy="100"
|
||||
r="92"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="2 10"
|
||||
/>
|
||||
<circle
|
||||
className="hero-ring hero-ring-spin-rev"
|
||||
cx="100"
|
||||
cy="100"
|
||||
r="78"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="14 8"
|
||||
opacity="0.6"
|
||||
/>
|
||||
<circle
|
||||
className="hero-ring"
|
||||
cx="100"
|
||||
cy="100"
|
||||
r="64"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
opacity="0.35"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full p-10 text-foreground"
|
||||
viewBox="0 0 74.193405 74.232162"
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import MDEditor from "@uiw/react-md-editor";
|
||||
import type { Control, FieldValues, Path } from "react-hook-form";
|
||||
import { FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form";
|
||||
export default function MdeFormField<T extends FieldValues>(params: { control: Control<T>, name: Path<T>, label: string, dataColorMode: "dark"|"light" }) {
|
||||
return (
|
||||
<FormField
|
||||
control={params.control}
|
||||
name={params.name}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{params.label}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<MDEditor
|
||||
value={field.value ? field.value : ""}
|
||||
onChange={field.onChange}
|
||||
data-color-mode={params.dataColorMode}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -44,6 +44,26 @@ External resource
|
||||
alt="Describe the image"
|
||||
caption="Optional caption"
|
||||
/>`,
|
||||
},
|
||||
{
|
||||
name: "Layout / Row / Column",
|
||||
description: "Border-less flex layout. Columns sit side by side and wrap when narrow.",
|
||||
code: `<Layout>
|
||||
<Row>
|
||||
<Column>
|
||||
|
||||
- First list item
|
||||
- Second list item
|
||||
|
||||
</Column>
|
||||
<Column>
|
||||
|
||||
- Another list
|
||||
- Side by side
|
||||
|
||||
</Column>
|
||||
</Row>
|
||||
</Layout>`,
|
||||
},
|
||||
{
|
||||
name: "PullQuote",
|
||||
|
||||
@@ -73,6 +73,27 @@ const mdxAutocompleteSuggestions: MdeAutocompleteSuggestion[] = [
|
||||
group: 'Component',
|
||||
trigger: '<',
|
||||
},
|
||||
{
|
||||
label: 'Layout',
|
||||
value: `<Layout>\n<Row>\n<Column>\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Column>\n<Column>\n\n</Column>\n</Row>\n</Layout>`,
|
||||
detail: 'Flex layout wrapper with side-by-side, wrapping columns.',
|
||||
group: 'Component',
|
||||
trigger: '<',
|
||||
},
|
||||
{
|
||||
label: 'Row',
|
||||
value: `<Row>\n<Column>\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Column>\n<Column>\n\n</Column>\n</Row>`,
|
||||
detail: 'Side-by-side columns that wrap when narrow.',
|
||||
group: 'Component',
|
||||
trigger: '<',
|
||||
},
|
||||
{
|
||||
label: 'Column',
|
||||
value: `<Column>\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Column>`,
|
||||
detail: 'Vertically stacked column within a Row.',
|
||||
group: 'Component',
|
||||
trigger: '<',
|
||||
},
|
||||
{
|
||||
label: 'PullQuote',
|
||||
value: `<PullQuote>\n${AUTOCOMPLETE_CURSOR_MARKER}\n</PullQuote>`,
|
||||
|
||||
@@ -24,7 +24,7 @@ export default async function CvPage() {
|
||||
components={mdxComponents}
|
||||
options={{
|
||||
mdxOptions: {
|
||||
format: "md",
|
||||
format: "mdx",
|
||||
remarkPlugins: [remarkGfm],
|
||||
rehypePlugins: [rehypeHighlight],
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ export default async function ProjectsPage() {
|
||||
components={mdxComponents}
|
||||
options={{
|
||||
mdxOptions: {
|
||||
format: "md",
|
||||
format: "mdx",
|
||||
remarkPlugins: [remarkGfm],
|
||||
rehypePlugins: [rehypeHighlight],
|
||||
},
|
||||
|
||||
@@ -111,6 +111,31 @@ function Img({ src, alt, title }: { src: string; alt?: string; title?: string })
|
||||
return <img src={src} alt={alt ?? ""} className="w-full rounded-md border object-cover" />;
|
||||
}
|
||||
|
||||
// Composable, border-less flex layout primitives for MDX.
|
||||
//
|
||||
// <Layout> — section wrapper, stacks its children with spacing
|
||||
// <Row> — lays children side by side, wraps when too narrow
|
||||
// <Column> — stacks its own children vertically
|
||||
//
|
||||
// Row/Column nest freely. A Row sizes its direct children into equal,
|
||||
// flex-growing tracks with a min width, so two lists/Columns sit side by
|
||||
// side and drop to stacked once they can't keep ~16rem each.
|
||||
function Layout({ children }: { children: ReactNode }) {
|
||||
return <div className="my-6 flex flex-col gap-6 [&>*]:my-0">{children}</div>;
|
||||
}
|
||||
|
||||
function Row({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-x-8 gap-y-4 [&>*]:mt-0 [&>*]:min-w-[16rem] [&>*]:flex-1">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Column({ children }: { children: ReactNode }) {
|
||||
return <div className="flex min-w-0 flex-col gap-2 [&>*]:my-0">{children}</div>;
|
||||
}
|
||||
|
||||
function PullQuote({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<blockquote className="border-primary my-8 border-l-4 pl-5 text-xl leading-8 font-medium">
|
||||
@@ -135,7 +160,15 @@ function ExternalLink(props: ComponentPropsWithoutRef<"a">) {
|
||||
);
|
||||
}
|
||||
|
||||
const blockComponents = new Set<unknown>([Callout, Figure, PullQuote, TagList]);
|
||||
const blockComponents = new Set<unknown>([
|
||||
Callout,
|
||||
Column,
|
||||
Figure,
|
||||
Layout,
|
||||
PullQuote,
|
||||
Row,
|
||||
TagList,
|
||||
]);
|
||||
|
||||
function Paragraph({ children }: { children: ReactNode }) {
|
||||
const containsBlockComponent = Children.toArray(children).some(
|
||||
@@ -153,9 +186,12 @@ export const mdxComponents = {
|
||||
Badge,
|
||||
ButtonLink,
|
||||
Callout,
|
||||
Column,
|
||||
Figure,
|
||||
img: Img,
|
||||
Layout,
|
||||
Lead,
|
||||
PullQuote,
|
||||
Row,
|
||||
TagList,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { publicProcedure, router } from "~/server/trpc";
|
||||
import { db } from "~/server/db";
|
||||
import { music } from "~/server/dbschema/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { isAdmin } from "~/app/actions";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
@@ -9,7 +9,7 @@ import { createMusicInputSchema, updateMusicInputSchema, setStreamInputSchema }
|
||||
import { utapi } from "../uploadthing";
|
||||
export const musicRouter = router({
|
||||
list: publicProcedure.query(async () => {
|
||||
let res = await db.select().from(music).orderBy(music.createdAt);
|
||||
let res = await db.select().from(music).orderBy(sql`${music.createdAt} desc`);
|
||||
console.log(res);
|
||||
return res;
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user