Compare commits

...

17 Commits

Author SHA1 Message Date
hypercross ae3045f96b fix(cli): preserve inline blocks without file or as attributes 2026-07-08 16:49:01 +08:00
hypercross 6ab6b76978 feat: pass source path to markdown parser for icon resolution
Pass the file's base path through the markdown parsing pipeline to
allow the icon directive to resolve relative asset paths correctly
using an absolute fallback chain.
2026-07-08 16:38:19 +08:00
hypercross 2dba19e1f8 feat: implement icon directive with relative path fallback
Refactor icon rendering to use a custom `marked-directive` instead of
a global icon prefix. This allows icons to use a CSS-based fallback
chain (searching up to 10 directory levels for an `assets/` folder),
making icon paths more robust and removing the need to pass path
prefixes through the component tree.
2026-07-08 16:24:09 +08:00
hypercross 749b1a6a4b feat: support multiple attributes and `as` directive in inline blocks
Update the inline block parser to support arbitrary key-value pairs.
If an `as` attribute is provided, the block is replaced with a
directive (e.g., `:directive[path]{attrs}`) instead of being
stripped. If no `as` attribute is present, the block is removed.
Additionally, filenames are now automatically generated using a
content hash if the `file` attribute is missing.
2026-07-08 16:02:19 +08:00
hypercross 2a9eee6410 feat: extract and index inline file blocks in markdown 2026-07-08 15:46:19 +08:00
hypercross 9a5ee695c2 docs: clarify dev environment index loading behavior
Update file-index.ts documentation and implementation to specify
that webpackContext loading is restricted to development mode.
This ensures the code block is tree-shaken during production builds.
2026-07-08 15:19:50 +08:00
hypercross e0c3b8f4a0 feat: add bundle analysis support
Add `webpack-bundle-analyzer` and a new `analyze` script to allow
visualizing bundle composition via `npm run analyze`.
2026-07-08 15:16:12 +08:00
hypercross d95615f210 refactor: remove token and token-viewer components
Remove the `md-token` and `md-token-viewer` components along with their
associated 3D generation and image tracing utilities. Update
documentation and project metadata to reflect these changes and rename
the project title to "Tabletop Tools".
2026-07-08 15:09:22 +08:00
hypercross 5097aba842 refactor: implement scroll container context and flex layout
Switch from fixed positioning to a flexbox-based layout for the
main application structure. This introduces a `ScrollContainerCtx` to
provide access to the main scrollable element, allowing child
components like `FileTree` and `RevealManager` to perform accurate
scrolling and positioning relative to the container instead of the
window.

refactor: set JournalPanel height to full
2026-07-08 15:03:31 +08:00
hypercross b54f7ab1fa chore: add uuid dependency 2026-07-08 11:48:25 +08:00
hypercross c13f66153a feat: implement session creation flow
Refactor `CreateSessionDialog` to be controlled by its parent instead
of using an internal `open` prop. Lift the state up to `JournalPanel`
and pass the creation callback through `JournalHeader` to
`SessionDropdown`.
2026-07-08 00:39:29 +08:00
hypercross 376dde44b5 feat: add range support for spark table cell matching 2026-07-08 00:37:01 +08:00
hypercross 03671583cd feat: allow closing dialogs by clicking backdrop 2026-07-08 00:35:01 +08:00
hypercross 2c2b7b781d feat(journal): implement session management UI components
Add a suite of dialogs and header components to manage journal
sessions, including:

- ConnectDialog for joining sessions with name and role selection
- CreateSessionDialog for starting new sessions
- InviteDialog for generating and copying player invite links
- SessionDropdown for switching between sessions (GM only)
- JournalHeader for displaying connection status and user info
2026-07-08 00:31:35 +08:00
hypercross 3d957706e9 feat(journal): add JournalContext to allow closing panel on mobile 2026-07-08 00:11:16 +08:00
hypercross 57ffb35f2b feat: publish initial manifest on startup and improve UI focus
- Publish the existing session manifest via the broker on startup
- Automatically focus the new session input field when it is shown
- Prevent event propagation when clicking to create a new session
2026-07-08 00:08:12 +08:00
hypercross 3eba8b3279 fix(ui): increase z-index for DocDialog elements
Update z-index from 50 to 60 for the dialog overlay and dropdown
to ensure they appear above other UI components.
2026-07-07 23:59:57 +08:00
41 changed files with 1165 additions and 1815 deletions

View File

@ -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 |

View File

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

View File

@ -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 图表

376
package-lock.json generated
View File

@ -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",

View File

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

View File

@ -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" }]
: [],
}, },
}); });

View File

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

View File

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

105
src/cli/inline-blocks.ts Normal file
View File

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

View File

@ -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>");

View File

@ -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>

View File

@ -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 ${

View File

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

View File

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

View File

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

View File

@ -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,
]; ];

View File

@ -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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (

View File

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

View File

@ -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" 和新格式)

View File

@ -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>

View File

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

View File

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

View File

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

View File

@ -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,
};
}

View File

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

View File

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

View File

@ -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 恢复保存的目录句柄

View File

@ -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 打印前的代币模型预览。

View File

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

View File

@ -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>

78
src/markdown/icon.ts Normal file
View File

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

View File

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