7 Commits

Author SHA1 Message Date
caeaf0a41d Merge branch 'music-fix-a' 2026-06-20 14:40:56 +02:00
cfe905b9f9 music mobile fix 2026-06-20 14:11:50 +02:00
281c3d02f2 scope codemirror-helix
All checks were successful
Publish codemirror-helix / publish (push) Successful in 2m19s
2026-06-19 17:15:51 +02:00
781e03c50c publish codemirrot helix package flow
Some checks failed
Publish codemirror-helix / publish (push) Failing after 2m16s
2026-06-19 17:07:18 +02:00
81c60feed9 mdx layout components 2026-06-19 14:48:38 +02:00
0fe62fcce5 music desc order 2026-06-18 05:55:37 +02:00
b5f40a4007 remove tacky red circles 2026-06-18 05:50:15 +02:00
27 changed files with 2364 additions and 134 deletions

View 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
View File

@@ -48,3 +48,6 @@ yarn-error.log*
/.clerk/ /.clerk/
.worktrees .worktrees
.claudesession .claudesession
# built workspace packages (rebuilt by helix:build / package CI)
/packages/*/dist

View File

@@ -8,12 +8,20 @@
"@ai-sdk/openai": "^3.0.67", "@ai-sdk/openai": "^3.0.67",
"@ai-sdk/react": "^3.0.195", "@ai-sdk/react": "^3.0.195",
"@clerk/nextjs": "^7.4.2", "@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", "@electric-sql/pglite": "^0.4.6",
"@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2", "@ffmpeg/util": "^0.12.2",
"@fortawesome/fontawesome-svg-core": "^7.2.0", "@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/react-fontawesome": "^3.3.1", "@fortawesome/react-fontawesome": "^3.3.1",
"@gregorlohaus/codemirror-helix": "workspace:*",
"@gsap/react": "^2.1.2", "@gsap/react": "^2.1.2",
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@mdx-js/loader": "^3.1.1", "@mdx-js/loader": "^3.1.1",
@@ -57,6 +65,7 @@
"@trpc/react-query": "^11.17.0", "@trpc/react-query": "^11.17.0",
"@trpc/server": "^11.17.0", "@trpc/server": "^11.17.0",
"@types/mdx": "^2.0.14", "@types/mdx": "^2.0.14",
"@uiw/react-codemirror": "^4.25.10",
"@uiw/react-md-editor": "^4.1.1", "@uiw/react-md-editor": "^4.1.1",
"@uploadthing/react": "^7.3.3", "@uploadthing/react": "^7.3.3",
"@vercel/speed-insights": "^2.0.0", "@vercel/speed-insights": "^2.0.0",
@@ -130,6 +139,25 @@
"vitest": "^4.1.8", "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": { "packages": {
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], "@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=="], "@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=="], "@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=="], "@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=="], "@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=="], "@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=="], "@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=="], "@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/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=="], "@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=="], "@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/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-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=="], "@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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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-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=="], "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=="], "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=="], "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=="], "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=="], "@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/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=="], "@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=="],

View File

@@ -3,8 +3,10 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"workspaces": ["packages/*"],
"scripts": { "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", "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": "biome check .",
"check:unsafe": "biome check --write --unsafe .", "check:unsafe": "biome check --write --unsafe .",
@@ -13,7 +15,7 @@
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"dev": "next dev --turbo", "dev": "bun run helix:build && next dev --turbo",
"preview": "next build && next start", "preview": "next build && next start",
"start": "next start", "start": "next start",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
@@ -23,6 +25,13 @@
"@ai-sdk/openai": "^3.0.67", "@ai-sdk/openai": "^3.0.67",
"@ai-sdk/react": "^3.0.195", "@ai-sdk/react": "^3.0.195",
"@clerk/nextjs": "^7.4.2", "@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", "@electric-sql/pglite": "^0.4.6",
"@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2", "@ffmpeg/util": "^0.12.2",
@@ -31,9 +40,9 @@
"@fortawesome/react-fontawesome": "^3.3.1", "@fortawesome/react-fontawesome": "^3.3.1",
"@gsap/react": "^2.1.2", "@gsap/react": "^2.1.2",
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@mdx-js/loader": "^3.1.1",
"@mdx-js/mdx": "^3.1.1", "@mdx-js/mdx": "^3.1.1",
"@mdx-js/react": "^3.1.1", "@mdx-js/react": "^3.1.1",
"@mdx-js/loader": "^3.1.1",
"@neondatabase/serverless": "^1.1.0", "@neondatabase/serverless": "^1.1.0",
"@next/mdx": "^16.2.9", "@next/mdx": "^16.2.9",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
@@ -72,7 +81,9 @@
"@trpc/react-query": "^11.17.0", "@trpc/react-query": "^11.17.0",
"@trpc/server": "^11.17.0", "@trpc/server": "^11.17.0",
"@types/mdx": "^2.0.14", "@types/mdx": "^2.0.14",
"@uiw/react-codemirror": "^4.25.10",
"@uiw/react-md-editor": "^4.1.1", "@uiw/react-md-editor": "^4.1.1",
"@gregorlohaus/codemirror-helix": "workspace:*",
"@uploadthing/react": "^7.3.3", "@uploadthing/react": "^7.3.3",
"@vercel/speed-insights": "^2.0.0", "@vercel/speed-insights": "^2.0.0",
"ai": "^6.0.193", "ai": "^6.0.193",

10
packages/codemirror-helix/.gitignore vendored Normal file
View 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

View 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

View 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"
}
}

View 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 };

View 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";

View 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;
},
}),
);
}

View 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 };
}

View 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;
}

View 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 };
}

View 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";

View 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",
},
});

View 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"]
}

View File

@@ -1,58 +1,72 @@
"use client"; "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 { 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 { createPortal } from "react-dom";
import type { Control, FieldValues, Path } from "react-hook-form"; import type { Control, FieldValues, Path } from "react-hook-form";
import { FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form"; import { FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { ClientMdx } from "~/components/ClientMdx";
import { import {
InternalLinkTextarea,
type AutocompleteTriggerConfig, type AutocompleteTriggerConfig,
type MdeAutocompleteSuggestion, type MdeAutocompleteSuggestion,
} from "./InternalLinkTextarea"; } from "./InternalLinkTextarea";
import { mdxCompletionSource } from "./codemirror/mdxAutocomplete";
export default function MdeFormField<T extends FieldValues>(params: { export default function MdeFormField<T extends FieldValues>(params: {
control: Control<T>, control: Control<T>;
name: Path<T>, name: Path<T>;
label: string, label: string;
dataColorMode: "dark"|"light", dataColorMode: "dark" | "light";
autocompleteSuggestions?: MdeAutocompleteSuggestion[], autocompleteSuggestions?: MdeAutocompleteSuggestion[];
triggerConfigs?: AutocompleteTriggerConfig[], triggerConfigs?: AutocompleteTriggerConfig[];
renderPreview?: (source: string) => ReactElement, renderPreview?: (source: string) => ReactElement;
}) { }) {
const [fullscreen, setFullscreen] = useState(false) const [fullscreen, setFullscreen] = useState(false);
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false);
useEffect(() => { useEffect(() => {
setMounted(true) setMounted(true);
}, []) }, []);
useEffect(() => { useEffect(() => {
if (!fullscreen) return if (!fullscreen) return;
const originalOverflow = document.body.style.overflow;
const originalOverflow = document.body.style.overflow document.body.style.overflow = "hidden";
document.body.style.overflow = "hidden"
return () => { return () => {
document.body.style.overflow = originalOverflow document.body.style.overflow = originalOverflow;
} };
}, [fullscreen]) }, [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 ( return (
<FormField <FormField
control={params.control} control={params.control}
name={params.name} name={params.name}
render={({ field }) => { render={({ field }) => {
const source: string = field.value ?? "";
const editor = ( const editor = (
<FormItem className={cn(fullscreen && "mde-form-field-fullscreen")}> <FormItem className={cn(fullscreen && "mde-form-field-fullscreen")}>
<div className="flex shrink-0 items-center justify-between gap-2"> <div className="flex shrink-0 items-center justify-between gap-2">
<FormLabel> <FormLabel>{params.label}</FormLabel>
{params.label}
</FormLabel>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -64,37 +78,48 @@ export default function MdeFormField<T extends FieldValues>(params: {
</Button> </Button>
</div> </div>
<FormControl className={cn(fullscreen && "min-h-0 flex-1")}> <FormControl className={cn(fullscreen && "min-h-0 flex-1")}>
<MDEditor <div
className={cn(fullscreen && "mde-form-field-editor-fullscreen min-h-0 flex-1")} className={cn(
height={fullscreen ? "calc(100vh - 72px)" : undefined} "flex flex-wrap gap-4",
visibleDragbar={!fullscreen} fullscreen ? "min-h-0 flex-1 items-stretch" : "items-start",
value={field.value ? field.value : ""} )}
onChange={field.onChange} >
data-color-mode={params.dataColorMode} <div className="min-w-[18rem] flex-1 overflow-hidden rounded-md border">
commandsFilter={(command) => command.name === "fullscreen" ? false : command} <CodeMirror
components={{ value={source}
textarea: (props) => ( onChange={(value) => field.onChange(value)}
<InternalLinkTextarea extensions={extensions}
{...(props as TextareaHTMLAttributes<HTMLTextAreaElement>)} theme={params.dataColorMode === "dark" ? "dark" : "light"}
suggestions={params.autocompleteSuggestions ?? []} height={fullscreen ? "calc(100vh - 96px)" : "360px"}
triggerConfigs={params.triggerConfigs} basicSetup={{
/> lineNumbers: false,
), foldGutter: false,
preview: params.renderPreview highlightActiveLine: false,
? (source) => params.renderPreview?.(source) ?? <></> highlightActiveLineGutter: false,
: (source) => <ClientMdx source={source} fallback={source} />,
}} }}
/> />
</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> </FormControl>
</FormItem> </FormItem>
) );
if (fullscreen && mounted) { if (fullscreen && mounted) {
return createPortal(editor, document.body) return createPortal(editor, document.body);
} }
return editor return editor;
}} }}
/> />
) );
} }

View File

@@ -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>\]\)]*$/,
};
};
}

View File

@@ -7,9 +7,9 @@ import { useRef } from "react";
// The centerpiece is the site's own "G" mark (public/GLIcon.svg). GSAP draws the // 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 // 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 // gently alive (the logo floats). A curved arrow in the lower-right corner, with
// lower-right corner, with its label above the tail, points at the chat FAB to // its label above the tail, points at the chat FAB to nudge people toward the
// nudge people toward the assistant. // assistant.
export default function HomeHero() { export default function HomeHero() {
const root = useRef<HTMLDivElement>(null); const root = useRef<HTMLDivElement>(null);
const gPath = useRef<SVGPathElement>(null); const gPath = useRef<SVGPathElement>(null);
@@ -26,11 +26,6 @@ export default function HomeHero() {
const tl = gsap.timeline({ defaults: { ease: "power3.out" } }); 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); if (logo) tl.to(logo, { strokeDashoffset: 0, duration: 1.3 }, 0.25);
tl.to(".hero-fill", { fillOpacity: 1, duration: 0.6 }, "-=0.35"); 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"); 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. // 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-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 }); gsap.to(".hero-arrow-wrap", { y: 10, duration: 1.5, ease: "sine.inOut", yoyo: true, repeat: -1 });
}, },
{ scope: root }, { scope: root },
@@ -55,44 +48,8 @@ export default function HomeHero() {
ref={root} ref={root}
className="relative flex h-full w-full flex-col items-center justify-center overflow-hidden px-6 text-center" 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"> <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 <svg
className="absolute inset-0 h-full w-full p-10 text-foreground" className="absolute inset-0 h-full w-full p-10 text-foreground"
viewBox="0 0 74.193405 74.232162" viewBox="0 0 74.193405 74.232162"

View File

@@ -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>
)}
/>
)
}

View File

@@ -44,6 +44,26 @@ External resource
alt="Describe the image" alt="Describe the image"
caption="Optional caption" 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", name: "PullQuote",

View File

@@ -73,6 +73,27 @@ const mdxAutocompleteSuggestions: MdeAutocompleteSuggestion[] = [
group: 'Component', group: 'Component',
trigger: '<', 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', label: 'PullQuote',
value: `<PullQuote>\n${AUTOCOMPLETE_CURSOR_MARKER}\n</PullQuote>`, value: `<PullQuote>\n${AUTOCOMPLETE_CURSOR_MARKER}\n</PullQuote>`,

View File

@@ -24,7 +24,7 @@ export default async function CvPage() {
components={mdxComponents} components={mdxComponents}
options={{ options={{
mdxOptions: { mdxOptions: {
format: "md", format: "mdx",
remarkPlugins: [remarkGfm], remarkPlugins: [remarkGfm],
rehypePlugins: [rehypeHighlight], rehypePlugins: [rehypeHighlight],
}, },

View File

@@ -47,7 +47,15 @@ export default function AudioPlayer(props: {
const [downloading, setDownloading] = useState(false); const [downloading, setDownloading] = useState(false);
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
const { currentId, isPlaying, currentTime, toggle, seek, subscribeTime } = useMusicPlayer(); const {
currentId,
isPlaying,
currentTime,
duration: engineDuration,
toggle,
seek,
subscribeTime,
} = useMusicPlayer();
const isActive = currentId === props.id; const isActive = currentId === props.id;
// Reach live values from the once-created wavesurfer callbacks. // Reach live values from the once-created wavesurfer callbacks.
@@ -143,6 +151,9 @@ export default function AudioPlayer(props: {
} }
const playing = isActive && isPlaying; const playing = isActive && isPlaying;
// The waveform is only a visual; playback is owned by the shared engine. Show
// its duration if the waveform hasn't decoded (slow/unreliable on mobile).
const shownDuration = duration || (isActive ? engineDuration : 0);
return ( return (
<div className="flex items-center gap-3 rounded-lg border bg-transparent px-3 py-2"> <div className="flex items-center gap-3 rounded-lg border bg-transparent px-3 py-2">
@@ -151,7 +162,6 @@ export default function AudioPlayer(props: {
size="icon" size="icon"
variant="ghost" variant="ghost"
aria-label={playing ? "Pause" : "Play"} aria-label={playing ? "Pause" : "Play"}
disabled={!ready}
onClick={() => toggle(props.id)} onClick={() => toggle(props.id)}
> >
{playing ? <Pause /> : <Play />} {playing ? <Pause /> : <Play />}
@@ -171,7 +181,7 @@ export default function AudioPlayer(props: {
</div> </div>
<span className="w-10 shrink-0 font-mono text-xs text-muted-foreground tabular-nums"> <span className="w-10 shrink-0 font-mono text-xs text-muted-foreground tabular-nums">
{formatTime(duration)} {formatTime(shownDuration)}
</span> </span>
<Button <Button

View File

@@ -23,7 +23,7 @@ export default async function ProjectsPage() {
components={mdxComponents} components={mdxComponents}
options={{ options={{
mdxOptions: { mdxOptions: {
format: "md", format: "mdx",
remarkPlugins: [remarkGfm], remarkPlugins: [remarkGfm],
rehypePlugins: [rehypeHighlight], rehypePlugins: [rehypeHighlight],
}, },

View File

@@ -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" />; 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 }) { function PullQuote({ children }: { children: ReactNode }) {
return ( return (
<blockquote className="border-primary my-8 border-l-4 pl-5 text-xl leading-8 font-medium"> <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 }) { function Paragraph({ children }: { children: ReactNode }) {
const containsBlockComponent = Children.toArray(children).some( const containsBlockComponent = Children.toArray(children).some(
@@ -153,9 +186,12 @@ export const mdxComponents = {
Badge, Badge,
ButtonLink, ButtonLink,
Callout, Callout,
Column,
Figure, Figure,
img: Img, img: Img,
Layout,
Lead, Lead,
PullQuote, PullQuote,
Row,
TagList, TagList,
}; };

View File

@@ -1,7 +1,7 @@
import { publicProcedure, router } from "~/server/trpc"; import { publicProcedure, router } from "~/server/trpc";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { music } from "~/server/dbschema/schema"; import { music } from "~/server/dbschema/schema";
import { eq } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import { isAdmin } from "~/app/actions"; import { isAdmin } from "~/app/actions";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
@@ -9,7 +9,7 @@ import { createMusicInputSchema, updateMusicInputSchema, setStreamInputSchema }
import { utapi } from "../uploadthing"; import { utapi } from "../uploadthing";
export const musicRouter = router({ export const musicRouter = router({
list: publicProcedure.query(async () => { 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); console.log(res);
return res; return res;
}), }),