Compare commits
17 Commits
c10b088fb1
...
ae3045f96b
| Author | SHA1 | Date |
|---|---|---|
|
|
ae3045f96b | |
|
|
6ab6b76978 | |
|
|
2dba19e1f8 | |
|
|
749b1a6a4b | |
|
|
2a9eee6410 | |
|
|
9a5ee695c2 | |
|
|
e0c3b8f4a0 | |
|
|
d95615f210 | |
|
|
5097aba842 | |
|
|
b54f7ab1fa | |
|
|
c13f66153a | |
|
|
376dde44b5 | |
|
|
03671583cd | |
|
|
2c2b7b781d | |
|
|
3d957706e9 | |
|
|
57ffb35f2b | |
|
|
3eba8b3279 |
|
|
@ -41,7 +41,7 @@ ttrpg-tools/
|
||||||
│ │ ├── md-pins.tsx # 标记组件
|
│ │ ├── md-pins.tsx # 标记组件
|
||||||
│ │ ├── md-commander/ # 命令追踪器
|
│ │ ├── md-commander/ # 命令追踪器
|
||||||
│ │ ├── md-yarn-spinner.tsx # 叙事线组件
|
│ │ ├── md-yarn-spinner.tsx # 叙事线组件
|
||||||
│ │ ├── md-token.tsx # 代币组件
|
|
||||||
│ │ ├── Article.tsx # 文章组件
|
│ │ ├── Article.tsx # 文章组件
|
||||||
│ │ ├── Sidebar.tsx # 侧边栏
|
│ │ ├── Sidebar.tsx # 侧边栏
|
||||||
│ │ ├── FileTree.tsx # 文件树
|
│ │ ├── FileTree.tsx # 文件树
|
||||||
|
|
@ -77,7 +77,7 @@ ttrpg-tools/
|
||||||
| 样式 | Tailwind CSS 4 + @tailwindcss/typography |
|
| 样式 | Tailwind CSS 4 + @tailwindcss/typography |
|
||||||
| Markdown | marked + marked-directive + marked-alert + marked-gfm-heading-id |
|
| Markdown | marked + marked-directive + marked-alert + marked-gfm-heading-id |
|
||||||
| 图表 | mermaid |
|
| 图表 | mermaid |
|
||||||
| 3D | three.js |
|
|
||||||
| 测试 | Jest |
|
| 测试 | Jest |
|
||||||
| CLI | commander + chokidar |
|
| CLI | commander + chokidar |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -431,26 +431,7 @@ layers="字段:起始行,起始列 - 结束列,字体大小"
|
||||||
:md-yarn-spinner[./story.yarn]
|
: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)
|
### 📋 命令追踪器 (md-commander)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -335,7 +335,7 @@ MCP 服务器提供以下 Resources,包含 TTRPG Tools 的文档和参考材
|
||||||
- `:md-table` - 表格组件
|
- `:md-table` - 表格组件
|
||||||
- `:md-deck` - 卡牌组件
|
- `:md-deck` - 卡牌组件
|
||||||
- `:md-yarn-spinner` - 叙事线组件
|
- `:md-yarn-spinner` - 叙事线组件
|
||||||
- `:md-token` - 代币组件
|
|
||||||
- `:md-commander` - 命令追踪器
|
- `:md-commander` - 命令追踪器
|
||||||
- YAML 标签
|
- YAML 标签
|
||||||
- Mermaid 图表
|
- Mermaid 图表
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@
|
||||||
"@modelcontextprotocol/sdk": "^0.5.0",
|
"@modelcontextprotocol/sdk": "^0.5.0",
|
||||||
"@solidjs/router": "^0.15.0",
|
"@solidjs/router": "^0.15.0",
|
||||||
"@thisbeyond/solid-dnd": "^0.7.5",
|
"@thisbeyond/solid-dnd": "^0.7.5",
|
||||||
"@types/three": "^0.183.1",
|
|
||||||
"aedes": "^1.1.1",
|
"aedes": "^1.1.1",
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
|
|
@ -27,17 +26,16 @@
|
||||||
"mqtt": "^5.15.1",
|
"mqtt": "^5.15.1",
|
||||||
"solid-element": "^1.9.1",
|
"solid-element": "^1.9.1",
|
||||||
"solid-js": "^1.9.3",
|
"solid-js": "^1.9.3",
|
||||||
"three": "^0.183.2",
|
"uuid": "^14.0.1",
|
||||||
"three-3mf-exporter": "^45.1.0",
|
|
||||||
"ws": "^8.21.0",
|
"ws": "^8.21.0",
|
||||||
"yarn-spinner-loader": "^0.1.0",
|
"yarn-spinner-loader": "^0.1.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"ttrpg": "dist/cli/index.js"
|
"ttrpg": "dist/cli/index.js",
|
||||||
|
"ttt": "dist/cli/index.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@image-tracer-ts/core": "^1.0.2",
|
|
||||||
"@rsbuild/core": "^1.1.8",
|
"@rsbuild/core": "^1.1.8",
|
||||||
"@rsbuild/plugin-babel": "^1.1.0",
|
"@rsbuild/plugin-babel": "^1.1.0",
|
||||||
"@rsbuild/plugin-solid": "^1.1.0",
|
"@rsbuild/plugin-solid": "^1.1.0",
|
||||||
|
|
@ -47,14 +45,17 @@
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/node": "^22.19.13",
|
"@types/node": "^22.19.13",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"ts-jest": "^29.4.6",
|
"ts-jest": "^29.4.6",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3",
|
||||||
|
"webpack-bundle-analyzer": "^5.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
|
|
@ -871,11 +872,15 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@dimforge/rapier3d-compat": {
|
"node_modules/@discoveryjs/json-ext": {
|
||||||
"version": "0.12.0",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz",
|
||||||
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
"integrity": "sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ==",
|
||||||
"license": "Apache-2.0"
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17.0"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.9.2",
|
"version": "1.9.2",
|
||||||
|
|
@ -908,6 +913,13 @@
|
||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@iconify/types": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
|
||||||
|
|
@ -925,13 +937,6 @@
|
||||||
"mlly": "^1.8.0"
|
"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": {
|
"node_modules/@istanbuljs/load-nyc-config": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
|
||||||
|
|
@ -2012,6 +2017,13 @@
|
||||||
"url": "https://github.com/sponsors/Boshen"
|
"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": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.0-rc.12",
|
"version": "1.0.0-rc.12",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz",
|
||||||
|
|
@ -2942,12 +2954,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
|
|
@ -3354,27 +3360,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/tough-cookie": {
|
||||||
"version": "4.0.5",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
|
||||||
|
|
@ -3389,10 +3374,11 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/webxr": {
|
"node_modules/@types/uuid": {
|
||||||
"version": "0.5.24",
|
"version": "10.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||||
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/ws": {
|
"node_modules/@types/ws": {
|
||||||
|
|
@ -3431,12 +3417,6 @@
|
||||||
"d3-transition": "^3.0.1"
|
"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": {
|
"node_modules/abab": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
|
||||||
|
|
@ -4441,12 +4421,6 @@
|
||||||
"url": "https://opencollective.com/core-js"
|
"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": {
|
"node_modules/cose-base": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz",
|
||||||
|
|
@ -4503,6 +4477,24 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
|
@ -5576,41 +5568,6 @@
|
||||||
],
|
],
|
||||||
"license": "BSD-3-Clause"
|
"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": {
|
"node_modules/fastparallel": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz",
|
||||||
|
|
@ -5640,12 +5597,6 @@
|
||||||
"bser": "2.1.1"
|
"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": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
|
|
@ -6082,12 +6033,6 @@
|
||||||
],
|
],
|
||||||
"license": "BSD-3-Clause"
|
"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": {
|
"node_modules/import-local": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
|
||||||
|
|
@ -6247,12 +6192,6 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
|
|
@ -7959,18 +7898,6 @@
|
||||||
"node": ">=6"
|
"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": {
|
"node_modules/katex": {
|
||||||
"version": "0.16.45",
|
"version": "0.16.45",
|
||||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz",
|
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz",
|
||||||
|
|
@ -8044,15 +7971,6 @@
|
||||||
"node": ">=6"
|
"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": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
|
|
@ -8514,11 +8432,18 @@
|
||||||
"node": ">= 20"
|
"node": ">= 20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/meshoptimizer": {
|
"node_modules/mermaid/node_modules/uuid": {
|
||||||
"version": "1.0.1",
|
"version": "11.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
|
||||||
"integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==",
|
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
|
||||||
"license": "MIT"
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/esm/bin/uuid"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/micromatch": {
|
"node_modules/micromatch": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
|
|
@ -8713,6 +8638,16 @@
|
||||||
"safe-buffer": "~5.2.0"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|
@ -8832,6 +8767,16 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/p-limit": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||||
|
|
@ -8893,12 +8838,6 @@
|
||||||
"integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==",
|
"integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/parse-json": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
|
||||||
|
|
@ -8947,21 +8886,6 @@
|
||||||
"node": ">=8"
|
"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": {
|
"node_modules/path-is-absolute": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||||
|
|
@ -9298,21 +9222,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/readdirp": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||||
|
|
@ -9567,12 +9476,6 @@
|
||||||
"seroval": "^1.0"
|
"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": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
|
@ -9609,6 +9512,21 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/sisteransi": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
|
||||||
|
|
@ -9840,18 +9758,6 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/stylis": {
|
||||||
"version": "4.3.6",
|
"version": "4.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
|
||||||
|
|
@ -9927,25 +9833,6 @@
|
||||||
"node": ">=8"
|
"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": {
|
"node_modules/tinyexec": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz",
|
||||||
|
|
@ -10034,6 +9921,16 @@
|
||||||
"node": ">=0.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": {
|
"node_modules/tough-cookie": {
|
||||||
"version": "4.1.4",
|
"version": "4.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
|
||||||
|
|
@ -10338,16 +10235,16 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "11.1.0",
|
"version": "14.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
|
||||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
"integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==",
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://github.com/sponsors/broofa",
|
"https://github.com/sponsors/broofa",
|
||||||
"https://github.com/sponsors/ctavan"
|
"https://github.com/sponsors/ctavan"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"uuid": "dist/esm/bin/uuid"
|
"uuid": "dist-node/bin/uuid"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/v8-compile-cache-lib": {
|
"node_modules/v8-compile-cache-lib": {
|
||||||
|
|
@ -10547,6 +10444,51 @@
|
||||||
"node": ">=12"
|
"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": {
|
"node_modules/whatwg-encoding": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz",
|
||||||
|
|
|
||||||
16
package.json
16
package.json
|
|
@ -1,10 +1,11 @@
|
||||||
{
|
{
|
||||||
"name": "ttrpg-tools",
|
"name": "ttrpg-tools",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"description": "A TTRPG toolbox based on solid.js and rsbuild",
|
"description": "A tabletop RPG toolbox based on solid.js and rsbuild",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"ttrpg": "./dist/cli/index.js"
|
"ttrpg": "./dist/cli/index.js",
|
||||||
|
"ttt": "./dist/cli/index.js"
|
||||||
},
|
},
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"module": "./dist/index.js",
|
"module": "./dist/index.js",
|
||||||
|
|
@ -17,6 +18,7 @@
|
||||||
"dev": "rsbuild dev",
|
"dev": "rsbuild dev",
|
||||||
"build": "rsbuild build",
|
"build": "rsbuild build",
|
||||||
"preview": "rsbuild preview",
|
"preview": "rsbuild preview",
|
||||||
|
"analyze": "cross-env ANALYZE=true rsbuild build",
|
||||||
"cli:dev": "tsc -p tsconfig.cli.json --watch",
|
"cli:dev": "tsc -p tsconfig.cli.json --watch",
|
||||||
"cli:build": "tsc -p tsconfig.cli.json",
|
"cli:build": "tsc -p tsconfig.cli.json",
|
||||||
"ttrpg": "node --loader ts-node/esm ./src/cli/index.ts",
|
"ttrpg": "node --loader ts-node/esm ./src/cli/index.ts",
|
||||||
|
|
@ -35,7 +37,6 @@
|
||||||
"@modelcontextprotocol/sdk": "^0.5.0",
|
"@modelcontextprotocol/sdk": "^0.5.0",
|
||||||
"@solidjs/router": "^0.15.0",
|
"@solidjs/router": "^0.15.0",
|
||||||
"@thisbeyond/solid-dnd": "^0.7.5",
|
"@thisbeyond/solid-dnd": "^0.7.5",
|
||||||
"@types/three": "^0.183.1",
|
|
||||||
"aedes": "^1.1.1",
|
"aedes": "^1.1.1",
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
|
|
@ -50,14 +51,12 @@
|
||||||
"mqtt": "^5.15.1",
|
"mqtt": "^5.15.1",
|
||||||
"solid-element": "^1.9.1",
|
"solid-element": "^1.9.1",
|
||||||
"solid-js": "^1.9.3",
|
"solid-js": "^1.9.3",
|
||||||
"three": "^0.183.2",
|
"uuid": "^14.0.1",
|
||||||
"three-3mf-exporter": "^45.1.0",
|
|
||||||
"ws": "^8.21.0",
|
"ws": "^8.21.0",
|
||||||
"yarn-spinner-loader": "^0.1.0",
|
"yarn-spinner-loader": "^0.1.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@image-tracer-ts/core": "^1.0.2",
|
|
||||||
"@rsbuild/core": "^1.1.8",
|
"@rsbuild/core": "^1.1.8",
|
||||||
"@rsbuild/plugin-babel": "^1.1.0",
|
"@rsbuild/plugin-babel": "^1.1.0",
|
||||||
"@rsbuild/plugin-solid": "^1.1.0",
|
"@rsbuild/plugin-solid": "^1.1.0",
|
||||||
|
|
@ -67,13 +66,16 @@
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/node": "^22.19.13",
|
"@types/node": "^22.19.13",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"ts-jest": "^29.4.6",
|
"ts-jest": "^29.4.6",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3",
|
||||||
|
"webpack-bundle-analyzer": "^5.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { defineConfig } from '@rsbuild/core';
|
import { defineConfig } from "@rsbuild/core";
|
||||||
import { pluginBabel } from '@rsbuild/plugin-babel';
|
import { pluginBabel } from "@rsbuild/plugin-babel";
|
||||||
import { pluginSolid } from '@rsbuild/plugin-solid';
|
import { pluginSolid } from "@rsbuild/plugin-solid";
|
||||||
import tailwindcss from '@tailwindcss/postcss';
|
import tailwindcss from "@tailwindcss/postcss";
|
||||||
|
import { BundleAnalyzerPlugin } from "webpack-bundle-analyzer";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|
@ -16,32 +17,36 @@ export default defineConfig({
|
||||||
plugins: [tailwindcss()],
|
plugins: [tailwindcss()],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rspack: {
|
rspack: {
|
||||||
module: {
|
module: {
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
test: /\.md|\.yarn|\.csv$/,
|
test: /\.md|\.yarn|\.csv$/,
|
||||||
exclude: /\.schema\.csv$/,
|
exclude: /\.schema\.csv$/,
|
||||||
type: 'asset/source'
|
type: "asset/source",
|
||||||
},
|
},
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
}
|
plugins: [
|
||||||
|
process.env.ANALYZE ? new BundleAnalyzerPlugin() : undefined,
|
||||||
|
].filter(Boolean),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
html: {
|
html: {
|
||||||
template: './src/index.html',
|
template: "./src/index.html",
|
||||||
},
|
},
|
||||||
source: {
|
source: {
|
||||||
entry: {
|
entry: {
|
||||||
index: './src/main.tsx',
|
index: "./src/main.tsx",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
distPath: {
|
distPath: {
|
||||||
root: 'dist/web',
|
root: "dist/web",
|
||||||
},
|
},
|
||||||
copy: process.env.NODE_ENV === 'development' ? [
|
copy:
|
||||||
{ from: './content', to: 'content' }
|
process.env.NODE_ENV === "development"
|
||||||
] : [],
|
? [{ from: "./content", to: "content" }]
|
||||||
|
: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
116
src/App.tsx
116
src/App.tsx
|
|
@ -1,4 +1,11 @@
|
||||||
import { Component, createEffect, createMemo, createSignal } from "solid-js";
|
import {
|
||||||
|
Component,
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
createEffect,
|
||||||
|
createMemo,
|
||||||
|
createSignal,
|
||||||
|
} from "solid-js";
|
||||||
import { useLocation } from "@solidjs/router";
|
import { useLocation } from "@solidjs/router";
|
||||||
import { useJournalStream } from "./components/stores/journalStream";
|
import { useJournalStream } from "./components/stores/journalStream";
|
||||||
|
|
||||||
|
|
@ -15,6 +22,23 @@ import {
|
||||||
import { generateToc, type FileNode, type TocNode } from "./data-loader";
|
import { generateToc, type FileNode, type TocNode } from "./data-loader";
|
||||||
import { JournalPanel } from "./components/journal";
|
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 App: Component = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const stream = useJournalStream();
|
const stream = useJournalStream();
|
||||||
|
|
@ -30,6 +54,8 @@ const App: Component = () => {
|
||||||
>({});
|
>({});
|
||||||
const [tocKey, setTocKey] = createSignal(0);
|
const [tocKey, setTocKey] = createSignal(0);
|
||||||
|
|
||||||
|
let mainRef!: HTMLElement;
|
||||||
|
|
||||||
const loadToc = async () => {
|
const loadToc = async () => {
|
||||||
const toc = await generateToc();
|
const toc = await generateToc();
|
||||||
setFileTree(toc.fileTree);
|
setFileTree(toc.fileTree);
|
||||||
|
|
@ -58,23 +84,10 @@ const App: Component = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="min-h-screen bg-gray-50">
|
<div class="h-screen flex flex-col overflow-hidden bg-gray-50">
|
||||||
{/* 桌面端固定侧边栏 */}
|
{/* Header */}
|
||||||
<DesktopSidebar
|
<header class="shrink-0 h-16 bg-white shadow z-30">
|
||||||
fileTree={fileTree()}
|
<div class="h-full max-w-4xl mx-auto px-4 flex items-center gap-4">
|
||||||
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
|
<button
|
||||||
onClick={() => setIsSidebarOpen(true)}
|
onClick={() => setIsSidebarOpen(true)}
|
||||||
|
|
@ -83,7 +96,7 @@ const App: Component = () => {
|
||||||
>
|
>
|
||||||
☰
|
☰
|
||||||
</button>
|
</button>
|
||||||
<h1 class="text-2xl font-bold text-gray-900">TTRPG Tools</h1>
|
<h1 class="text-2xl font-bold text-gray-900">Tabletop Tools</h1>
|
||||||
<div class="flex-1" />
|
<div class="flex-1" />
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsJournalOpen((v) => !v)}
|
onClick={() => setIsJournalOpen((v) => !v)}
|
||||||
|
|
@ -115,30 +128,61 @@ const App: Component = () => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{/* fill the rest of the space */}
|
|
||||||
<div
|
{/* 移动端抽屉式侧边栏 (overlay) */}
|
||||||
class="fixed top-16 left-0 right-0 bottom-0 overflow-auto transition-all duration-300"
|
<MobileSidebar
|
||||||
classList={{ "md:pr-[420px]": isJournalOpen() }}
|
isOpen={isSidebarOpen()}
|
||||||
>
|
onClose={() => setIsSidebarOpen(false)}
|
||||||
<main class="max-w-4xl mx-auto px-4 py-8 pt-4 md:ml-64 2xl:ml-auto flex justify-center items-center">
|
fileTree={fileTree()}
|
||||||
<Article
|
pathHeadings={pathHeadings()}
|
||||||
class="prose text-black prose-sm max-w-full flex-1"
|
onDataSourceOpen={() => setIsDataSourceOpen(true)}
|
||||||
src={currentPath()}
|
/>
|
||||||
>
|
|
||||||
<RevealManager />
|
{/* Body: sidebar + main + journal */}
|
||||||
</Article>
|
<div class="flex flex-1 min-h-0">
|
||||||
</main>
|
{/* 桌面端固定侧边栏 (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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DocDialog isOpen={isDocOpen()} onClose={() => setIsDocOpen(false)} />
|
<DocDialog isOpen={isDocOpen()} onClose={() => setIsDocOpen(false)} />
|
||||||
<DataSourceDialog
|
<DataSourceDialog
|
||||||
isOpen={isDataSourceOpen()}
|
isOpen={isDataSourceOpen()}
|
||||||
onClose={() => setIsDataSourceOpen(false)}
|
onClose={() => setIsDataSourceOpen(false)}
|
||||||
onSourceChanged={handleSourceChanged}
|
onSourceChanged={handleSourceChanged}
|
||||||
/>
|
/>
|
||||||
<JournalPanel
|
|
||||||
open={isJournalOpen()}
|
|
||||||
onClose={() => setIsJournalOpen(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
scanCompletions,
|
scanCompletions,
|
||||||
type CompletionsPayload,
|
type CompletionsPayload,
|
||||||
} from "../completions/index.js";
|
} from "../completions/index.js";
|
||||||
|
import { extractInlineBlocks } from "../inline-blocks.js";
|
||||||
|
|
||||||
interface ContentIndex {
|
interface ContentIndex {
|
||||||
[path: string]: string;
|
[path: string]: string;
|
||||||
|
|
@ -105,7 +106,16 @@ export function scanDirectory(dir: string): ContentIndex {
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(fullPath, "utf-8");
|
const content = readFileSync(fullPath, "utf-8");
|
||||||
index[normalizedRelPath] = content;
|
if (entry.endsWith(".md")) {
|
||||||
|
const stripped = extractInlineBlocks(
|
||||||
|
content,
|
||||||
|
normalizedRelPath,
|
||||||
|
index,
|
||||||
|
);
|
||||||
|
index[normalizedRelPath] = stripped;
|
||||||
|
} else {
|
||||||
|
index[normalizedRelPath] = content;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`读取文件失败:${fullPath}`, e);
|
console.error(`读取文件失败:${fullPath}`, e);
|
||||||
}
|
}
|
||||||
|
|
@ -295,9 +305,18 @@ export function createContentServer(
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(path, "utf-8");
|
const content = readFileSync(path, "utf-8");
|
||||||
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
||||||
contentIndex[relPath] = content;
|
if (relPath.endsWith(".md")) {
|
||||||
|
const stripped = extractInlineBlocks(
|
||||||
|
content,
|
||||||
|
relPath,
|
||||||
|
contentIndex,
|
||||||
|
);
|
||||||
|
contentIndex[relPath] = stripped;
|
||||||
|
recomputeCompletions();
|
||||||
|
} else {
|
||||||
|
contentIndex[relPath] = content;
|
||||||
|
}
|
||||||
console.log(`[新增] ${relPath}`);
|
console.log(`[新增] ${relPath}`);
|
||||||
if (relPath.endsWith(".md")) recomputeCompletions();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`读取新增文件失败:${path}`, e);
|
console.error(`读取新增文件失败:${path}`, e);
|
||||||
}
|
}
|
||||||
|
|
@ -312,9 +331,18 @@ export function createContentServer(
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(path, "utf-8");
|
const content = readFileSync(path, "utf-8");
|
||||||
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
||||||
contentIndex[relPath] = content;
|
if (relPath.endsWith(".md")) {
|
||||||
|
const stripped = extractInlineBlocks(
|
||||||
|
content,
|
||||||
|
relPath,
|
||||||
|
contentIndex,
|
||||||
|
);
|
||||||
|
contentIndex[relPath] = stripped;
|
||||||
|
recomputeCompletions();
|
||||||
|
} else {
|
||||||
|
contentIndex[relPath] = content;
|
||||||
|
}
|
||||||
console.log(`[更新] ${relPath}`);
|
console.log(`[更新] ${relPath}`);
|
||||||
if (relPath.endsWith(".md")) recomputeCompletions();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`读取更新文件失败:${path}`, e);
|
console.error(`读取更新文件失败:${path}`, e);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -150,6 +150,20 @@ 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] persistence listener active");
|
||||||
console.log("[journal] broker available at ws://<host>:<port>");
|
console.log("[journal] broker available at ws://<host>:<port>");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,20 +7,17 @@ import {
|
||||||
onCleanup,
|
onCleanup,
|
||||||
Show,
|
Show,
|
||||||
createResource,
|
createResource,
|
||||||
createMemo,
|
|
||||||
createSignal,
|
createSignal,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { parseMarkdown } from "../markdown";
|
import { parseMarkdown } from "../markdown";
|
||||||
import { extractSection } from "../data-loader";
|
import { extractSection } from "../data-loader";
|
||||||
import mermaid from "mermaid";
|
import mermaid from "mermaid";
|
||||||
import { getIndexedData } from "../data-loader/file-index";
|
import { getIndexedData } from "../data-loader/file-index";
|
||||||
import { resolvePath } from "./utils/path";
|
|
||||||
import { useNavigateWithParams } from "./useNavigateWithParams";
|
import { useNavigateWithParams } from "./useNavigateWithParams";
|
||||||
|
|
||||||
export interface ArticleProps {
|
export interface ArticleProps {
|
||||||
src: string;
|
src: string;
|
||||||
section?: string; // 指定要显示的标题(不含 #)
|
section?: string; // 指定要显示的标题(不含 #)
|
||||||
iconPath?: string; // 图标路径前缀,默认为 "./assets",空字符串表示禁用
|
|
||||||
onLoaded?: () => void;
|
onLoaded?: () => void;
|
||||||
onError?: (error: Error) => void;
|
onError?: (error: Error) => void;
|
||||||
class?: string; // 额外的 class 用于样式控制
|
class?: string; // 额外的 class 用于样式控制
|
||||||
|
|
@ -83,12 +80,6 @@ export const Article: Component<ArticleProps & ParentProps> = (props) => {
|
||||||
);
|
);
|
||||||
const [contentDom, setContentDom] = createSignal<HTMLDivElement>();
|
const [contentDom, setContentDom] = createSignal<HTMLDivElement>();
|
||||||
|
|
||||||
// 解析 iconPath,默认为 "./assets",空字符串表示禁用
|
|
||||||
const iconPrefix = createMemo(() => {
|
|
||||||
if (props.iconPath === "") return undefined; // 空字符串禁用图标前缀
|
|
||||||
return resolvePath(props.src, props.iconPath ?? "./assets");
|
|
||||||
});
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const data = content();
|
const data = content();
|
||||||
if (data) {
|
if (data) {
|
||||||
|
|
@ -144,7 +135,7 @@ export const Article: Component<ArticleProps & ParentProps> = (props) => {
|
||||||
<div
|
<div
|
||||||
class="relative"
|
class="relative"
|
||||||
ref={setContentDom}
|
ref={setContentDom}
|
||||||
innerHTML={parseMarkdown(content()!, iconPrefix())}
|
innerHTML={parseMarkdown(content()!, props.src)}
|
||||||
/>
|
/>
|
||||||
{props.children}
|
{props.children}
|
||||||
</ArticleDomCtx.Provider>
|
</ArticleDomCtx.Provider>
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ const DocDialog: Component<DocDialogProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<Show when={props.isOpen}>
|
<Show when={props.isOpen}>
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
|
class="fixed inset-0 bg-black/50 z-60 flex items-center justify-center p-4"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (e.target === e.currentTarget) props.onClose();
|
if (e.target === e.currentTarget) props.onClose();
|
||||||
}}
|
}}
|
||||||
|
|
@ -79,7 +79,7 @@ const DocDialog: Component<DocDialogProps> = (props) => {
|
||||||
<span class="text-gray-400 text-sm">▾</span>
|
<span class="text-gray-400 text-sm">▾</span>
|
||||||
</button>
|
</button>
|
||||||
<Show when={dropdownOpen()}>
|
<Show when={dropdownOpen()}>
|
||||||
<div class="absolute top-full left-0 mt-1 w-52 bg-white border border-gray-200 rounded shadow-lg z-50 py-1">
|
<div class="absolute top-full left-0 mt-1 w-52 bg-white border border-gray-200 rounded shadow-lg z-60 py-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => switchDocSet("directives")}
|
onClick={() => switchDocSet("directives")}
|
||||||
class={`w-full text-left px-3 py-2 text-sm hover:bg-gray-100 flex items-center gap-2 ${
|
class={`w-full text-left px-3 py-2 text-sm hover:bg-gray-100 flex items-center gap-2 ${
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Component, createMemo, createSignal, Show } from "solid-js";
|
import { Component, createMemo, createSignal, Show } from "solid-js";
|
||||||
import { type FileNode, type TocNode } from "../data-loader";
|
import { type FileNode, type TocNode } from "../data-loader";
|
||||||
import { useNavigateWithParams } from "./useNavigateWithParams";
|
import { useNavigateWithParams } from "./useNavigateWithParams";
|
||||||
|
import { useScrollContainer } from "../App";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查当前文件路径是否在文件夹内
|
* 检查当前文件路径是否在文件夹内
|
||||||
|
|
@ -98,6 +99,8 @@ export const HeadingNode: Component<{
|
||||||
setIsExpanded(!isExpanded());
|
setIsExpanded(!isExpanded());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const scrollContainer = useScrollContainer();
|
||||||
|
|
||||||
const handleClick = (e: MouseEvent) => {
|
const handleClick = (e: MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
navigate(href);
|
navigate(href);
|
||||||
|
|
@ -105,10 +108,18 @@ export const HeadingNode: Component<{
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const element = document.getElementById(anchor);
|
const element = document.getElementById(anchor);
|
||||||
if (element) {
|
if (element) {
|
||||||
|
const scroller = scrollContainer();
|
||||||
const navBarHeight = 80;
|
const navBarHeight = 80;
|
||||||
const elementPosition = element.getBoundingClientRect().top;
|
const elementPosition = element.getBoundingClientRect().top;
|
||||||
const offsetPosition = window.scrollY + elementPosition - navBarHeight;
|
const containerTop = scroller?.getBoundingClientRect().top ?? 0;
|
||||||
window.scrollTo({ top: offsetPosition, behavior: "instant" });
|
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" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
import { Portal } from "solid-js/web";
|
import { Portal } from "solid-js/web";
|
||||||
import { useLocation } from "@solidjs/router";
|
import { useLocation } from "@solidjs/router";
|
||||||
import { useArticleDom } from "./Article";
|
import { useArticleDom } from "./Article";
|
||||||
|
import { useScrollContainer } from "../App";
|
||||||
import { journalStreamState } from "./stores/journalStream";
|
import { journalStreamState } from "./stores/journalStream";
|
||||||
import { useJournalCompletions } from "./journal/completions";
|
import { useJournalCompletions } from "./journal/completions";
|
||||||
import {
|
import {
|
||||||
|
|
@ -75,6 +76,7 @@ function hide() {
|
||||||
|
|
||||||
export const RevealManager: Component = () => {
|
export const RevealManager: Component = () => {
|
||||||
const contentDom = useArticleDom();
|
const contentDom = useArticleDom();
|
||||||
|
const scrollContainer = useScrollContainer();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const comp = useJournalCompletions();
|
const comp = useJournalCompletions();
|
||||||
|
|
||||||
|
|
@ -179,8 +181,16 @@ export const RevealManager: Component = () => {
|
||||||
<button
|
<button
|
||||||
class={`fixed z-50 inline-flex items-center justify-center w-5 h-5 rounded cursor-pointer ${btn().color}`}
|
class={`fixed z-50 inline-flex items-center justify-center w-5 h-5 rounded cursor-pointer ${btn().color}`}
|
||||||
style={{
|
style={{
|
||||||
top: `${btn().rect.top + window.scrollY - 6}px`,
|
top: `${
|
||||||
left: `${btn().rect.left + window.scrollX - 20}px`,
|
btn().rect.top +
|
||||||
|
(scrollContainer()?.scrollTop ?? window.scrollY) -
|
||||||
|
6
|
||||||
|
}px`,
|
||||||
|
left: `${
|
||||||
|
btn().rect.left +
|
||||||
|
(scrollContainer()?.scrollLeft ?? window.scrollX) -
|
||||||
|
20
|
||||||
|
}px`,
|
||||||
}}
|
}}
|
||||||
title={btn().title}
|
title={btn().title}
|
||||||
innerHTML={btn().svg}
|
innerHTML={btn().svg}
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ export const MobileSidebar: Component<SidebarProps> = (props) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 桌面端固定侧边栏
|
* 桌面端侧边栏 — flex child, no fixed positioning
|
||||||
*/
|
*/
|
||||||
export const DesktopSidebar: Component<{
|
export const DesktopSidebar: Component<{
|
||||||
fileTree?: FileNode[];
|
fileTree?: FileNode[];
|
||||||
|
|
@ -182,7 +182,7 @@ export const DesktopSidebar: Component<{
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside class="hidden md:block fixed top-0 left-0 h-full w-64 bg-white shadow-lg z-30 overflow-hidden pt-16">
|
<aside class="hidden md:flex flex-col w-64 shrink-0 bg-white shadow-lg overflow-hidden">
|
||||||
<SidebarContent
|
<SidebarContent
|
||||||
fileTree={fileTree()}
|
fileTree={fileTree()}
|
||||||
pathHeadings={pathHeadings()}
|
pathHeadings={pathHeadings()}
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,7 @@ import mdBorderRaw from "../doc-entries/md-border.md";
|
||||||
import mdEmbedRaw from "../doc-entries/md-embed.md";
|
import mdEmbedRaw from "../doc-entries/md-embed.md";
|
||||||
import mdDeckRaw from "../doc-entries/md-deck.md";
|
import mdDeckRaw from "../doc-entries/md-deck.md";
|
||||||
import mdYarnRaw from "../doc-entries/md-yarn-spinner.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";
|
import mdCommanderRaw from "../doc-entries/md-commander.md";
|
||||||
|
|
||||||
const rawDocuments: string[] = [
|
const rawDocuments: string[] = [
|
||||||
|
|
@ -71,8 +70,7 @@ const rawDocuments: string[] = [
|
||||||
mdEmbedRaw,
|
mdEmbedRaw,
|
||||||
mdDeckRaw,
|
mdDeckRaw,
|
||||||
mdYarnRaw,
|
mdYarnRaw,
|
||||||
mdTokenRaw,
|
|
||||||
mdTokenViewerRaw,
|
|
||||||
mdCommanderRaw,
|
mdCommanderRaw,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,6 @@ import "./md-embed";
|
||||||
import "./md-deck";
|
import "./md-deck";
|
||||||
import "./md-commander/index";
|
import "./md-commander/index";
|
||||||
import "./md-yarn-spinner";
|
import "./md-yarn-spinner";
|
||||||
import "./md-token";
|
|
||||||
import "./md-token-viewer";
|
|
||||||
|
|
||||||
// 导出组件
|
// 导出组件
|
||||||
export { Article } from "./Article";
|
export { Article } from "./Article";
|
||||||
|
|
@ -33,7 +31,6 @@ export type { FontProps } from "./md-font";
|
||||||
export type { BorderProps } from "./md-border";
|
export type { BorderProps } from "./md-border";
|
||||||
export type { EmbedProps } from "./md-embed";
|
export type { EmbedProps } from "./md-embed";
|
||||||
export type { YarnSpinnerProps } from "./md-yarn-spinner";
|
export type { YarnSpinnerProps } from "./md-yarn-spinner";
|
||||||
export type { TokenProps } from "./md-token";
|
|
||||||
|
|
||||||
// 导出 md-commander 相关
|
// 导出 md-commander 相关
|
||||||
export type {
|
export type {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
/**
|
||||||
|
* 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 };
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,24 +1,20 @@
|
||||||
/**
|
/**
|
||||||
* JournalPanel — right panel container for the journal stream
|
* JournalPanel — right panel container for the journal stream
|
||||||
*
|
*
|
||||||
* Fixed overlay. Shows stream when connected, connect dialog when not.
|
* On mobile: fixed overlay with backdrop.
|
||||||
|
* On desktop: positioned by the parent flex container (width transition).
|
||||||
* Auto-connects to ws://{current host}:{current port} — no URL input needed.
|
* Auto-connects to ws://{current host}:{current port} — no URL input needed.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, Show, createSignal, onMount, For } from "solid-js";
|
import { Component, Show, createSignal, For } from "solid-js";
|
||||||
import {
|
import { useJournalStream } from "../stores/journalStream";
|
||||||
connectStream,
|
|
||||||
hydrateFromServer,
|
|
||||||
useJournalStream,
|
|
||||||
setMyName,
|
|
||||||
setMyRole,
|
|
||||||
setSessionId,
|
|
||||||
sessions,
|
|
||||||
createSession,
|
|
||||||
disconnectStream,
|
|
||||||
} from "../stores/journalStream";
|
|
||||||
import { StreamView } from "./StreamView";
|
import { StreamView } from "./StreamView";
|
||||||
import { JournalInput } from "./JournalInput";
|
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 {
|
export interface JournalPanelProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -28,6 +24,7 @@ export interface JournalPanelProps {
|
||||||
export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
||||||
const stream = useJournalStream();
|
const stream = useJournalStream();
|
||||||
const [showInvite, setShowInvite] = createSignal(false);
|
const [showInvite, setShowInvite] = createSignal(false);
|
||||||
|
const [showCreateSession, setShowCreateSession] = createSignal(false);
|
||||||
|
|
||||||
// Player list (exclude gm and observer)
|
// Player list (exclude gm and observer)
|
||||||
const playerEntries = () =>
|
const playerEntries = () =>
|
||||||
|
|
@ -37,430 +34,73 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Backdrop — only on mobile, starts below header */}
|
{/* Mobile backdrop — overlay that appears below the header */}
|
||||||
<div
|
<div
|
||||||
class="fixed top-16 inset-x-0 bottom-0 bg-black/30 z-40 md:hidden"
|
class="fixed top-16 inset-x-0 bottom-0 bg-black/30 z-40 md:hidden"
|
||||||
classList={{ hidden: !props.open }}
|
classList={{ hidden: !props.open }}
|
||||||
onClick={props.onClose}
|
onClick={props.onClose}
|
||||||
/>
|
/>
|
||||||
{/* Panel — always mounted for exit animation, visibility toggled */}
|
{/* Panel — on mobile: fixed overlay; on desktop: fills the parent wrapper */}
|
||||||
<aside
|
<aside
|
||||||
class={`fixed top-16 right-0 bottom-0 z-50 bg-white border-l border-gray-200
|
class={`fixed top-16 right-0 bottom-0 z-50 bg-white
|
||||||
flex flex-col w-full max-w-md md:w-[420px] shadow-lg
|
flex flex-col w-full h-full max-w-md shadow-lg
|
||||||
transition-transform duration-300 ease-in-out ${
|
transition-transform duration-300 ease-in-out
|
||||||
props.open ? "translate-x-0" : "translate-x-full"
|
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"}`}
|
||||||
aria-hidden={!props.open}
|
inert={!props.open}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
<JournalContext.Provider value={{ onClose: props.onClose }}>
|
||||||
<JournalHeader onClose={props.onClose} />
|
{/* Header */}
|
||||||
|
<JournalHeader
|
||||||
|
onClose={props.onClose}
|
||||||
|
onCreateSession={() => setShowCreateSession(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Player list tabs */}
|
{/* Player list tabs */}
|
||||||
<Show when={stream.connected}>
|
<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">
|
<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()}>
|
<For each={playerEntries()}>
|
||||||
{([name]) => (
|
{([name]) => (
|
||||||
<span class="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full whitespace-nowrap">
|
<span class="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full whitespace-nowrap">
|
||||||
{name}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowInvite(true)}
|
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"
|
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="邀请玩家"
|
title="邀请玩家"
|
||||||
>
|
>
|
||||||
+ 邀请
|
+ 邀请
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show
|
|
||||||
when={stream.connected}
|
|
||||||
fallback={
|
|
||||||
<div class="flex-1 flex items-center justify-center p-4 overflow-y-auto">
|
|
||||||
<ConnectDialog />
|
|
||||||
</div>
|
</div>
|
||||||
}
|
</Show>
|
||||||
>
|
|
||||||
<div class="flex-1 min-h-0">
|
|
||||||
<StreamView />
|
|
||||||
</div>
|
|
||||||
<JournalInput />
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Invite modal */}
|
<Show
|
||||||
<Show when={showInvite()}>
|
when={stream.connected}
|
||||||
<InviteDialog onClose={() => setShowInvite(false)} />
|
fallback={
|
||||||
</Show>
|
<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>
|
||||||
</aside>
|
</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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -37,6 +37,11 @@ export type {
|
||||||
// UI components
|
// UI components
|
||||||
export { JournalPanel } from "./JournalPanel";
|
export { JournalPanel } from "./JournalPanel";
|
||||||
export type { JournalPanelProps } 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 { ConnectionBar } from "./ConnectionBar";
|
||||||
export { StreamView } from "./StreamView";
|
export { StreamView } from "./StreamView";
|
||||||
export { StreamMessageCard } from "./StreamMessage";
|
export { StreamMessageCard } from "./StreamMessage";
|
||||||
|
|
@ -44,3 +49,5 @@ export { ComposePanel } from "./ComposePanel";
|
||||||
export { JournalInput } from "./JournalInput";
|
export { JournalInput } from "./JournalInput";
|
||||||
export { DynamicForm } from "./DynamicForm";
|
export { DynamicForm } from "./DynamicForm";
|
||||||
export { useJournalCompletions, invalidateCompletions } from "./completions";
|
export { useJournalCompletions, invalidateCompletions } from "./completions";
|
||||||
|
export { JournalContext, useJournalContext } from "./JournalContext";
|
||||||
|
export type { JournalContextValue } from "./JournalContext";
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { z } from "zod";
|
||||||
import { registerMessageType } from "../registry";
|
import { registerMessageType } from "../registry";
|
||||||
import { journalSetState } from "../../stores/journalStream";
|
import { journalSetState } from "../../stores/journalStream";
|
||||||
import { produce } from "solid-js/store";
|
import { produce } from "solid-js/store";
|
||||||
|
import { useJournalContext } from "../JournalContext";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
path: z.string().min(1),
|
path: z.string().min(1),
|
||||||
|
|
@ -44,6 +45,7 @@ function slugToTitle(slug: string): string {
|
||||||
|
|
||||||
const RevealLink: Component<LinkPayload> = (p) => {
|
const RevealLink: Component<LinkPayload> = (p) => {
|
||||||
const navigate = useNavigateWithParams();
|
const navigate = useNavigateWithParams();
|
||||||
|
const ctx = useJournalContext();
|
||||||
|
|
||||||
const target = () => {
|
const target = () => {
|
||||||
let t = encodePath(p.path);
|
let t = encodePath(p.path);
|
||||||
|
|
@ -59,6 +61,10 @@ const RevealLink: Component<LinkPayload> = (p) => {
|
||||||
const handleClick = (e: MouseEvent) => {
|
const handleClick = (e: MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
navigate(target());
|
navigate(target());
|
||||||
|
// On mobile, close the journal panel to reveal the article
|
||||||
|
if (window.innerWidth < 768) {
|
||||||
|
ctx?.onClose();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { getLayerStyle } from "./hooks/dimensions";
|
||||||
import type { CardData, CardSide, LayerConfig } from "./types";
|
import type { CardData, CardSide, LayerConfig } from "./types";
|
||||||
import { DeckStore } from "./hooks/deckStore";
|
import { DeckStore } from "./hooks/deckStore";
|
||||||
import { processVariables } from "../utils/csv-loader";
|
import { processVariables } from "../utils/csv-loader";
|
||||||
import { resolvePath } from "../utils/path";
|
|
||||||
import type { LayerInteractionHandlers } from "./hooks/useLayerInteraction";
|
import type { LayerInteractionHandlers } from "./hooks/useLayerInteraction";
|
||||||
|
|
||||||
export interface CardLayerProps {
|
export interface CardLayerProps {
|
||||||
|
|
@ -26,13 +25,9 @@ export function CardLayer(props: CardLayerProps) {
|
||||||
const draggingState = () => props.store.state.draggingState;
|
const draggingState = () => props.store.state.draggingState;
|
||||||
|
|
||||||
function renderLayerContent(content: string) {
|
function renderLayerContent(content: string) {
|
||||||
const iconPath = resolvePath(
|
|
||||||
props.store.state.cards.sourcePath,
|
|
||||||
props.cardData.iconPath ?? "./assets",
|
|
||||||
);
|
|
||||||
return parseMarkdown(
|
return parseMarkdown(
|
||||||
processVariables(content, props.cardData, props.store.state.cards),
|
processVariables(content, props.cardData, props.store.state.cards),
|
||||||
iconPath,
|
props.store.state.cards.sourcePath,
|
||||||
) as string;
|
) as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { customElement, noShadowDOM } from "solid-element";
|
import { customElement, noShadowDOM } from "solid-element";
|
||||||
import { Show, onCleanup } from "solid-js";
|
import { Show, onCleanup } from "solid-js";
|
||||||
import { resolvePath } from "../utils/path";
|
import { resolvePath } from "../utils/path";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { createDeckStore } from "./hooks/deckStore";
|
import { createDeckStore } from "./hooks/deckStore";
|
||||||
import { registerDeck, unregisterDeck } from "./hooks/deck-registry";
|
import { registerDeck, unregisterDeck } from "./hooks/deck-registry";
|
||||||
import type { CardShape } from "./types";
|
import type { CardShape } from "./types";
|
||||||
|
|
@ -66,7 +67,7 @@ customElement<DeckProps>(
|
||||||
const store = createDeckStore(resolvedSrc);
|
const store = createDeckStore(resolvedSrc);
|
||||||
|
|
||||||
// 生成唯一 ID 并注册到全局注册表
|
// 生成唯一 ID 并注册到全局注册表
|
||||||
const deckId = `deck-${crypto.randomUUID()}`;
|
const deckId = `deck-${uuidv4()}`;
|
||||||
registerDeck(deckId, store, resolvedSrc, csvPath);
|
registerDeck(deckId, store, resolvedSrc, csvPath);
|
||||||
|
|
||||||
// 解析 size 属性(支持旧格式 "54x86" 和新格式)
|
// 解析 size 属性(支持旧格式 "54x86" 和新格式)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { customElement, noShadowDOM } from "solid-element";
|
import { customElement, noShadowDOM } from "solid-element";
|
||||||
import { createResource, Show, createMemo, createEffect } from "solid-js";
|
import { createResource, Show, createEffect } from "solid-js";
|
||||||
import { getIndexedData } from "../data-loader/file-index";
|
import { getIndexedData } from "../data-loader/file-index";
|
||||||
import { extractSection } from "../data-loader";
|
import { extractSection } from "../data-loader";
|
||||||
import { parseMarkdown } from "../markdown";
|
import { parseMarkdown } from "../markdown";
|
||||||
|
|
@ -27,9 +27,6 @@ customElement("md-embed", { headingBase: 0 }, (props, { element }) => {
|
||||||
const articlePath = articleEl?.getAttribute("data-src") || "";
|
const articlePath = articleEl?.getAttribute("data-src") || "";
|
||||||
const resolvedPath = resolvePath(articlePath, filePath);
|
const resolvedPath = resolvePath(articlePath, filePath);
|
||||||
|
|
||||||
// 计算嵌入文件的 iconPrefix,用 createMemo 包装避免每个 embed 都重新计算
|
|
||||||
const iconPrefix = createMemo(() => resolvePath(resolvedPath, "./assets"));
|
|
||||||
|
|
||||||
const [content] = createResource(
|
const [content] = createResource(
|
||||||
() => ({ path: resolvedPath, section }),
|
() => ({ path: resolvedPath, section }),
|
||||||
async ({ path, section }) => {
|
async ({ path, section }) => {
|
||||||
|
|
@ -58,7 +55,7 @@ customElement("md-embed", { headingBase: 0 }, (props, { element }) => {
|
||||||
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: markdown render */}
|
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: markdown render */}
|
||||||
<div
|
<div
|
||||||
class="prose"
|
class="prose"
|
||||||
innerHTML={parseMarkdown(content()!, iconPrefix())}
|
innerHTML={parseMarkdown(content()!, resolvedPath)}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,216 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,313 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -188,6 +188,43 @@ 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
|
// Rolling
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -207,17 +244,11 @@ export function rollSparkTable(meta: SparkTableMeta): SparkTableResult {
|
||||||
const roll = rollFormula(meta.notation);
|
const roll = rollFormula(meta.notation);
|
||||||
const rolledValue = roll.result.total;
|
const rolledValue = roll.result.total;
|
||||||
|
|
||||||
// Find the row matching the rolled value — compare as integers,
|
// Find the row matching the rolled value
|
||||||
// falling back to string comparison.
|
|
||||||
let value = `(no row for ${rolledValue})`;
|
let value = `(no row for ${rolledValue})`;
|
||||||
for (const row of meta.rows) {
|
for (const row of meta.rows) {
|
||||||
const diceCell = row[0] ?? "";
|
const diceCell = row[0] ?? "";
|
||||||
const cellNum = parseInt(diceCell.trim(), 10);
|
if (matchesCell(diceCell, rolledValue)) {
|
||||||
const match =
|
|
||||||
!isNaN(cellNum) && rolledValue === cellNum
|
|
||||||
? true
|
|
||||||
: String(rolledValue) === diceCell.trim();
|
|
||||||
if (match) {
|
|
||||||
value = row[colIdx + 1] ?? "";
|
value = row[colIdx + 1] ?? "";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* 文件索引管理器
|
* 文件索引管理器
|
||||||
* 支持多种文件索引加载方式:
|
* 支持多种文件索引加载方式:
|
||||||
* 1. CLI 环境:从 /__CONTENT_INDEX.json 加载
|
* 1. CLI 环境:从 /__CONTENT_INDEX.json 加载
|
||||||
* 2. Dev 环境:使用 webpackContext 仅加载 .md 文件
|
* 2. Dev 环境:使用 webpackContext 仅加载 .md 文件(仅在 dev 构建中生效)
|
||||||
* 3. 浏览器环境:用户选择本地文件夹,扫描文件索引
|
* 3. 浏览器环境:用户选择本地文件夹,扫描文件索引
|
||||||
* - 支持 IndexedDB 持久化目录句柄,刷新页面无需重新选择
|
* - 支持 IndexedDB 持久化目录句柄,刷新页面无需重新选择
|
||||||
*/
|
*/
|
||||||
|
|
@ -25,7 +25,7 @@ let activeDirHandle: FileSystemDirectoryHandle | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载文件索引(只加载一次)
|
* 加载文件索引(只加载一次)
|
||||||
* 尝试顺序:CLI JSON → webpack context → 已持久化的目录句柄
|
* 尝试顺序:CLI JSON → webpack context (dev only) → 已持久化的目录句柄
|
||||||
*/
|
*/
|
||||||
function ensureIndexLoaded(): Promise<void> {
|
function ensureIndexLoaded(): Promise<void> {
|
||||||
if (indexLoadPromise) return indexLoadPromise;
|
if (indexLoadPromise) return indexLoadPromise;
|
||||||
|
|
@ -44,25 +44,28 @@ function ensureIndexLoaded(): Promise<void> {
|
||||||
// CLI 索引不可用时尝试下一个策略
|
// CLI 索引不可用时尝试下一个策略
|
||||||
}
|
}
|
||||||
|
|
||||||
// 策略 2: Dev 环境 — webpackContext + raw loader
|
// 策略 2: Dev 环境 — webpackContext + raw loader(仅 dev 构建时生效)
|
||||||
try {
|
// 生产构建时 process.env.NODE_ENV 被替换为 'production',整个分支会被 tree-shake 掉
|
||||||
const context = import.meta.webpackContext("../../content", {
|
if (process.env.NODE_ENV === "development") {
|
||||||
recursive: true,
|
try {
|
||||||
regExp: /\.md|\.yarn|\.csv$/i,
|
const context = import.meta.webpackContext("../../content", {
|
||||||
});
|
recursive: true,
|
||||||
const keys = context.keys();
|
regExp: /\.md|\.yarn|\.csv$/i,
|
||||||
const index: FileIndex = {};
|
});
|
||||||
for (const key of keys) {
|
const keys = context.keys();
|
||||||
const module = context(key) as { default?: string } | string;
|
const index: FileIndex = {};
|
||||||
const content =
|
for (const key of keys) {
|
||||||
typeof module === "string" ? module : (module.default ?? "");
|
const module = context(key) as { default?: string } | string;
|
||||||
const normalizedPath = "/content" + key.slice(1);
|
const content =
|
||||||
index[normalizedPath] = content;
|
typeof module === "string" ? module : (module.default ?? "");
|
||||||
|
const normalizedPath = "/content" + key.slice(1);
|
||||||
|
index[normalizedPath] = content;
|
||||||
|
}
|
||||||
|
fileIndex = { ...fileIndex, ...index };
|
||||||
|
activeSource = "webpack";
|
||||||
|
} catch (e) {
|
||||||
|
// webpackContext 不可用时忽略
|
||||||
}
|
}
|
||||||
fileIndex = { ...fileIndex, ...index };
|
|
||||||
activeSource = "webpack";
|
|
||||||
} catch (e) {
|
|
||||||
// webpackContext 不可用时忽略
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 策略 3: 浏览器 — 尝试从 IndexedDB 恢复保存的目录句柄
|
// 策略 3: 浏览器 — 尝试从 IndexedDB 恢复保存的目录句柄
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
---
|
|
||||||
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 打印前的代币模型预览。
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
---
|
|
||||||
tag: md-token
|
|
||||||
icon: 🪙
|
|
||||||
title: 代币组件
|
|
||||||
description: 展示游戏代币或棋子的图片。
|
|
||||||
syntax: ':md-token[./token.png]'
|
|
||||||
props: []
|
|
||||||
---
|
|
||||||
|
|
||||||
**展示代币:**
|
|
||||||
:md-token[./goblin.png]
|
|
||||||
|
|
||||||
代币图片自动渲染为圆形,适合战棋或卡牌中的棋子展示。
|
|
||||||
|
|
||||||
## 使用场景
|
|
||||||
|
|
||||||
用于展示怪物代币、角色棋子、道具图标等。
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>TTRPG Tools</title>
|
<title>Tabletop Tools</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -6,14 +6,7 @@ import markedTable from "./table";
|
||||||
import { gfmHeadingId } from "marked-gfm-heading-id";
|
import { gfmHeadingId } from "marked-gfm-heading-id";
|
||||||
import markedColumns from "./columns";
|
import markedColumns from "./columns";
|
||||||
import markedCodeBlockYamlTag from "./code-block-yaml-tag";
|
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 来支持指令语法
|
// 使用 marked-directive 来支持指令语法
|
||||||
const marked = new Marked()
|
const marked = new Marked()
|
||||||
|
|
@ -33,73 +26,16 @@ const marked = new Marked()
|
||||||
marker: ":::::",
|
marker: ":::::",
|
||||||
level: "container",
|
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()],
|
extensions: [...markedColumns()],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export function parseMarkdown(content: string, iconPrefix?: string): string {
|
export function parseMarkdown(content: string, basePath?: string): string {
|
||||||
const restore = overrideIconPrefix(iconPrefix);
|
setIconBase(basePath);
|
||||||
try {
|
return marked.parse(content.trimStart()) as string;
|
||||||
return marked.parse(content.trimStart()) as string;
|
|
||||||
} finally {
|
|
||||||
restore();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { marked };
|
export { marked };
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue