Compare commits

..

No commits in common. "ae3045f96b238b1abadcaed1f17a595f818a9964" and "c10b088fb1030df1c56a5c25c85a88d491def499" have entirely different histories.

41 changed files with 1815 additions and 1165 deletions

View File

@ -41,7 +41,7 @@ ttrpg-tools/
│ │ ├── md-pins.tsx # 标记组件
│ │ ├── md-commander/ # 命令追踪器
│ │ ├── md-yarn-spinner.tsx # 叙事线组件
│ │ ├── md-token.tsx # 代币组件
│ │ ├── Article.tsx # 文章组件
│ │ ├── Sidebar.tsx # 侧边栏
│ │ ├── FileTree.tsx # 文件树
@ -77,7 +77,7 @@ ttrpg-tools/
| 样式 | Tailwind CSS 4 + @tailwindcss/typography |
| Markdown | marked + marked-directive + marked-alert + marked-gfm-heading-id |
| 图表 | mermaid |
| 3D | three.js |
| 测试 | Jest |
| CLI | commander + chokidar |

View File

@ -431,7 +431,26 @@ layers="字段:起始行,起始列 - 结束列,字体大小"
:md-yarn-spinner[./story.yarn]
```
### 🪙 代币组件 (md-token)
用于展示游戏代币/棋子。
```markdown
:md-token[./token.png]
```
### 🎨 代币预览组件 (md-token-viewer)
用于 3D 预览 3MF 格式的代币模型。
```markdown
:md-token-viewer[./token.3mf]
```
**功能:**
- 使用 Three.js 渲染 3D 模型
- 支持鼠标拖拽旋转
- 自动旋转展示
### 📋 命令追踪器 (md-commander)

View File

@ -335,7 +335,7 @@ MCP 服务器提供以下 Resources包含 TTRPG Tools 的文档和参考材
- `:md-table` - 表格组件
- `:md-deck` - 卡牌组件
- `:md-yarn-spinner` - 叙事线组件
- `:md-token` - 代币组件
- `:md-commander` - 命令追踪器
- YAML 标签
- Mermaid 图表

376
package-lock.json generated
View File

@ -12,6 +12,7 @@
"@modelcontextprotocol/sdk": "^0.5.0",
"@solidjs/router": "^0.15.0",
"@thisbeyond/solid-dnd": "^0.7.5",
"@types/three": "^0.183.1",
"aedes": "^1.1.1",
"chokidar": "^5.0.0",
"commander": "^14.0.3",
@ -26,16 +27,17 @@
"mqtt": "^5.15.1",
"solid-element": "^1.9.1",
"solid-js": "^1.9.3",
"uuid": "^14.0.1",
"three": "^0.183.2",
"three-3mf-exporter": "^45.1.0",
"ws": "^8.21.0",
"yarn-spinner-loader": "^0.1.0",
"zod": "^4.4.3"
},
"bin": {
"ttrpg": "dist/cli/index.js",
"ttt": "dist/cli/index.js"
"ttrpg": "dist/cli/index.js"
},
"devDependencies": {
"@image-tracer-ts/core": "^1.0.2",
"@rsbuild/core": "^1.1.8",
"@rsbuild/plugin-babel": "^1.1.0",
"@rsbuild/plugin-solid": "^1.1.0",
@ -45,17 +47,14 @@
"@types/jest": "^30.0.0",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.19.13",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
"cross-env": "^10.1.0",
"husky": "^9.1.7",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"tailwindcss": "^4.0.0",
"ts-jest": "^29.4.6",
"ts-node": "^10.9.2",
"typescript": "^5.9.3",
"webpack-bundle-analyzer": "^5.3.0"
"typescript": "^5.9.3"
}
},
"node_modules/@alloc/quick-lru": {
@ -872,15 +871,11 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@discoveryjs/json-ext": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz",
"integrity": "sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.17.0"
}
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"license": "Apache-2.0"
},
"node_modules/@emnapi/core": {
"version": "1.9.2",
@ -913,13 +908,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/@epic-web/invariant": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
"dev": true,
"license": "MIT"
},
"node_modules/@iconify/types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
@ -937,6 +925,13 @@
"mlly": "^1.8.0"
}
},
"node_modules/@image-tracer-ts/core": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@image-tracer-ts/core/-/core-1.0.2.tgz",
"integrity": "sha512-IY1/AMqvu6444dEwaFwJXwskp2fyKTFFVKvKHXBCT7hGz7OLRLHrWGHJrCsVw6HPSeucoHD5j/gtieWVSzocEw==",
"dev": true,
"license": "MIT"
},
"node_modules/@istanbuljs/load-nyc-config": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@ -2017,13 +2012,6 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
"dev": true,
"license": "MIT"
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz",
@ -2954,6 +2942,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@ -3360,6 +3354,27 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.183.1",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz",
"integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==",
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": ">=0.5.17",
"@webgpu/types": "*",
"fflate": "~0.8.2",
"meshoptimizer": "~1.0.1"
}
},
"node_modules/@types/tough-cookie": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
@ -3374,11 +3389,10 @@
"license": "MIT",
"optional": true
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"dev": true,
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"license": "MIT"
},
"node_modules/@types/ws": {
@ -3417,6 +3431,12 @@
"d3-transition": "^3.0.1"
}
},
"node_modules/@webgpu/types": {
"version": "0.1.69",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz",
"integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==",
"license": "BSD-3-Clause"
},
"node_modules/abab": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
@ -4421,6 +4441,12 @@
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cose-base": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz",
@ -4477,24 +4503,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/cross-env": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@epic-web/invariant": "^1.0.0",
"cross-spawn": "^7.0.6"
},
"bin": {
"cross-env": "dist/bin/cross-env.js",
"cross-env-shell": "dist/bin/cross-env-shell.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -5568,6 +5576,41 @@
],
"license": "BSD-3-Clause"
},
"node_modules/fast-xml-builder": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"path-expression-matcher": "^1.1.3"
}
},
"node_modules/fast-xml-parser": {
"version": "5.5.10",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.10.tgz",
"integrity": "sha512-go2J2xODMc32hT+4Xr/bBGXMaIoiCwrwp2mMtAvKyvEFW6S/v5Gn2pBmE4nvbwNjGhpcAiOwEv7R6/GZ6XRa9w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"fast-xml-builder": "^1.1.4",
"path-expression-matcher": "^1.2.1",
"strnum": "^2.2.2"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fastparallel": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz",
@ -5597,6 +5640,12 @@
"bser": "2.1.1"
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@ -6033,6 +6082,12 @@
],
"license": "BSD-3-Clause"
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/import-local": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
@ -6192,6 +6247,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -7898,6 +7959,18 @@
"node": ">=6"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/katex": {
"version": "0.16.45",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz",
@ -7971,6 +8044,15 @@
"node": ">=6"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@ -8432,18 +8514,11 @@
"node": ">= 20"
}
},
"node_modules/mermaid/node_modules/uuid": {
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
"node_modules/meshoptimizer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz",
"integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==",
"license": "MIT"
},
"node_modules/micromatch": {
"version": "4.0.8",
@ -8638,16 +8713,6 @@
"safe-buffer": "~5.2.0"
}
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -8767,16 +8832,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/opener": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
"integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==",
"dev": true,
"license": "(WTFPL OR MIT)",
"bin": {
"opener": "bin/opener-bin.js"
}
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@ -8838,6 +8893,12 @@
"integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==",
"license": "MIT"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parse-json": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
@ -8886,6 +8947,21 @@
"node": ">=8"
}
},
"node_modules/path-expression-matcher": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.1.tgz",
"integrity": "sha512-d7gQQmLvAKXKXE2GeP9apIGbMYKz88zWdsn/BN2HRWVQsDFdUY36WSLTY0Jvd4HWi7Fb30gQ62oAOzdgJA6fZw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@ -9222,6 +9298,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
@ -9476,6 +9567,12 @@
"seroval": "^1.0"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@ -9512,21 +9609,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
"integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@polka/url": "^1.0.0-next.24",
"mrmime": "^2.0.0",
"totalist": "^3.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@ -9758,6 +9840,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strnum": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz",
"integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/stylis": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
@ -9833,6 +9927,25 @@
"node": ">=8"
}
},
"node_modules/three": {
"version": "0.183.2",
"resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz",
"integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==",
"license": "MIT"
},
"node_modules/three-3mf-exporter": {
"version": "45.2.0",
"resolved": "https://registry.npmjs.org/three-3mf-exporter/-/three-3mf-exporter-45.2.0.tgz",
"integrity": "sha512-Xwuciopn3ecMhf9muVIRTh/GrSdxUTrdz43fMfazPZdxQ1E0eOBIGEhQEUrGlvRLLcWeaHo5MvZQCClTAMmmog==",
"license": "MIT",
"dependencies": {
"fast-xml-parser": "^5.0.9",
"jszip": "^3.10.1"
},
"peerDependencies": {
"three": "*"
}
},
"node_modules/tinyexec": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz",
@ -9921,16 +10034,6 @@
"node": ">=0.6"
}
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
@ -10235,16 +10338,16 @@
"license": "MIT"
},
"node_modules/uuid": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
"integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==",
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/v8-compile-cache-lib": {
@ -10444,51 +10547,6 @@
"node": ">=12"
}
},
"node_modules/webpack-bundle-analyzer": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-5.3.0.tgz",
"integrity": "sha512-PEhAoqiJ+47d0uLMx/+zo5XOvaU+Vk6N2ZLht7H3n09QLy/fhyvqGNwjdRUHJDgMN8crBR2ZwVHkIswT3Xuawg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@discoveryjs/json-ext": "^0.6.3",
"acorn": "^8.0.4",
"acorn-walk": "^8.0.0",
"commander": "^14.0.2",
"escape-string-regexp": "^5.0.0",
"html-escaper": "^3.0.3",
"opener": "^1.5.2",
"picocolors": "^1.0.0",
"sirv": "^3.0.2",
"ws": "^8.19.0"
},
"bin": {
"webpack-bundle-analyzer": "lib/bin/analyzer.js"
},
"engines": {
"node": ">= 20.9.0"
}
},
"node_modules/webpack-bundle-analyzer/node_modules/escape-string-regexp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/webpack-bundle-analyzer/node_modules/html-escaper": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
"integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
"dev": true,
"license": "MIT"
},
"node_modules/whatwg-encoding": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz",

View File

@ -1,11 +1,10 @@
{
"name": "ttrpg-tools",
"version": "0.0.1",
"description": "A tabletop RPG toolbox based on solid.js and rsbuild",
"description": "A TTRPG toolbox based on solid.js and rsbuild",
"type": "module",
"bin": {
"ttrpg": "./dist/cli/index.js",
"ttt": "./dist/cli/index.js"
"ttrpg": "./dist/cli/index.js"
},
"main": "./dist/index.js",
"module": "./dist/index.js",
@ -18,7 +17,6 @@
"dev": "rsbuild dev",
"build": "rsbuild build",
"preview": "rsbuild preview",
"analyze": "cross-env ANALYZE=true rsbuild build",
"cli:dev": "tsc -p tsconfig.cli.json --watch",
"cli:build": "tsc -p tsconfig.cli.json",
"ttrpg": "node --loader ts-node/esm ./src/cli/index.ts",
@ -37,6 +35,7 @@
"@modelcontextprotocol/sdk": "^0.5.0",
"@solidjs/router": "^0.15.0",
"@thisbeyond/solid-dnd": "^0.7.5",
"@types/three": "^0.183.1",
"aedes": "^1.1.1",
"chokidar": "^5.0.0",
"commander": "^14.0.3",
@ -51,12 +50,14 @@
"mqtt": "^5.15.1",
"solid-element": "^1.9.1",
"solid-js": "^1.9.3",
"uuid": "^14.0.1",
"three": "^0.183.2",
"three-3mf-exporter": "^45.1.0",
"ws": "^8.21.0",
"yarn-spinner-loader": "^0.1.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@image-tracer-ts/core": "^1.0.2",
"@rsbuild/core": "^1.1.8",
"@rsbuild/plugin-babel": "^1.1.0",
"@rsbuild/plugin-solid": "^1.1.0",
@ -66,16 +67,13 @@
"@types/jest": "^30.0.0",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.19.13",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
"cross-env": "^10.1.0",
"husky": "^9.1.7",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"tailwindcss": "^4.0.0",
"ts-jest": "^29.4.6",
"ts-node": "^10.9.2",
"typescript": "^5.9.3",
"webpack-bundle-analyzer": "^5.3.0"
"typescript": "^5.9.3"
}
}

View File

@ -1,8 +1,7 @@
import { defineConfig } from "@rsbuild/core";
import { pluginBabel } from "@rsbuild/plugin-babel";
import { pluginSolid } from "@rsbuild/plugin-solid";
import tailwindcss from "@tailwindcss/postcss";
import { BundleAnalyzerPlugin } from "webpack-bundle-analyzer";
import { defineConfig } from '@rsbuild/core';
import { pluginBabel } from '@rsbuild/plugin-babel';
import { pluginSolid } from '@rsbuild/plugin-solid';
import tailwindcss from '@tailwindcss/postcss';
export default defineConfig({
plugins: [
@ -17,36 +16,32 @@ export default defineConfig({
plugins: [tailwindcss()],
},
},
rspack: {
module: {
rules: [
{
test: /\.md|\.yarn|\.csv$/,
exclude: /\.schema\.csv$/,
type: "asset/source",
},
],
},
plugins: [
process.env.ANALYZE ? new BundleAnalyzerPlugin() : undefined,
].filter(Boolean),
},
rspack: {
module: {
rules: [
{
test: /\.md|\.yarn|\.csv$/,
exclude: /\.schema\.csv$/,
type: 'asset/source'
},
]
}
}
},
html: {
template: "./src/index.html",
template: './src/index.html',
},
source: {
entry: {
index: "./src/main.tsx",
index: './src/main.tsx',
},
},
output: {
distPath: {
root: "dist/web",
root: 'dist/web',
},
copy:
process.env.NODE_ENV === "development"
? [{ from: "./content", to: "content" }]
: [],
copy: process.env.NODE_ENV === 'development' ? [
{ from: './content', to: 'content' }
] : [],
},
});

View File

@ -1,11 +1,4 @@
import {
Component,
createContext,
useContext,
createEffect,
createMemo,
createSignal,
} from "solid-js";
import { Component, createEffect, createMemo, createSignal } from "solid-js";
import { useLocation } from "@solidjs/router";
import { useJournalStream } from "./components/stores/journalStream";
@ -22,23 +15,6 @@ import {
import { generateToc, type FileNode, type TocNode } from "./data-loader";
import { JournalPanel } from "./components/journal";
// ---------------------------------------------------------------------------
// Scroll container context lets child components (FileTree, RevealManager)
// find the correct scrollable element instead of relying on window.scroll
// ---------------------------------------------------------------------------
const ScrollContainerCtx = createContext<() => HTMLElement | undefined>();
/** Access the main content scroll container from any descendant. */
export function useScrollContainer(): () => HTMLElement | undefined {
const ctx = useContext(ScrollContainerCtx);
return ctx ?? (() => undefined);
}
// ---------------------------------------------------------------------------
// App
// ---------------------------------------------------------------------------
const App: Component = () => {
const location = useLocation();
const stream = useJournalStream();
@ -54,8 +30,6 @@ const App: Component = () => {
>({});
const [tocKey, setTocKey] = createSignal(0);
let mainRef!: HTMLElement;
const loadToc = async () => {
const toc = await generateToc();
setFileTree(toc.fileTree);
@ -84,10 +58,23 @@ const App: Component = () => {
});
return (
<div class="h-screen flex flex-col overflow-hidden bg-gray-50">
{/* Header */}
<header class="shrink-0 h-16 bg-white shadow z-30">
<div class="h-full max-w-4xl mx-auto px-4 flex items-center gap-4">
<div class="min-h-screen bg-gray-50">
{/* 桌面端固定侧边栏 */}
<DesktopSidebar
fileTree={fileTree()}
pathHeadings={pathHeadings()}
onDataSourceOpen={() => setIsDataSourceOpen(true)}
/>
{/* 移动端抽屉式侧边栏 */}
<MobileSidebar
isOpen={isSidebarOpen()}
onClose={() => setIsSidebarOpen(false)}
fileTree={fileTree()}
pathHeadings={pathHeadings()}
onDataSourceOpen={() => setIsDataSourceOpen(true)}
/>
<header class="fixed top-0 left-0 right-0 bg-white shadow z-30">
<div class="max-w-4xl mx-auto px-4 py-4 flex items-center gap-4">
{/* 仅在移动端显示菜单按钮 */}
<button
onClick={() => setIsSidebarOpen(true)}
@ -96,7 +83,7 @@ const App: Component = () => {
>
</button>
<h1 class="text-2xl font-bold text-gray-900">Tabletop Tools</h1>
<h1 class="text-2xl font-bold text-gray-900">TTRPG Tools</h1>
<div class="flex-1" />
<button
onClick={() => setIsJournalOpen((v) => !v)}
@ -128,61 +115,30 @@ const App: Component = () => {
</button>
</div>
</header>
{/* 移动端抽屉式侧边栏 (overlay) */}
<MobileSidebar
isOpen={isSidebarOpen()}
onClose={() => setIsSidebarOpen(false)}
fileTree={fileTree()}
pathHeadings={pathHeadings()}
onDataSourceOpen={() => setIsDataSourceOpen(true)}
/>
{/* Body: sidebar + main + journal */}
<div class="flex flex-1 min-h-0">
{/* 桌面端固定侧边栏 (flex child) */}
<DesktopSidebar
fileTree={fileTree()}
pathHeadings={pathHeadings()}
onDataSourceOpen={() => setIsDataSourceOpen(true)}
/>
{/* Main content — the scrollable region */}
<ScrollContainerCtx.Provider value={() => mainRef}>
<main ref={mainRef} class="flex-1 min-w-0 overflow-y-auto">
<div class="max-w-4xl mx-auto px-4 py-8">
<Article
class="prose text-black prose-sm max-w-full"
src={currentPath()}
>
<RevealManager />
</Article>
</div>
</main>
</ScrollContainerCtx.Provider>
{/* Journal panel wrapper on desktop: flex child with width transition;
on mobile: passes through to JournalPanel's own overlay */}
<div
class="contents md:block md:shrink-0 md:overflow-hidden md:transition-all md:duration-300"
classList={{
"md:w-105": isJournalOpen(),
"md:w-0": !isJournalOpen(),
}}
>
<JournalPanel
open={isJournalOpen()}
onClose={() => setIsJournalOpen(false)}
/>
</div>
{/* fill the rest of the space */}
<div
class="fixed top-16 left-0 right-0 bottom-0 overflow-auto transition-all duration-300"
classList={{ "md:pr-[420px]": isJournalOpen() }}
>
<main class="max-w-4xl mx-auto px-4 py-8 pt-4 md:ml-64 2xl:ml-auto flex justify-center items-center">
<Article
class="prose text-black prose-sm max-w-full flex-1"
src={currentPath()}
>
<RevealManager />
</Article>
</main>
</div>
<DocDialog isOpen={isDocOpen()} onClose={() => setIsDocOpen(false)} />
<DataSourceDialog
isOpen={isDataSourceOpen()}
onClose={() => setIsDataSourceOpen(false)}
onSourceChanged={handleSourceChanged}
/>
<JournalPanel
open={isJournalOpen()}
onClose={() => setIsJournalOpen(false)}
/>
</div>
);
};

View File

@ -11,7 +11,6 @@ import {
scanCompletions,
type CompletionsPayload,
} from "../completions/index.js";
import { extractInlineBlocks } from "../inline-blocks.js";
interface ContentIndex {
[path: string]: string;
@ -106,16 +105,7 @@ export function scanDirectory(dir: string): ContentIndex {
) {
try {
const content = readFileSync(fullPath, "utf-8");
if (entry.endsWith(".md")) {
const stripped = extractInlineBlocks(
content,
normalizedRelPath,
index,
);
index[normalizedRelPath] = stripped;
} else {
index[normalizedRelPath] = content;
}
index[normalizedRelPath] = content;
} catch (e) {
console.error(`读取文件失败:${fullPath}`, e);
}
@ -305,18 +295,9 @@ export function createContentServer(
try {
const content = readFileSync(path, "utf-8");
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
if (relPath.endsWith(".md")) {
const stripped = extractInlineBlocks(
content,
relPath,
contentIndex,
);
contentIndex[relPath] = stripped;
recomputeCompletions();
} else {
contentIndex[relPath] = content;
}
contentIndex[relPath] = content;
console.log(`[新增] ${relPath}`);
if (relPath.endsWith(".md")) recomputeCompletions();
} catch (e) {
console.error(`读取新增文件失败:${path}`, e);
}
@ -331,18 +312,9 @@ export function createContentServer(
try {
const content = readFileSync(path, "utf-8");
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
if (relPath.endsWith(".md")) {
const stripped = extractInlineBlocks(
content,
relPath,
contentIndex,
);
contentIndex[relPath] = stripped;
recomputeCompletions();
} else {
contentIndex[relPath] = content;
}
contentIndex[relPath] = content;
console.log(`[更新] ${relPath}`);
if (relPath.endsWith(".md")) recomputeCompletions();
} catch (e) {
console.error(`读取更新文件失败:${path}`, e);
}

View File

@ -1,105 +0,0 @@
import { posix } from "path";
import { createHash } from "crypto";
/**
* Regex to match a fenced code block with attributes, e.g.:
*
* ```csv file="card-data.csv"
* name, cost, effect
* Fireball, 1, Blast em
* ```
*
* ```csv as="md-table" roll=true
* name, cost, effect
* Fireball, 1, Blast em
* ```
*
* Group 1: language identifier (e.g. "csv")
* Group 2: attribute string (e.g. `file="card-data.csv" as="md-table" roll=true`)
* Group 3: the code block body
*/
const INLINE_FILE_BLOCK_RE = /^```(\w*)\s+(.+?)\s*\n([\s\S]*?)```\s*$/gm;
/**
* Parse key="value" and key=value pairs from an attribute string.
*/
function parseAttrs(info: string): Record<string, string> {
const attrs: Record<string, string> = {};
const re = /(\w+)\s*=\s*("[^"]*"|\S+)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(info)) !== null) {
attrs[m[1]] = m[2].replace(/^"|"$/g, "");
}
return attrs;
}
/**
* Short content hash for stable generated filenames.
*/
function contentHash(body: string): string {
return createHash("md5").update(body).digest("hex").slice(0, 8);
}
/**
* Process fenced code blocks that contain attributes.
*
* For each block like:
* ```lang file="card-data.csv" as="md-table" roll=true
* content
* ```
*
* 1. Strips the block from the returned text
* 2. Injects a synthetic entry into `index` at the resolved file path
* 3. If `as` is present, replaces the block with a directive like
* `:md-table[./file.csv]{roll=true}`
*
* @param content - Raw file content
* @param fileRelativePath - Normalized path of the containing file (e.g. "/rules/combat.md")
* @param index - The content index to inject synthetic entries into
* @returns Content with inline blocks processed
*/
export function extractInlineBlocks(
content: string,
fileRelativePath: string,
index: Record<string, string>,
): string {
const fileDir = posix.dirname(fileRelativePath);
return content.replace(
INLINE_FILE_BLOCK_RE,
(_match: string, lang: string, infoString: string, body: string) => {
const attrs = parseAttrs(infoString);
// Only process blocks that have `file` or `as`; leave others untouched
if (!attrs.file && !attrs.as) {
return _match;
}
// Determine filename: explicit `file` or content-hash generated
const filename =
attrs.file || `_inline_${contentHash(body)}.${lang || "txt"}`;
const resolvedPath = posix.join(fileDir, filename);
// Inject the body as a synthetic file entry
index[resolvedPath] = body;
console.log(
`[inline] Extracted ${resolvedPath} from ${fileRelativePath}`,
);
// If `as` is set, emit a directive instead of stripping
if (attrs.as) {
const extra = { ...attrs };
delete extra.file;
delete extra.as;
const extraStr = Object.keys(extra).length
? `{${Object.entries(extra)
.map(([k, v]) => `${k}=${v}`)
.join(" ")}}`
: "";
return `:${attrs.as}[./${filename}]${extraStr}`;
}
return ""; // has `file` but no `as` — strip the block (body is already injected into index)
},
);
}

View File

@ -150,20 +150,6 @@ export async function createJournalServer(
() => {},
);
// Publish the initial manifest on startup so clients see existing sessions
const initialManifest = loadManifest(root);
broker.publish(
{
topic: $SESSIONS,
payload: Buffer.from(JSON.stringify(initialManifest)),
qos: 1,
retain: true,
cmd: "publish" as const,
dup: false,
},
() => {},
);
console.log("[journal] persistence listener active");
console.log("[journal] broker available at ws://<host>:<port>");

View File

@ -7,17 +7,20 @@ import {
onCleanup,
Show,
createResource,
createMemo,
createSignal,
} from "solid-js";
import { parseMarkdown } from "../markdown";
import { extractSection } from "../data-loader";
import mermaid from "mermaid";
import { getIndexedData } from "../data-loader/file-index";
import { resolvePath } from "./utils/path";
import { useNavigateWithParams } from "./useNavigateWithParams";
export interface ArticleProps {
src: string;
section?: string; // 指定要显示的标题(不含 #
iconPath?: string; // 图标路径前缀,默认为 "./assets",空字符串表示禁用
onLoaded?: () => void;
onError?: (error: Error) => void;
class?: string; // 额外的 class 用于样式控制
@ -80,6 +83,12 @@ export const Article: Component<ArticleProps & ParentProps> = (props) => {
);
const [contentDom, setContentDom] = createSignal<HTMLDivElement>();
// 解析 iconPath默认为 "./assets",空字符串表示禁用
const iconPrefix = createMemo(() => {
if (props.iconPath === "") return undefined; // 空字符串禁用图标前缀
return resolvePath(props.src, props.iconPath ?? "./assets");
});
createEffect(() => {
const data = content();
if (data) {
@ -135,7 +144,7 @@ export const Article: Component<ArticleProps & ParentProps> = (props) => {
<div
class="relative"
ref={setContentDom}
innerHTML={parseMarkdown(content()!, props.src)}
innerHTML={parseMarkdown(content()!, iconPrefix())}
/>
{props.children}
</ArticleDomCtx.Provider>

View File

@ -63,7 +63,7 @@ const DocDialog: Component<DocDialogProps> = (props) => {
return (
<Show when={props.isOpen}>
<div
class="fixed inset-0 bg-black/50 z-60 flex items-center justify-center p-4"
class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
onClick={(e) => {
if (e.target === e.currentTarget) props.onClose();
}}
@ -79,7 +79,7 @@ const DocDialog: Component<DocDialogProps> = (props) => {
<span class="text-gray-400 text-sm"></span>
</button>
<Show when={dropdownOpen()}>
<div class="absolute top-full left-0 mt-1 w-52 bg-white border border-gray-200 rounded shadow-lg z-60 py-1">
<div class="absolute top-full left-0 mt-1 w-52 bg-white border border-gray-200 rounded shadow-lg z-50 py-1">
<button
onClick={() => switchDocSet("directives")}
class={`w-full text-left px-3 py-2 text-sm hover:bg-gray-100 flex items-center gap-2 ${

View File

@ -1,7 +1,6 @@
import { Component, createMemo, createSignal, Show } from "solid-js";
import { type FileNode, type TocNode } from "../data-loader";
import { useNavigateWithParams } from "./useNavigateWithParams";
import { useScrollContainer } from "../App";
/**
*
@ -99,8 +98,6 @@ export const HeadingNode: Component<{
setIsExpanded(!isExpanded());
}
};
const scrollContainer = useScrollContainer();
const handleClick = (e: MouseEvent) => {
e.preventDefault();
navigate(href);
@ -108,18 +105,10 @@ export const HeadingNode: Component<{
requestAnimationFrame(() => {
const element = document.getElementById(anchor);
if (element) {
const scroller = scrollContainer();
const navBarHeight = 80;
const elementPosition = element.getBoundingClientRect().top;
const containerTop = scroller?.getBoundingClientRect().top ?? 0;
const relativePosition = elementPosition - containerTop;
const currentScroll = scroller?.scrollTop ?? window.scrollY;
const offsetPosition = currentScroll + relativePosition - navBarHeight;
if (scroller) {
scroller.scrollTo({ top: offsetPosition, behavior: "instant" });
} else {
window.scrollTo({ top: offsetPosition, behavior: "instant" });
}
const offsetPosition = window.scrollY + elementPosition - navBarHeight;
window.scrollTo({ top: offsetPosition, behavior: "instant" });
}
});
};

View File

@ -18,7 +18,6 @@ import {
import { Portal } from "solid-js/web";
import { useLocation } from "@solidjs/router";
import { useArticleDom } from "./Article";
import { useScrollContainer } from "../App";
import { journalStreamState } from "./stores/journalStream";
import { useJournalCompletions } from "./journal/completions";
import {
@ -76,7 +75,6 @@ function hide() {
export const RevealManager: Component = () => {
const contentDom = useArticleDom();
const scrollContainer = useScrollContainer();
const location = useLocation();
const comp = useJournalCompletions();
@ -181,16 +179,8 @@ export const RevealManager: Component = () => {
<button
class={`fixed z-50 inline-flex items-center justify-center w-5 h-5 rounded cursor-pointer ${btn().color}`}
style={{
top: `${
btn().rect.top +
(scrollContainer()?.scrollTop ?? window.scrollY) -
6
}px`,
left: `${
btn().rect.left +
(scrollContainer()?.scrollLeft ?? window.scrollX) -
20
}px`,
top: `${btn().rect.top + window.scrollY - 6}px`,
left: `${btn().rect.left + window.scrollX - 20}px`,
}}
title={btn().title}
innerHTML={btn().svg}

View File

@ -158,7 +158,7 @@ export const MobileSidebar: Component<SidebarProps> = (props) => {
};
/**
* flex child, no fixed positioning
*
*/
export const DesktopSidebar: Component<{
fileTree?: FileNode[];
@ -182,7 +182,7 @@ export const DesktopSidebar: Component<{
});
return (
<aside class="hidden md:flex flex-col w-64 shrink-0 bg-white shadow-lg overflow-hidden">
<aside class="hidden md:block fixed top-0 left-0 h-full w-64 bg-white shadow-lg z-30 overflow-hidden pt-16">
<SidebarContent
fileTree={fileTree()}
pathHeadings={pathHeadings()}

View File

@ -56,7 +56,8 @@ import mdBorderRaw from "../doc-entries/md-border.md";
import mdEmbedRaw from "../doc-entries/md-embed.md";
import mdDeckRaw from "../doc-entries/md-deck.md";
import mdYarnRaw from "../doc-entries/md-yarn-spinner.md";
import mdTokenRaw from "../doc-entries/md-token.md";
import mdTokenViewerRaw from "../doc-entries/md-token-viewer.md";
import mdCommanderRaw from "../doc-entries/md-commander.md";
const rawDocuments: string[] = [
@ -70,7 +71,8 @@ const rawDocuments: string[] = [
mdEmbedRaw,
mdDeckRaw,
mdYarnRaw,
mdTokenRaw,
mdTokenViewerRaw,
mdCommanderRaw,
];

View File

@ -10,6 +10,8 @@ import "./md-embed";
import "./md-deck";
import "./md-commander/index";
import "./md-yarn-spinner";
import "./md-token";
import "./md-token-viewer";
// 导出组件
export { Article } from "./Article";
@ -31,6 +33,7 @@ export type { FontProps } from "./md-font";
export type { BorderProps } from "./md-border";
export type { EmbedProps } from "./md-embed";
export type { YarnSpinnerProps } from "./md-yarn-spinner";
export type { TokenProps } from "./md-token";
// 导出 md-commander 相关
export type {

View File

@ -1,118 +0,0 @@
/**
* ConnectDialog name + role picker to join a session
*/
import { Component, createSignal, onMount, Show } from "solid-js";
import {
connectStream,
hydrateFromServer,
useJournalStream,
setMyName,
setMyRole,
setSessionId,
} from "../stores/journalStream";
export const ConnectDialog: Component = () => {
const stream = useJournalStream();
const [playerName, setPlayerName] = createSignal(stream.myName);
const [role, setRole] = createSignal<"gm" | "player" | "observer">(
stream.myRole,
);
const [connecting, setConnecting] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [autoJoined, setAutoJoined] = createSignal(false);
// Auto-join when URL has both session and player params
onMount(() => {
const params = new URL(window.location.href).searchParams;
if (
params.has("session") &&
params.has("player") &&
params.has("autojoin")
) {
setPlayerName(params.get("player") || "");
setRole("player");
setAutoJoined(true);
handleConnect();
}
});
const handleConnect = async () => {
const name = playerName().trim() || stream.myName;
if (!name) return;
const brokerUrl = `ws://${window.location.host}`;
setConnecting(true);
setError(null);
try {
setMyName(name);
setMyRole(role());
const sessionId = stream.sessionId || "default";
setSessionId(sessionId);
await hydrateFromServer(sessionId);
await connectStream(sessionId, brokerUrl);
} catch (e) {
const msg = e instanceof Error ? e.message : "Connection failed";
setError(msg);
} finally {
setConnecting(false);
}
};
return (
<Show
when={!autoJoined() || error()}
fallback={
<p class="text-sm text-gray-500 text-center">
{connecting() ? "正在加入会话…" : "已加入!"}
</p>
}
>
<div class="w-full max-w-sm space-y-3">
<p class="text-sm text-gray-600 text-center">
<br />
<span class="text-xs text-gray-400">
{window.location.host}
</span>
</p>
<input
type="text"
value={playerName()}
onInput={(e) => setPlayerName(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && handleConnect()}
placeholder="你的名字"
class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm"
autofocus
/>
{/* Role selector */}
<div class="flex gap-1">
{(["gm", "player", "observer"] as const).map((r) => (
<button
onClick={() => setRole(r)}
class={`flex-1 rounded px-2 py-1.5 text-xs font-medium border transition-colors ${
role() === r
? "bg-blue-600 text-white border-blue-600"
: "bg-white text-gray-600 border-gray-300 hover:border-gray-400"
}`}
>
{r === "gm" ? "主持人" : r === "player" ? "玩家" : "观察者"}
</button>
))}
</div>
<button
onClick={handleConnect}
disabled={connecting() || !playerName().trim()}
class="w-full bg-blue-600 text-white rounded px-3 py-2 text-sm font-medium
hover:bg-blue-700 disabled:opacity-50"
>
{connecting() ? "连接中..." : "加入"}
</button>
<Show when={error()}>
<p class="text-red-500 text-sm text-center">{error()}</p>
</Show>
</div>
</Show>
);
};

View File

@ -1,77 +0,0 @@
/**
* CreateSessionDialog modal for naming a new session
*/
import { Component, createSignal, onMount } from "solid-js";
import { createSession } from "../stores/journalStream";
interface CreateSessionDialogProps {
onClose: () => void;
}
export const CreateSessionDialog: Component<CreateSessionDialogProps> = (
props,
) => {
const [name, setName] = createSignal("");
let inputRef!: HTMLInputElement;
const handleCreate = () => {
const trimmed = name().trim();
if (!trimmed) return;
createSession(trimmed);
setName("");
props.onClose();
};
onMount(() => inputRef?.focus());
return (
<div
class="absolute inset-0 z-50 flex items-center justify-center bg-black/20"
onClick={props.onClose}
>
<div
class="bg-white rounded-lg border border-gray-200 shadow-xl w-[280px] p-4 space-y-3"
onClick={(e) => e.stopPropagation()}
>
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-800"></h3>
<button
onClick={props.onClose}
class="text-gray-400 hover:text-gray-600 text-sm"
>
</button>
</div>
<p class="text-xs text-gray-500"></p>
<input
ref={inputRef!}
type="text"
value={name()}
onInput={(e) => setName(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
if (e.key === "Escape") props.onClose();
}}
placeholder="会话名称"
class="w-full border border-gray-300 rounded px-2 py-1.5 text-sm"
autofocus
/>
<div class="flex justify-end gap-1">
<button
onClick={props.onClose}
class="px-3 py-1 text-xs text-gray-600 hover:text-gray-800"
>
</button>
<button
onClick={handleCreate}
disabled={!name().trim()}
class="bg-blue-600 text-white rounded px-3 py-1 text-xs font-medium hover:bg-blue-700 disabled:opacity-50"
>
</button>
</div>
</div>
</div>
);
};

View File

@ -1,90 +0,0 @@
/**
* InviteDialog input player name, copy invite link
*/
import { Component, createSignal } from "solid-js";
import { useJournalStream } from "../stores/journalStream";
interface InviteDialogProps {
onClose: () => void;
}
export const InviteDialog: Component<InviteDialogProps> = (props) => {
const stream = useJournalStream();
const [playerName, setPlayerName] = createSignal("");
const [copied, setCopied] = createSignal(false);
const inviteLink = () => {
const url = new URL(window.location.href);
url.searchParams.set("session", stream.sessionId || "default");
if (playerName().trim()) {
url.searchParams.set("player", playerName().trim());
}
url.searchParams.set("autojoin", "1");
return url.toString();
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(inviteLink());
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
const input = document.getElementById(
"invite-link-input",
) as HTMLInputElement;
if (input) input.select();
}
};
return (
<div
class="absolute inset-0 z-50 flex items-center justify-center bg-black/20"
onClick={props.onClose}
>
<div
class="bg-white rounded-lg border border-gray-200 shadow-xl w-[280px] p-4 space-y-3"
onClick={(e) => e.stopPropagation()}
>
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-800"></h3>
<button
onClick={props.onClose}
class="text-gray-400 hover:text-gray-600 text-sm"
>
</button>
</div>
<p class="text-xs text-gray-500">
</p>
<input
type="text"
value={playerName()}
onInput={(e) => setPlayerName(e.currentTarget.value)}
placeholder="玩家名字"
class="w-full border border-gray-300 rounded px-2 py-1 text-xs"
autofocus
/>
<div class="flex gap-1">
<input
id="invite-link-input"
type="text"
value={inviteLink()}
readonly
class="flex-1 border border-gray-200 rounded px-2 py-1 text-xs bg-gray-50 text-gray-600 font-mono truncate"
/>
<button
onClick={handleCopy}
class={`shrink-0 rounded px-2 py-1 text-xs font-medium transition-colors ${
copied()
? "bg-green-100 text-green-700"
: "bg-blue-600 text-white hover:bg-blue-700"
}`}
>
{copied() ? "已复制!" : "复制"}
</button>
</div>
</div>
</div>
);
};

View File

@ -1,18 +0,0 @@
/**
* Journal context provides `onClose` to message renderers so that
* clicking a link message can close the panel on mobile.
*/
import { createContext, useContext } from "solid-js";
export interface JournalContextValue {
onClose: () => void;
}
const JournalContext = createContext<JournalContextValue>();
export function useJournalContext(): JournalContextValue | undefined {
return useContext(JournalContext);
}
export { JournalContext };

View File

@ -1,80 +0,0 @@
/**
* JournalHeader status dot, player name, session dropdown, disconnect & close
*/
import { Component, Show } from "solid-js";
import { useJournalStream, disconnectStream } from "../stores/journalStream";
import { SessionDropdown } from "./SessionDropdown";
interface JournalHeaderProps {
onClose: () => void;
onCreateSession: () => void;
}
export const JournalHeader: Component<JournalHeaderProps> = (props) => {
const stream = useJournalStream();
const handleDisconnect = () => disconnectStream();
return (
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 shrink-0">
<div class="flex items-center gap-2 min-w-0">
<span
class="w-2 h-2 rounded-full shrink-0"
classList={{
"bg-gray-400": stream.connectionStatus === "disconnected",
"bg-yellow-400 animate-pulse":
stream.connectionStatus === "connecting",
"bg-green-500": stream.connectionStatus === "connected",
"bg-red-500": stream.connectionStatus === "error",
}}
title={stream.connectionStatus}
/>
<Show
when={stream.connected && stream.sessionId}
fallback={
<h2 class="text-sm font-semibold text-gray-700 truncate"></h2>
}
>
<div class="min-w-0">
<p class="text-sm font-semibold text-gray-700 truncate leading-tight flex items-center gap-1">
{stream.myName}
<Show when={stream.myRole !== "gm"}>
<span
class={`text-[10px] px-1 rounded ${
stream.myRole === "observer"
? "bg-gray-200 text-gray-500"
: "bg-blue-100 text-blue-600"
}`}
>
{stream.myRole}
</span>
</Show>
</p>
{/* Clickable subtitle — session dropdown (GM only) */}
<Show when={stream.myRole === "gm"}>
<SessionDropdown onCreate={props.onCreateSession} />
</Show>
</div>
</Show>
</div>
<div class="flex items-center gap-1 shrink-0">
<Show when={stream.connected}>
<button
onClick={handleDisconnect}
class="text-xs text-gray-400 hover:text-red-500 px-1"
title="断开连接"
>
</button>
</Show>
<button
onClick={props.onClose}
class="text-gray-400 hover:text-gray-600 text-sm px-1"
title="隐藏面板"
>
</button>
</div>
</div>
);
};

View File

@ -1,20 +1,24 @@
/**
* JournalPanel right panel container for the journal stream
*
* On mobile: fixed overlay with backdrop.
* On desktop: positioned by the parent flex container (width transition).
* Fixed overlay. Shows stream when connected, connect dialog when not.
* Auto-connects to ws://{current host}:{current port} — no URL input needed.
*/
import { Component, Show, createSignal, For } from "solid-js";
import { useJournalStream } from "../stores/journalStream";
import { Component, Show, createSignal, onMount, For } from "solid-js";
import {
connectStream,
hydrateFromServer,
useJournalStream,
setMyName,
setMyRole,
setSessionId,
sessions,
createSession,
disconnectStream,
} from "../stores/journalStream";
import { StreamView } from "./StreamView";
import { JournalInput } from "./JournalInput";
import { JournalContext } from "./JournalContext";
import { JournalHeader } from "./JournalHeader";
import { ConnectDialog } from "./ConnectDialog";
import { InviteDialog } from "./InviteDialog";
import { CreateSessionDialog } from "./CreateSessionDialog";
export interface JournalPanelProps {
open: boolean;
@ -24,7 +28,6 @@ export interface JournalPanelProps {
export const JournalPanel: Component<JournalPanelProps> = (props) => {
const stream = useJournalStream();
const [showInvite, setShowInvite] = createSignal(false);
const [showCreateSession, setShowCreateSession] = createSignal(false);
// Player list (exclude gm and observer)
const playerEntries = () =>
@ -34,73 +37,430 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
return (
<>
{/* Mobile backdrop — overlay that appears below the header */}
{/* Backdrop — only on mobile, starts below header */}
<div
class="fixed top-16 inset-x-0 bottom-0 bg-black/30 z-40 md:hidden"
classList={{ hidden: !props.open }}
onClick={props.onClose}
/>
{/* Panel — on mobile: fixed overlay; on desktop: fills the parent wrapper */}
{/* Panel — always mounted for exit animation, visibility toggled */}
<aside
class={`fixed top-16 right-0 bottom-0 z-50 bg-white
flex flex-col w-full h-full max-w-md shadow-lg
transition-transform duration-300 ease-in-out
md:static md:inset-auto md:w-105 md:max-w-none md:shadow-none md:border-l md:border-gray-200
${props.open ? "translate-x-0" : "translate-x-full"}`}
inert={!props.open}
class={`fixed top-16 right-0 bottom-0 z-50 bg-white border-l border-gray-200
flex flex-col w-full max-w-md md:w-[420px] shadow-lg
transition-transform duration-300 ease-in-out ${
props.open ? "translate-x-0" : "translate-x-full"
}`}
aria-hidden={!props.open}
>
<JournalContext.Provider value={{ onClose: props.onClose }}>
{/* Header */}
<JournalHeader
onClose={props.onClose}
onCreateSession={() => setShowCreateSession(true)}
/>
{/* Header */}
<JournalHeader onClose={props.onClose} />
{/* Player list tabs */}
<Show when={stream.connected}>
<div class="flex items-center gap-1 px-3 py-1.5 border-b border-gray-100 bg-gray-50 overflow-x-auto shrink-0">
<For each={playerEntries()}>
{([name]) => (
<span class="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full whitespace-nowrap">
{name}
</span>
)}
</For>
<button
onClick={() => setShowInvite(true)}
class="text-xs text-gray-400 hover:text-blue-600 px-1.5 py-0.5 rounded border border-dashed border-gray-300 hover:border-blue-400 whitespace-nowrap shrink-0"
title="邀请玩家"
>
+
</button>
{/* Player list tabs */}
<Show when={stream.connected}>
<div class="flex items-center gap-1 px-3 py-1.5 border-b border-gray-100 bg-gray-50 overflow-x-auto shrink-0">
<For each={playerEntries()}>
{([name]) => (
<span class="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full whitespace-nowrap">
{name}
</span>
)}
</For>
<button
onClick={() => setShowInvite(true)}
class="text-xs text-gray-400 hover:text-blue-600 px-1.5 py-0.5 rounded border border-dashed border-gray-300 hover:border-blue-400 whitespace-nowrap shrink-0"
title="邀请玩家"
>
+
</button>
</div>
</Show>
<Show
when={stream.connected}
fallback={
<div class="flex-1 flex items-center justify-center p-4 overflow-y-auto">
<ConnectDialog />
</div>
</Show>
}
>
<div class="flex-1 min-h-0">
<StreamView />
</div>
<JournalInput />
</Show>
<Show
when={stream.connected}
fallback={
<div class="flex-1 flex items-center justify-center p-4 overflow-y-auto">
<ConnectDialog />
</div>
}
>
<div class="flex-1 min-h-0">
<StreamView />
</div>
<JournalInput />
</Show>
{/* Invite modal */}
<Show when={showInvite()}>
<InviteDialog onClose={() => setShowInvite(false)} />
</Show>
{/* Create session modal */}
<Show when={showCreateSession()}>
<CreateSessionDialog onClose={() => setShowCreateSession(false)} />
</Show>
</JournalContext.Provider>
{/* Invite modal */}
<Show when={showInvite()}>
<InviteDialog onClose={() => setShowInvite(false)} />
</Show>
</aside>
</>
);
};
// ---------------------------------------------------------------------------
// Journal Header — status dot, player name, session dropdown, disconnect & close
// ---------------------------------------------------------------------------
interface JournalHeaderProps {
onClose: () => void;
}
const JournalHeader: Component<JournalHeaderProps> = (props) => {
const stream = useJournalStream();
const manifest = sessions;
const [dropdownOpen, setDropdownOpen] = createSignal(false);
const [newSessionName, setNewSessionName] = createSignal("");
const [showNewInput, setShowNewInput] = createSignal(false);
let dropdownRef!: HTMLDivElement;
const handleSessionSelect = async (id: string) => {
try {
await hydrateFromServer(id);
setSessionId(id);
} catch {
/* */
}
setDropdownOpen(false);
};
const handleCreate = () => {
const name = newSessionName().trim();
if (!name) return;
createSession(name);
setNewSessionName("");
setShowNewInput(false);
};
const handleDisconnect = () => disconnectStream();
// Close dropdown on outside click
if (typeof document !== "undefined") {
document.addEventListener("click", (e) => {
if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
setDropdownOpen(false);
setShowNewInput(false);
}
});
}
return (
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 shrink-0">
<div class="flex items-center gap-2 min-w-0">
<span
class="w-2 h-2 rounded-full shrink-0"
classList={{
"bg-gray-400": stream.connectionStatus === "disconnected",
"bg-yellow-400 animate-pulse":
stream.connectionStatus === "connecting",
"bg-green-500": stream.connectionStatus === "connected",
"bg-red-500": stream.connectionStatus === "error",
}}
title={stream.connectionStatus}
/>
<Show
when={stream.connected && stream.sessionId}
fallback={
<h2 class="text-sm font-semibold text-gray-700 truncate"></h2>
}
>
<div class="min-w-0">
<p class="text-sm font-semibold text-gray-700 truncate leading-tight flex items-center gap-1">
{stream.myName}
<Show when={stream.myRole !== "gm"}>
<span
class={`text-[10px] px-1 rounded ${
stream.myRole === "observer"
? "bg-gray-200 text-gray-500"
: "bg-blue-100 text-blue-600"
}`}
>
{stream.myRole}
</span>
</Show>
</p>
{/* Clickable subtitle — session dropdown (GM only) */}
<Show when={stream.myRole === "gm"}>
<div ref={dropdownRef} class="relative">
<button
onClick={() => setDropdownOpen((v) => !v)}
class="text-[10px] text-gray-400 hover:text-gray-600 truncate leading-tight cursor-pointer text-left"
>
{stream.sessionName || stream.sessionId}{" "}
<span class="text-gray-300"></span>
</button>
<Show when={dropdownOpen()}>
<div class="absolute top-full left-0 mt-1 w-48 bg-white border border-gray-200 rounded shadow-lg z-50 py-1 max-h-48 overflow-y-auto">
<For each={Object.entries(manifest().sessions)}>
{([id, meta]) => (
<button
onClick={() => handleSessionSelect(id)}
class={`w-full text-left px-3 py-1 text-xs hover:bg-gray-100 flex items-center gap-1 ${
id === stream.sessionId
? "bg-blue-50 text-blue-700"
: "text-gray-700"
}`}
>
<span class="truncate flex-1">{meta.name || id}</span>
<Show when={id === stream.sessionId}>
<span class="text-blue-500 shrink-0"></span>
</Show>
</button>
)}
</For>
<div class="border-t border-gray-100 my-0.5" />
<Show
when={!showNewInput()}
fallback={
<div class="px-3 py-1 flex gap-1">
<input
type="text"
value={newSessionName()}
onInput={(e) =>
setNewSessionName(e.currentTarget.value)
}
placeholder="会话名称"
class="flex-1 border border-gray-300 rounded px-1.5 py-0.5 text-xs"
autofocus
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
if (e.key === "Escape") setShowNewInput(false);
}}
/>
<button
onClick={handleCreate}
class="bg-blue-600 text-white rounded px-2 py-0.5 text-xs hover:bg-blue-700"
>
+
</button>
</div>
}
>
<button
onClick={() => setShowNewInput(true)}
class="w-full text-left px-3 py-1 text-xs text-blue-600 hover:bg-gray-100"
>
+
</button>
</Show>
</div>
</Show>
</div>
</Show>
</div>
</Show>
</div>
<div class="flex items-center gap-1 shrink-0">
<Show when={stream.connected}>
<button
onClick={handleDisconnect}
class="text-xs text-gray-400 hover:text-red-500 px-1"
title="断开连接"
>
</button>
</Show>
<button
onClick={props.onClose}
class="text-gray-400 hover:text-gray-600 text-sm px-1"
title="隐藏面板"
>
</button>
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// Connect dialog — one input: player name. Broker URL is always current host.
// ---------------------------------------------------------------------------
const ConnectDialog: Component = () => {
const stream = useJournalStream();
const [playerName, setPlayerName] = createSignal(stream.myName);
const [role, setRole] = createSignal<"gm" | "player" | "observer">(
stream.myRole,
);
const [connecting, setConnecting] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [autoJoined, setAutoJoined] = createSignal(false);
// Auto-join when URL has both session and player params
onMount(() => {
const params = new URL(window.location.href).searchParams;
if (
params.has("session") &&
params.has("player") &&
params.has("autojoin")
) {
setPlayerName(params.get("player") || "");
setRole("player");
setAutoJoined(true);
handleConnect();
}
});
const handleConnect = async () => {
const name = playerName().trim() || stream.myName;
if (!name) return;
const brokerUrl = `ws://${window.location.host}`;
setConnecting(true);
setError(null);
try {
setMyName(name);
setMyRole(role());
const sessionId = stream.sessionId || "default";
setSessionId(sessionId);
await hydrateFromServer(sessionId);
await connectStream(sessionId, brokerUrl);
} catch (e) {
const msg = e instanceof Error ? e.message : "Connection failed";
setError(msg);
} finally {
setConnecting(false);
}
};
return (
<Show
when={!autoJoined() || error()}
fallback={
<p class="text-sm text-gray-500 text-center">
{connecting() ? "正在加入会话…" : "已加入!"}
</p>
}
>
<div class="w-full max-w-sm space-y-3">
<p class="text-sm text-gray-600 text-center">
<br />
<span class="text-xs text-gray-400">
{window.location.host}
</span>
</p>
<input
type="text"
value={playerName()}
onInput={(e) => setPlayerName(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && handleConnect()}
placeholder="你的名字"
class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm"
autofocus
/>
{/* Role selector */}
<div class="flex gap-1">
{(["gm", "player", "observer"] as const).map((r) => (
<button
onClick={() => setRole(r)}
class={`flex-1 rounded px-2 py-1.5 text-xs font-medium border transition-colors ${
role() === r
? "bg-blue-600 text-white border-blue-600"
: "bg-white text-gray-600 border-gray-300 hover:border-gray-400"
}`}
>
{r === "gm" ? "主持人" : r === "player" ? "玩家" : "观察者"}
</button>
))}
</div>
<button
onClick={handleConnect}
disabled={connecting() || !playerName().trim()}
class="w-full bg-blue-600 text-white rounded px-3 py-2 text-sm font-medium
hover:bg-blue-700 disabled:opacity-50"
>
{connecting() ? "连接中..." : "加入"}
</button>
<Show when={error()}>
<p class="text-red-500 text-sm text-center">{error()}</p>
</Show>
</div>
</Show>
);
};
// ---------------------------------------------------------------------------
// Invite dialog — input player name, copy invite link
// ---------------------------------------------------------------------------
interface InviteDialogProps {
onClose: () => void;
}
const InviteDialog: Component<InviteDialogProps> = (props) => {
const stream = useJournalStream();
const [playerName, setPlayerName] = createSignal("");
const [copied, setCopied] = createSignal(false);
const inviteLink = () => {
const url = new URL(window.location.href);
url.searchParams.set("session", stream.sessionId || "default");
if (playerName().trim()) {
url.searchParams.set("player", playerName().trim());
}
url.searchParams.set("autojoin", "1");
return url.toString();
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(inviteLink());
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
const input = document.getElementById(
"invite-link-input",
) as HTMLInputElement;
if (input) input.select();
}
};
return (
<div class="absolute inset-0 z-50 flex items-center justify-center bg-black/20">
<div class="bg-white rounded-lg border border-gray-200 shadow-xl w-[280px] p-4 space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-800"></h3>
<button
onClick={props.onClose}
class="text-gray-400 hover:text-gray-600 text-sm"
>
</button>
</div>
<p class="text-xs text-gray-500">
</p>
<input
type="text"
value={playerName()}
onInput={(e) => setPlayerName(e.currentTarget.value)}
placeholder="玩家名字"
class="w-full border border-gray-300 rounded px-2 py-1 text-xs"
autofocus
/>
<div class="flex gap-1">
<input
id="invite-link-input"
type="text"
value={inviteLink()}
readonly
class="flex-1 border border-gray-200 rounded px-2 py-1 text-xs bg-gray-50 text-gray-600 font-mono truncate"
/>
<button
onClick={handleCopy}
class={`shrink-0 rounded px-2 py-1 text-xs font-medium transition-colors ${
copied()
? "bg-green-100 text-green-700"
: "bg-blue-600 text-white hover:bg-blue-700"
}`}
>
{copied() ? "已复制!" : "复制"}
</button>
</div>
</div>
</div>
);
};

View File

@ -1,91 +0,0 @@
/**
* SessionDropdown session switcher for the journal header (GM only)
*/
import { Component, createSignal, For, Show } from "solid-js";
import {
sessions,
setSessionId,
hydrateFromServer,
useJournalStream,
} from "../stores/journalStream";
interface SessionDropdownProps {
onCreate: () => void;
}
export const SessionDropdown: Component<SessionDropdownProps> = (props) => {
const stream = useJournalStream();
const manifest = sessions;
const [dropdownOpen, setDropdownOpen] = createSignal(false);
let dropdownRef!: HTMLDivElement;
const handleSessionSelect = (id: string) => {
if (id === stream.sessionId) {
setDropdownOpen(false);
return;
}
setDropdownOpen(false);
setSessionId(id);
hydrateFromServer(id).catch(() => {});
};
// Close dropdown on outside click
if (typeof document !== "undefined") {
document.addEventListener("click", (e) => {
if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
setDropdownOpen(false);
}
});
}
return (
<div ref={dropdownRef} class="relative">
<button
onClick={() => setDropdownOpen((v) => !v)}
class="text-[10px] text-gray-400 hover:text-gray-600 truncate leading-tight cursor-pointer text-left"
>
{stream.sessionName || stream.sessionId}{" "}
<span class="text-gray-300"></span>
</button>
<Show when={dropdownOpen()}>
<div
class="absolute top-full left-0 mt-1 w-48 bg-white border border-gray-200 rounded shadow-lg z-50 py-1 max-h-48 overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<For each={Object.entries(manifest().sessions)}>
{([id, meta]) => (
<button
onClick={(e) => {
e.stopPropagation();
handleSessionSelect(id);
}}
class={`w-full text-left px-3 py-1 text-xs hover:bg-gray-100 flex items-center gap-1 ${
id === stream.sessionId
? "bg-blue-50 text-blue-700"
: "text-gray-700"
}`}
>
<span class="truncate flex-1">{meta.name || id}</span>
<Show when={id === stream.sessionId}>
<span class="text-blue-500 shrink-0"></span>
</Show>
</button>
)}
</For>
<div class="border-t border-gray-100 my-0.5" />
<button
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setDropdownOpen(false);
props.onCreate();
}}
class="w-full text-left px-3 py-1 text-xs text-blue-600 hover:bg-gray-100"
>
+
</button>
</div>
</Show>
</div>
);
};

View File

@ -37,11 +37,6 @@ export type {
// UI components
export { JournalPanel } from "./JournalPanel";
export type { JournalPanelProps } from "./JournalPanel";
export { JournalHeader } from "./JournalHeader";
export { ConnectDialog } from "./ConnectDialog";
export { InviteDialog } from "./InviteDialog";
export { SessionDropdown } from "./SessionDropdown";
export { CreateSessionDialog } from "./CreateSessionDialog";
export { ConnectionBar } from "./ConnectionBar";
export { StreamView } from "./StreamView";
export { StreamMessageCard } from "./StreamMessage";
@ -49,5 +44,3 @@ export { ComposePanel } from "./ComposePanel";
export { JournalInput } from "./JournalInput";
export { DynamicForm } from "./DynamicForm";
export { useJournalCompletions, invalidateCompletions } from "./completions";
export { JournalContext, useJournalContext } from "./JournalContext";
export type { JournalContextValue } from "./JournalContext";

View File

@ -11,7 +11,6 @@ import { z } from "zod";
import { registerMessageType } from "../registry";
import { journalSetState } from "../../stores/journalStream";
import { produce } from "solid-js/store";
import { useJournalContext } from "../JournalContext";
const schema = z.object({
path: z.string().min(1),
@ -45,7 +44,6 @@ function slugToTitle(slug: string): string {
const RevealLink: Component<LinkPayload> = (p) => {
const navigate = useNavigateWithParams();
const ctx = useJournalContext();
const target = () => {
let t = encodePath(p.path);
@ -61,10 +59,6 @@ const RevealLink: Component<LinkPayload> = (p) => {
const handleClick = (e: MouseEvent) => {
e.preventDefault();
navigate(target());
// On mobile, close the journal panel to reveal the article
if (window.innerWidth < 768) {
ctx?.onClose();
}
};
return (

View File

@ -4,6 +4,7 @@ import { getLayerStyle } from "./hooks/dimensions";
import type { CardData, CardSide, LayerConfig } from "./types";
import { DeckStore } from "./hooks/deckStore";
import { processVariables } from "../utils/csv-loader";
import { resolvePath } from "../utils/path";
import type { LayerInteractionHandlers } from "./hooks/useLayerInteraction";
export interface CardLayerProps {
@ -25,9 +26,13 @@ export function CardLayer(props: CardLayerProps) {
const draggingState = () => props.store.state.draggingState;
function renderLayerContent(content: string) {
const iconPath = resolvePath(
props.store.state.cards.sourcePath,
props.cardData.iconPath ?? "./assets",
);
return parseMarkdown(
processVariables(content, props.cardData, props.store.state.cards),
props.store.state.cards.sourcePath,
iconPath,
) as string;
}

View File

@ -1,7 +1,6 @@
import { customElement, noShadowDOM } from "solid-element";
import { Show, onCleanup } from "solid-js";
import { resolvePath } from "../utils/path";
import { v4 as uuidv4 } from "uuid";
import { createDeckStore } from "./hooks/deckStore";
import { registerDeck, unregisterDeck } from "./hooks/deck-registry";
import type { CardShape } from "./types";
@ -67,7 +66,7 @@ customElement<DeckProps>(
const store = createDeckStore(resolvedSrc);
// 生成唯一 ID 并注册到全局注册表
const deckId = `deck-${uuidv4()}`;
const deckId = `deck-${crypto.randomUUID()}`;
registerDeck(deckId, store, resolvedSrc, csvPath);
// 解析 size 属性(支持旧格式 "54x86" 和新格式)

View File

@ -1,5 +1,5 @@
import { customElement, noShadowDOM } from "solid-element";
import { createResource, Show, createEffect } from "solid-js";
import { createResource, Show, createMemo, createEffect } from "solid-js";
import { getIndexedData } from "../data-loader/file-index";
import { extractSection } from "../data-loader";
import { parseMarkdown } from "../markdown";
@ -27,6 +27,9 @@ customElement("md-embed", { headingBase: 0 }, (props, { element }) => {
const articlePath = articleEl?.getAttribute("data-src") || "";
const resolvedPath = resolvePath(articlePath, filePath);
// 计算嵌入文件的 iconPrefix用 createMemo 包装避免每个 embed 都重新计算
const iconPrefix = createMemo(() => resolvePath(resolvedPath, "./assets"));
const [content] = createResource(
() => ({ path: resolvedPath, section }),
async ({ path, section }) => {
@ -55,7 +58,7 @@ customElement("md-embed", { headingBase: 0 }, (props, { element }) => {
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: markdown render */}
<div
class="prose"
innerHTML={parseMarkdown(content()!, resolvedPath)}
innerHTML={parseMarkdown(content()!, iconPrefix())}
/>
</Show>
</div>

View File

@ -0,0 +1,216 @@
import {
createSignal,
onMount,
onCleanup,
Show, createEffect,
} from "solid-js";
import * as THREE from "three";
import { ThreeMFLoader } from "three/addons/loaders/3MFLoader.js";
export interface TokenViewerProps {
url: string | null;
}
export default function MdTokenViewer(props: TokenViewerProps) {
const [viewerRef, setViewerRef] = createSignal<HTMLDivElement | null>(null);
const [isLoaded, setIsLoaded] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
let scene: THREE.Scene | null = null;
let camera: THREE.PerspectiveCamera | null = null;
let renderer: THREE.WebGLRenderer | null = null;
let mesh: THREE.Mesh | null = null;
let group: THREE.Group | null = null;
let animationId: number | null = null;
let isDragging = false;
let previousMousePosition = { x: 0, y: 0 };
// 加载 3MF 用于预览
const load3mf = async (url: string) => {
const viewerEl = viewerRef();
if (!viewerEl) return;
// 清理旧的场景
if (group) {
scene?.remove(group);
group.traverse((obj) => {
if ((obj as THREE.Mesh).isMesh) {
const meshObj = obj as THREE.Mesh;
meshObj.geometry.dispose();
(meshObj.material as THREE.Material).dispose();
}
});
group = null;
mesh = null;
}
try {
const loader = new ThreeMFLoader();
const object = await loader.loadAsync(url);
// 3MF 文件可能返回一个 Group包含多个 Mesh
group = object instanceof THREE.Group ? object : new THREE.Group().add(object);
group.rotateX(Math.PI);
// 为每个 mesh 启用原始颜色
group.traverse((child) => {
if ((child as THREE.Mesh).isMesh) {
const childMesh = child as THREE.Mesh;
const material = childMesh.material as THREE.MeshStandardMaterial;
// 保留原始顶点颜色或材质颜色
if (childMesh.geometry.attributes.color) {
material.vertexColors = true;
}
// 确保材质是标准材质以支持光照
if (!(childMesh.material instanceof THREE.MeshStandardMaterial)) {
childMesh.material = new THREE.MeshStandardMaterial({
color: material.color,
metalness: 0.3,
roughness: 0.7,
});
}
}
});
scene?.add(group);
mesh = group.children[0] as THREE.Mesh;
// 调整相机
if (camera && scene) {
const box = new THREE.Box3().setFromObject(group);
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
camera.position.set(maxDim * 2, maxDim * 2, maxDim * 2);
camera.lookAt(0, 0, 0);
}
setIsLoaded(true);
} catch (e) {
console.error("加载 3MF 预览失败:", e);
setError(e instanceof Error ? e.message : "加载模型失败");
}
};
// 初始化 Three.js 场景
onMount(() => {
const viewerEl = viewerRef();
if (!viewerEl) return;
// 创建场景
scene = new THREE.Scene();
scene.background = new THREE.Color(0xf3f4f6);
// 创建相机
camera = new THREE.PerspectiveCamera(
45,
viewerEl.clientWidth / viewerEl.clientHeight,
0.1,
1000
);
camera.position.set(100, 100, 100);
// 创建渲染器
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(viewerEl.clientWidth, viewerEl.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
viewerEl.appendChild(renderer.domElement);
// 添加灯光
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(50, 100, 50);
directionalLight.castShadow = true;
scene.add(directionalLight);
// 添加坐标轴辅助
const axesHelper = new THREE.AxesHelper(10);
scene.add(axesHelper);
// 鼠标控制
const handleMouseDown = (e: MouseEvent) => {
isDragging = true;
previousMousePosition = { x: e.clientX, y: e.clientY };
};
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging || !group) return;
const deltaX = e.clientX - previousMousePosition.x;
const deltaY = e.clientY - previousMousePosition.y;
group.rotation.y += deltaX * 0.01;
group.rotation.x += deltaY * 0.01;
previousMousePosition = { x: e.clientX, y: e.clientY };
};
const handleMouseUp = () => {
isDragging = false;
};
viewerEl.addEventListener("mousedown", handleMouseDown);
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
// 动画循环
const animate = () => {
if (scene && camera && renderer && group) {
if (!isDragging) {
group.rotation.y += 0.005; // 自动旋转
}
renderer.render(scene, camera);
}
animationId = requestAnimationFrame(animate);
};
animate();
// 清理函数
onCleanup(() => {
if (animationId) cancelAnimationFrame(animationId);
viewerEl.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
if (renderer) {
renderer.dispose();
viewerEl.removeChild(renderer.domElement);
}
if (scene) scene.clear();
});
});
createEffect(() => {
if (props.url) {
load3mf(props.url);
}
});
return (
<div
ref={setViewerRef}
class="relative border rounded-lg overflow-hidden bg-gray-50 aspect-square"
style={{ "min-height": "200px" }}
>
<div class="absolute top-2 left-2 z-10 bg-black/50 text-white text-xs px-2 py-1 rounded">
</div>
<Show when={error()}>
<div class="absolute inset-0 flex items-center justify-center bg-black/50 text-white">
{error()}
</div>
</Show>
<Show when={!isLoaded() && !error()}>
<div class="absolute inset-0 flex items-center justify-center text-gray-500">
<div class="animate-spin inline-block w-6 h-6 border-2 border-current border-t-transparent rounded-full mr-2" />
...
</div>
</Show>
</div>
);
}

313
src/components/md-token.tsx Normal file
View File

@ -0,0 +1,313 @@
import { customElement, noShadowDOM } from "solid-element";
import {
Show,
For,
createResource,
createMemo,
createSignal,
onCleanup,
} from "solid-js";
import { resolvePath } from "./utils/path";
import { traceImage, type TracedLayer } from "./utils/image-tracer";
import { generate3MF, type ExtrusionSettings } from "./utils/3mf-generator";
import MdTokenViewer from "./md-token-viewer";
export interface TokenProps {
size?: number; // 模型整体尺寸 (mm), 默认 50
defaultThickness?: number; // 默认图层厚度 (mm), 默认 2
}
interface LayerSettings {
id: string;
name: string;
enabled: boolean;
thickness: number;
color: string;
}
customElement("md-token", { size: 50, defaultThickness: 2 }, (props, { element }) => {
noShadowDOM();
const [showEditor, setShowEditor] = createSignal(false);
const [layers, setLayers] = createSignal<LayerSettings[]>([]);
const [modelUrl, setModelUrl] = createSignal<string | null>(null);
const [isGenerating, setIsGenerating] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
// 从 element 的 textContent 获取图片路径
const rawSrc = element?.textContent?.trim() || "";
// 隐藏原始文本内容
if (element) {
element.textContent = "";
}
// 从父节点 article 的 data-src 获取当前 markdown 文件完整路径
const articleEl = element?.closest("article[data-src]");
const articlePath = articleEl?.getAttribute("data-src") || "";
// 解析相对路径
const resolvedSrc = resolvePath(articlePath, rawSrc);
// 加载图片
const loadImage = (src: string): Promise<HTMLImageElement> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
};
const [image] = createResource(resolvedSrc, loadImage);
// 图像加载完成后进行矢量追踪
const [traceResult] = createResource(
() => image(),
async (img) => {
if (!img) return null;
try {
const traced = await traceImage(img);
return traced;
} catch (e) {
setError(e instanceof Error ? e.message : "矢量追踪失败");
return null;
}
}
);
// 初始化图层设置
createMemo(() => {
const result = traceResult();
if (result && result.layers.length > 0) {
const layerSettings: LayerSettings[] = result.layers.map((layer, index) => ({
id: layer.id,
name: layer.name || `图层 ${index + 1}`,
enabled: true,
thickness: props.defaultThickness || 2,
color: `#${layer.color.getHexString()}` || `hsl(${(index * 60) % 360}, 70%, 50%)`,
}));
setLayers(layerSettings);
}
});
// 生成 3MF 模型
const generateModel = async () => {
if (!image()) return;
setIsGenerating(true);
setError(null);
try {
const enabledLayers = layers().filter((l) => l.enabled);
if (enabledLayers.length === 0) {
setError("请至少选择一个图层");
setIsGenerating(false);
return;
}
const settings: ExtrusionSettings = {
size: props.size || 50,
layers: enabledLayers.map((l) => ({
id: l.id,
thickness: l.thickness,
})),
};
const modelBlob = await generate3MF(image()!, traceResult()!, settings);
const url = URL.createObjectURL(modelBlob);
setModelUrl(url);
} catch (e) {
setError(e instanceof Error ? e.message : "生成模型失败");
} finally {
setIsGenerating(false);
}
};
// 更新图层厚度
const updateLayerThickness = (layerId: string, thickness: number) => {
setLayers((prev) =>
prev.map((l) => (l.id === layerId ? { ...l, thickness } : l))
);
};
// 切换图层启用状态
const toggleLayer = (layerId: string) => {
setLayers((prev) =>
prev.map((l) => (l.id === layerId ? { ...l, enabled: !l.enabled } : l))
);
};
// 下载 3MF 文件
const downloadModel = () => {
const url = modelUrl();
if (!url) return;
const a = document.createElement("a");
a.href = url;
a.download = `token-${Date.now()}.3mf`;
a.click();
};
// 清理 URL
onCleanup(() => {
const url = modelUrl();
if (url) {
URL.revokeObjectURL(url);
}
});
const visible = createMemo(() => !image.loading && !!image());
return (
<div class="md-token-component my-4">
<Show when={visible()}>
<div class="flex flex-col gap-4">
{/* 预览区域 */}
<div class="flex flex-wrap gap-4 items-start">
{/* 原始图片预览 */}
<div class="flex-1 min-w-[200px]">
<h4 class="text-sm font-semibold mb-2 text-gray-700"></h4>
<div class="relative border rounded-lg overflow-hidden bg-gray-50">
<img
src={resolvedSrc}
alt="Token source"
class="max-w-full h-auto max-h-[300px] object-contain"
/>
</div>
</div>
{/* 3D 模型预览 */}
<Show when={modelUrl()}>
<div class="flex-1 min-w-[200px]">
<h4 class="text-sm font-semibold mb-2 text-gray-700">
3D
</h4>
<MdTokenViewer url={modelUrl()}/>
</div>
</Show>
</div>
{/* 错误信息 */}
<Show when={error()}>
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded">
{error()}
</div>
</Show>
{/* 控制面板 */}
<div class="border rounded-lg p-4 bg-white shadow-sm">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-800">
🎯 Token
</h3>
<button
onClick={() => setShowEditor(!showEditor())}
class="text-sm text-blue-600 hover:text-blue-800"
>
{showEditor() ? "隐藏设置" : "显示设置"}
</button>
</div>
<Show when={showEditor()}>
<div class="space-y-4">
{/* 整体尺寸设置 */}
<div class="flex items-center gap-4">
<label class="text-sm font-medium text-gray-700 min-w-[100px]">
(mm)
</label>
<input
type="range"
min="10"
max="100"
step="1"
value={props.size || 50}
class="flex-1"
onChange={(e) => {
const newSize = parseInt(e.target.value);
element?.setAttribute("size", newSize.toString());
}}
/>
<span class="text-sm text-gray-600 w-12 text-right">
{props.size || 50}mm
</span>
</div>
{/* 图层列表 */}
<div>
<h4 class="text-sm font-medium text-gray-700 mb-2">
</h4>
<div class="space-y-2 max-h-[300px] overflow-y-auto">
<For each={layers()}>
{(layer) => (
<div class="flex items-center gap-3 p-2 border rounded hover:bg-gray-50">
<input
type="checkbox"
checked={layer.enabled}
onChange={() => toggleLayer(layer.id)}
class="w-4 h-4"
/>
<div
class="w-4 h-4 rounded"
style={{ "background-color": layer.color }}
/>
<span class="flex-1 text-sm text-gray-700">
{layer.name}
</span>
<label class="text-xs text-gray-500">:</label>
<input
type="number"
min="0.5"
max="10"
step="0.5"
value={layer.thickness}
class="w-16 px-2 py-1 border rounded text-sm"
onChange={(e) =>
updateLayerThickness(
layer.id,
parseFloat(e.target.value) || 2
)
}
/>
<span class="text-xs text-gray-500">mm</span>
</div>
)}
</For>
</div>
</div>
{/* 生成按钮 */}
<div class="flex gap-2 pt-2">
<button
onClick={generateModel}
disabled={isGenerating()}
class="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white px-4 py-2 rounded font-medium transition-colors"
>
{isGenerating() ? "生成中..." : "🔄 生成 3D 模型"}
</button>
<Show when={modelUrl()}>
<button
onClick={downloadModel}
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded font-medium transition-colors"
>
📥 3MF
</button>
</Show>
</div>
</div>
</Show>
</div>
</div>
</Show>
{/* 加载状态 */}
<Show when={image.loading}>
<div class="text-center py-8 text-gray-500">
<div class="animate-spin inline-block w-6 h-6 border-2 border-current border-t-transparent rounded-full mr-2" />
...
</div>
</Show>
</div>
);
});

View File

@ -0,0 +1,119 @@
import * as THREE from "three";
import { exportTo3MF } from "three-3mf-exporter";
import type { TraceResult } from "./image-tracer";
import {Vector3} from "three";
export interface ExtrusionSettings {
size: number; // 模型整体尺寸 (mm)
layers: Array<{
id: string;
thickness: number; // 图层厚度 (mm)
}>;
}
export interface LayerMesh {
id: string;
mesh: THREE.Mesh;
thickness: number;
color: number;
}
/**
* 使
*/
function generateLayerColor(index: number): number {
const hue = (index * 137.508) % 360; // 黄金角
return new THREE.Color(`hsl(${hue}, 70%, 50%)`).getHex();
}
/**
* 3MF
* @param image -
* @param traceResult -
* @param settings -
* @returns 3MF Blob
*/
export async function generate3MF(
image: HTMLImageElement,
traceResult: TraceResult,
settings: ExtrusionSettings
): Promise<Blob> {
// 创建 Three.js 场景
const scene = new THREE.Scene();
// 计算缩放比例,使模型适应指定尺寸
const maxDimension = Math.max(traceResult.width, traceResult.height);
const scale = settings.size / maxDimension;
// 中心偏移
const offsetX = -traceResult.width / 2;
const offsetY = -traceResult.height / 2;
// 为每个启用的图层创建网格
let currentHeight = 0;
const meshes: LayerMesh[] = [];
let layerIndex = 0;
let boundingBox = new THREE.Box3();
for (const layerSetting of settings.layers) {
const layer = traceResult.layers.find((l) => l.id === layerSetting.id);
if (!layer) continue;
// 创建挤压几何体
const extrudeSettings: THREE.ExtrudeGeometryOptions = {
depth: layerSetting.thickness,
curveSegments: 36,
bevelEnabled: true,
bevelSize: 0.4
};
const geometry: THREE.ExtrudeGeometry = new THREE.ExtrudeGeometry(layer.paths, extrudeSettings);
geometry.computeBoundingBox();
// 为该图层生成颜色
const color = generateLayerColor(layerIndex);
const material = new THREE.MeshStandardMaterial({
color,
metalness: 0.3,
roughness: 0.7,
});
const mesh = new THREE.Mesh(geometry, material);
boundingBox.expandByObject(mesh);
// 设置图层高度(堆叠)
mesh.position.y = currentHeight;
scene.add(mesh);
meshes.push({
id: layerSetting.id,
mesh,
thickness: layerSetting.thickness,
color,
});
currentHeight += layerSetting.thickness;
layerIndex++;
}
const center = boundingBox.getCenter(new Vector3());
for(const mesh of meshes){
mesh.mesh.position.sub(center);
}
if (meshes.length === 0) {
throw new Error("没有可生成的图层");
}
// 导出为 3MF
const data = await exportTo3MF(scene);
const blob = new Blob([data], { type: "model/3mf" });
// 清理
scene.clear();
meshes.forEach(({ mesh }) => {
mesh.geometry.dispose();
(mesh.material as THREE.Material).dispose();
});
return blob;
}

View File

@ -0,0 +1,92 @@
import { ImageTracer, Options } from "@image-tracer-ts/core";
//@ts-ignore
import {SVGLoader, SVGResult} from "three/examples/jsm/loaders/SVGLoader";
import {Color, ShapePath, Shape} from "three";
export interface TracedLayer {
id: string;
name: string;
color: Color;
paths: Shape[];
}
export interface PathData {
points: Point[];
isClosed: boolean;
}
export interface Point {
x: number;
y: number;
}
export interface TraceResult {
width: number;
height: number;
layers: TracedLayer[];
}
/**
*
* @param image -
* @param options -
* @returns
*/
export async function traceImage(
image: HTMLImageElement,
options?: Partial<Options>
): Promise<TraceResult> {
// 创建 canvas 来获取图片数据
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("无法创建 canvas 上下文");
}
// 设置 canvas 尺寸为图片原始尺寸
canvas.width = image.naturalWidth || image.width;
canvas.height = image.naturalHeight || image.height;
// 绘制图片到 canvas
ctx.drawImage(image, 0, 0);
// 获取图片数据
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// 默认配置 - 使用 detailed preset 作为基础
const defaultOptions: Partial<Options> = {
...Options.Presets.default,
numberOfColors: 8, // 限制颜色数量以控制图层数
minColorQuota: 0.01, // 降低最小颜色占比阈值
strokeWidth: 0, // 不需要描边
lineFilter: true,
...options,
};
// 创建追踪器
const tracer = new ImageTracer(defaultOptions);
// 执行追踪,返回 SVG 字符串
const svgString = tracer.traceImage(imageData);
// 解析 SVG 字符串
const loader = new SVGLoader();
const result = loader.parse(svgString) as SVGResult;
const paths: ShapePath[] = result.paths;
const layers: TracedLayer[] = paths.map((path, i,) => {
return {
id: `layer-${i}`,
name: `颜色层 ${i + 1}`,
color: path.color,
paths: SVGLoader.createShapes(path),
};
});
return {
width: canvas.width,
height: canvas.height,
layers,
};
}

View File

@ -188,43 +188,6 @@ export function scanSparkTables(
}));
}
// ---------------------------------------------------------------------------
// Range parsing
// ---------------------------------------------------------------------------
/**
* Parse a cell value like "1-3", "4-6", or "10-20" into { min, max }.
* Returns null if the cell doesn't represent a range.
*/
function parseRange(cell: string): { min: number; max: number } | null {
const trimmed = cell.trim();
const m = /^(\d+)\s*-\s*(\d+)$/.exec(trimmed);
if (!m) return null;
const min = parseInt(m[1], 10);
const max = parseInt(m[2], 10);
if (isNaN(min) || isNaN(max)) return null;
return { min, max };
}
/** Test whether a rolled total matches a cell value */
function matchesCell(diceCell: string, rolledValue: number): boolean {
const trimmed = diceCell.trim();
// Exact integer match
const cellNum = parseInt(trimmed, 10);
if (!isNaN(cellNum) && rolledValue === cellNum) return true;
// Exact string match (for non-numeric labels)
if (String(rolledValue) === trimmed) return true;
// Range match (e.g. "1-3")
const range = parseRange(trimmed);
if (range && rolledValue >= range.min && rolledValue <= range.max)
return true;
return false;
}
// ---------------------------------------------------------------------------
// Rolling
// ---------------------------------------------------------------------------
@ -244,11 +207,17 @@ export function rollSparkTable(meta: SparkTableMeta): SparkTableResult {
const roll = rollFormula(meta.notation);
const rolledValue = roll.result.total;
// Find the row matching the rolled value
// Find the row matching the rolled value — compare as integers,
// falling back to string comparison.
let value = `(no row for ${rolledValue})`;
for (const row of meta.rows) {
const diceCell = row[0] ?? "";
if (matchesCell(diceCell, rolledValue)) {
const cellNum = parseInt(diceCell.trim(), 10);
const match =
!isNaN(cellNum) && rolledValue === cellNum
? true
: String(rolledValue) === diceCell.trim();
if (match) {
value = row[colIdx + 1] ?? "";
break;
}

View File

@ -0,0 +1,172 @@
import * as THREE from "three";
import type { TraceResult, PathData, Point } from "./image-tracer";
export interface ExtrusionSettings {
size: number; // 模型整体尺寸 (mm)
layers: Array<{
id: string;
thickness: number; // 图层厚度 (mm)
}>;
}
export interface LayerMesh {
id: string;
mesh: THREE.Mesh;
thickness: number;
}
/**
* STL
* @param image -
* @param traceResult -
* @param settings -
* @returns STL Blob
*/
export async function generateSTL(
image: HTMLImageElement,
traceResult: TraceResult,
settings: ExtrusionSettings
): Promise<Blob> {
// 创建 Three.js 场景
const scene = new THREE.Scene();
// 计算缩放比例,使模型适应指定尺寸
const maxDimension = Math.max(traceResult.width, traceResult.height);
const scale = settings.size / maxDimension;
// 中心偏移
const offsetX = -traceResult.width / 2;
const offsetY = -traceResult.height / 2;
// 为每个启用的图层创建网格
let currentHeight = 0;
const meshes: LayerMesh[] = [];
for (const layerSetting of settings.layers) {
const layer = traceResult.layers.find((l) => l.id === layerSetting.id);
if (!layer) continue;
// 创建挤压几何体 - Three.js 支持直接传入 shape 数组
const extrudeSettings: THREE.ExtrudeGeometryOptions = {
depth: layerSetting.thickness,
curveSegments: 12,
bevelEnabled: false,
};
// 直接将所有 shape 传给 ExtrudeGeometry它会自动处理多个 shape
const geometry = new THREE.ExtrudeGeometry(layer.paths, extrudeSettings);
// 创建网格并设置位置
const material = new THREE.MeshBasicMaterial({ color: 0x808080 });
const mesh = new THREE.Mesh(geometry, material);
// 设置图层高度(堆叠)
mesh.position.y = currentHeight;
scene.add(mesh);
meshes.push({
id: layerSetting.id,
mesh,
thickness: layerSetting.thickness,
});
currentHeight += layerSetting.thickness;
}
if (meshes.length === 0) {
throw new Error("没有可生成的图层");
}
// 导出为 STL
const stlString = exportToSTL(scene);
const blob = new Blob([stlString], { type: "model/stl" });
// 清理
scene.clear();
meshes.forEach(({ mesh }) => {
mesh.geometry.dispose();
(mesh.material as THREE.Material).dispose();
});
return blob;
}
/**
* Three.js
*/
function createShapeFromPath(
path: PathData,
scale: number,
offsetX: number,
offsetY: number
): THREE.Shape | null {
if (path.points.length < 2) return null;
const shape = new THREE.Shape();
// 移动到起点
const startPoint = path.points[0];
shape.moveTo(
(startPoint.x + offsetX) * scale,
(startPoint.y + offsetY) * scale
);
// 绘制线段到后续点
for (let i = 1; i < path.points.length; i++) {
const point = path.points[i];
shape.lineTo(
(point.x + offsetX) * scale,
(point.y + offsetY) * scale
);
}
// 如果是闭合路径,闭合形状
if (path.isClosed) {
shape.closePath();
}
return shape;
}
/**
* Three.js ASCII STL
*/
function exportToSTL(scene: THREE.Scene): string {
let output = "solid token\n";
scene.traverse((object) => {
if (object instanceof THREE.Mesh && object.geometry) {
const geometry = object.geometry as THREE.BufferGeometry;
const positions = geometry.getAttribute("position") as THREE.BufferAttribute;
const normals = geometry.getAttribute("normal") as THREE.BufferAttribute;
for (let i = 0; i < positions.count; i += 3) {
// 获取法线
let normalStr = "";
if (normals) {
const nx = normals.getX(i);
const ny = normals.getY(i);
const nz = normals.getZ(i);
normalStr = ` normal ${nx.toFixed(6)} ${ny.toFixed(6)} ${nz.toFixed(6)}`;
}
output += ` facet${normalStr}\n`;
output += " outer loop\n";
// 获取三个顶点
for (let j = 0; j < 3; j++) {
const x = positions.getX(i + j);
const y = positions.getY(i + j);
const z = positions.getZ(i + j);
output += ` vertex ${x.toFixed(6)} ${y.toFixed(6)} ${z.toFixed(6)}\n`;
}
output += " endloop\n";
output += " endfacet\n";
}
}
});
output += "endsolid token\n";
return output;
}

View File

@ -2,7 +2,7 @@
*
*
* 1. CLI /__CONTENT_INDEX.json
* 2. Dev 使 webpackContext .md dev
* 2. Dev 使 webpackContext .md
* 3.
* - IndexedDB
*/
@ -25,7 +25,7 @@ let activeDirHandle: FileSystemDirectoryHandle | null = null;
/**
*
* CLI JSON webpack context (dev only)
* CLI JSON webpack context
*/
function ensureIndexLoaded(): Promise<void> {
if (indexLoadPromise) return indexLoadPromise;
@ -44,28 +44,25 @@ function ensureIndexLoaded(): Promise<void> {
// CLI 索引不可用时尝试下一个策略
}
// 策略 2: Dev 环境 — webpackContext + raw loader仅 dev 构建时生效)
// 生产构建时 process.env.NODE_ENV 被替换为 'production',整个分支会被 tree-shake 掉
if (process.env.NODE_ENV === "development") {
try {
const context = import.meta.webpackContext("../../content", {
recursive: true,
regExp: /\.md|\.yarn|\.csv$/i,
});
const keys = context.keys();
const index: FileIndex = {};
for (const key of keys) {
const module = context(key) as { default?: string } | string;
const content =
typeof module === "string" ? module : (module.default ?? "");
const normalizedPath = "/content" + key.slice(1);
index[normalizedPath] = content;
}
fileIndex = { ...fileIndex, ...index };
activeSource = "webpack";
} catch (e) {
// webpackContext 不可用时忽略
// 策略 2: Dev 环境 — webpackContext + raw loader
try {
const context = import.meta.webpackContext("../../content", {
recursive: true,
regExp: /\.md|\.yarn|\.csv$/i,
});
const keys = context.keys();
const index: FileIndex = {};
for (const key of keys) {
const module = context(key) as { default?: string } | string;
const content =
typeof module === "string" ? module : (module.default ?? "");
const normalizedPath = "/content" + key.slice(1);
index[normalizedPath] = content;
}
fileIndex = { ...fileIndex, ...index };
activeSource = "webpack";
} catch (e) {
// webpackContext 不可用时忽略
}
// 策略 3: 浏览器 — 尝试从 IndexedDB 恢复保存的目录句柄

View File

@ -0,0 +1,17 @@
---
tag: md-token-viewer
icon: 🎨
title: 代币预览组件
description: 使用 Three.js 在浏览器中 3D 渲染 3MF 格式的代币模型,支持旋转查看。
syntax: ':md-token-viewer[./token.3mf]'
props: []
---
**3D 预览模型:**
:md-token-viewer[./dragon.3mf]
支持鼠标拖拽旋转和自动旋转,从多角度查看模型细节。
## 使用场景
用于 3D 打印前的代币模型预览。

View File

@ -0,0 +1,17 @@
---
tag: md-token
icon: 🪙
title: 代币组件
description: 展示游戏代币或棋子的图片。
syntax: ':md-token[./token.png]'
props: []
---
**展示代币:**
:md-token[./goblin.png]
代币图片自动渲染为圆形,适合战棋或卡牌中的棋子展示。
## 使用场景
用于展示怪物代币、角色棋子、道具图标等。

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tabletop Tools</title>
<title>TTRPG Tools</title>
</head>
<body>
<div id="root"></div>

View File

@ -1,78 +0,0 @@
/**
* marked-directive :[icon] :[icon.ext]
*
* CSS fallback
* url('/pages/sub/deep/assets/icon.png'),
* url('/pages/sub/assets/icon.png'),
* url('/pages/assets/icon.png'),
* url('/assets/icon.png')
*
* .svg, .png, .gif, .jpg, .jpeg, .webp .png
*/
const SUPPORTED_EXTENSIONS = ["svg", "png", "gif", "jpg", "jpeg", "webp"];
let globalBasePath: string | undefined;
/** 设置当前 markdown 文件路径,用于解析 ./assets */
export function setIconBase(basePath: string | undefined) {
globalBasePath = basePath;
}
/** 生成多层 fallback 路径,从文件所在目录向上查找 */
function buildIconSrc(iconName: string, extension: string): string {
if (!globalBasePath) return "";
// 取文件所在目录,如 /pages/sub/deep/file.md → /pages/sub/deep
const dir =
globalBasePath.substring(0, globalBasePath.lastIndexOf("/")) || "/";
const parts = dir.split("/").filter(Boolean);
const urls: string[] = [];
// 从当前目录开始,逐级向上
for (let i = parts.length; i >= 0; i--) {
const prefix = "/" + parts.slice(0, i).join("/");
const base = prefix === "/" ? "/assets" : `${prefix}/assets`;
urls.push(`url('${base}/${iconName}.${extension}')`);
}
return urls.join(", ");
}
export const iconDirective = {
level: "inline" as const,
marker: ":",
renderer(token: any) {
// meta.name 非空表示这是一个命名指令(如 :div不是图标
if (token.meta.name) return false;
const iconText = token.text || "";
let iconName = iconText;
let extension = "png";
const lastDotIndex = iconName.lastIndexOf(".");
if (lastDotIndex > 0) {
const potentialExt = iconName.slice(lastDotIndex + 1).toLowerCase();
if (SUPPORTED_EXTENSIONS.includes(potentialExt)) {
extension = potentialExt;
iconName = iconName.slice(0, lastDotIndex);
}
}
const label = token.attrs?.label as string | undefined;
const inner = label
? `<span class="icon-label-stroke">${label}</span><span class="icon-label">${label}</span>`
: "";
const style = `style="--icon-src: ${buildIconSrc(iconName, extension)}"`;
const iconHtml = `<icon ${style} class="icon-${iconName} ${token.attrs?.class || ""}">${inner}</icon>`;
const repeat = parseInt(`${token.attrs?.repeat || ""}`);
const join = token.attrs?.join || "";
const separator = join ? `<${join}></${join}>` : "";
if (isNaN(repeat) || repeat < 1) return iconHtml;
return Array(repeat).fill(iconHtml).join(separator);
},
};

View File

@ -6,7 +6,14 @@ import markedTable from "./table";
import { gfmHeadingId } from "marked-gfm-heading-id";
import markedColumns from "./columns";
import markedCodeBlockYamlTag from "./code-block-yaml-tag";
import { iconDirective, setIconBase } from "./icon";
let globalIconPrefix: string | undefined = undefined;
function overrideIconPrefix(path?: string) {
globalIconPrefix = path;
return () => {
globalIconPrefix = undefined;
};
}
// 使用 marked-directive 来支持指令语法
const marked = new Marked()
@ -26,16 +33,73 @@ const marked = new Marked()
marker: ":::::",
level: "container",
},
iconDirective,
{
level: "inline",
marker: ":",
// :[icon] 或 :[icon.ext] 语法
// 支持的扩展名: .svg, .png, .gif, .jpg, .jpeg, .webp
// 如果不指定扩展名,默认为 .png
renderer(token) {
if (!token.meta.name) {
const iconText = token.text || "";
// 已知支持的图片扩展名
const supportedExtensions = [
"svg",
"png",
"gif",
"jpg",
"jpeg",
"webp",
];
// 检查是否包含扩展名(查找最后一个点)
let iconName = iconText;
let extension = "png"; // 默认扩展名
const lastDotIndex = iconName.lastIndexOf(".");
if (lastDotIndex > 0) {
const potentialExt = iconName
.slice(lastDotIndex + 1)
.toLowerCase();
if (supportedExtensions.includes(potentialExt)) {
extension = potentialExt;
iconName = iconName.slice(0, lastDotIndex);
}
}
const label = token.attrs?.label as string | undefined;
const inner = label
? `<span class="icon-label-stroke">${label}</span><span class="icon-label">${label}</span>`
: "";
const style = globalIconPrefix
? `style="--icon-src: url('${globalIconPrefix}/${iconName}.${extension}')"`
: "";
const iconHtml = `<icon ${style} class="icon-${iconName} ${token.attrs?.class || ""}">${inner}</icon>`;
const repeat = parseInt(`${token.attrs?.repeat || ""}`);
const join = token.attrs?.join || "";
const separator = join ? `<${join}></${join}>` : "";
if (isNaN(repeat) || repeat < 1) return iconHtml;
return Array(repeat).fill(iconHtml).join(separator);
}
return false;
},
},
]),
{
extensions: [...markedColumns()],
},
);
export function parseMarkdown(content: string, basePath?: string): string {
setIconBase(basePath);
return marked.parse(content.trimStart()) as string;
export function parseMarkdown(content: string, iconPrefix?: string): string {
const restore = overrideIconPrefix(iconPrefix);
try {
return marked.parse(content.trimStart()) as string;
} finally {
restore();
}
}
export { marked };