Compare commits
149
Commits
d56e8ca23d
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b83123b6e | ||
|
|
b7a804f1cf | ||
|
|
256c685f6c | ||
|
|
5061c4d19d | ||
|
|
638e8f6526 | ||
|
|
80e2b5b209 | ||
|
|
5fc2bc5262 | ||
|
|
d75df5280f | ||
|
|
f1ddc8fb8e | ||
|
|
16dfc8a88c | ||
|
|
023d3a0cb9 | ||
|
|
7c4865ba8c | ||
|
|
49260ae4f6 | ||
|
|
750af4a50c | ||
|
|
c03528a293 | ||
|
|
0a68122efd | ||
|
|
e8aa7165cb | ||
|
|
9e081891df | ||
|
|
e3daac3080 | ||
|
|
a69a41875d | ||
|
|
6bcdca3a77 | ||
|
|
18cad20d2c | ||
|
|
8c9506c106 | ||
|
|
a934901cce | ||
|
|
d78d15d8ec | ||
|
|
f877a58a31 | ||
|
|
99dd49b077 | ||
|
|
7cb28e631e | ||
|
|
d74ba69fdf | ||
|
|
c4038a5213 | ||
|
|
ffa023b92a | ||
|
|
c106b6f79c | ||
|
|
2c762b0411 | ||
|
|
f172da378a | ||
|
|
3137970b97 | ||
|
|
6f01a29723 | ||
|
|
4c2eb65470 | ||
|
|
c524dd5867 | ||
|
|
411f4d79ff | ||
|
|
ae44191b28 | ||
|
|
a2d9605f4b | ||
|
|
c9c977bee1 | ||
|
|
960e310208 | ||
|
|
981c829d0f | ||
|
|
dbe2f9d9d8 | ||
|
|
894f1735e5 | ||
|
|
2983aa4440 | ||
|
|
86abf34c10 | ||
|
|
ffbfa65716 | ||
|
|
d690d5922e | ||
|
|
42e8971ff7 | ||
|
|
182d7ff28d | ||
|
|
736bcf4bb2 | ||
|
|
722a8110e6 | ||
|
|
40f2190307 | ||
|
|
241c8609f1 | ||
|
|
2187b7ed82 | ||
|
|
fc6e37a13d | ||
|
|
e46cc879ae | ||
|
|
2068ecad10 | ||
|
|
719bb6ad8a | ||
|
|
b8cfb05e53 | ||
|
|
841fbc7bae | ||
|
|
6d0bd75cb6 | ||
|
|
e437d20d73 | ||
|
|
f71af6a06d | ||
|
|
75e862c310 | ||
|
|
c661d2a1d2 | ||
|
|
52563fd3ef | ||
|
|
9790205468 | ||
|
|
d4ebca5fd0 | ||
|
|
89b32ef15e | ||
|
|
3a20e9558b | ||
|
|
c071cad50a | ||
|
|
a20063c624 | ||
|
|
06c3916a95 | ||
|
|
c38d9bdc4f | ||
|
|
fe87b1f80c | ||
|
|
30d1c5dc1b | ||
|
|
4f3b03d082 | ||
|
|
ef71a43f0a | ||
|
|
d83256e049 | ||
|
|
a4cfcde613 | ||
|
|
1c69bf394c | ||
|
|
19c66640b5 | ||
|
|
ef8b56e5b6 | ||
|
|
45cd9a01ac | ||
|
|
138c089514 | ||
|
|
44e4e15f3f | ||
|
|
9747409a1f | ||
|
|
4faff9a391 | ||
|
|
ae3045f96b | ||
|
|
6ab6b76978 | ||
|
|
2dba19e1f8 | ||
|
|
749b1a6a4b | ||
|
|
2a9eee6410 | ||
|
|
9a5ee695c2 | ||
|
|
e0c3b8f4a0 | ||
|
|
d95615f210 | ||
|
|
5097aba842 | ||
|
|
b54f7ab1fa | ||
|
|
c13f66153a | ||
|
|
376dde44b5 | ||
|
|
03671583cd | ||
|
|
2c2b7b781d | ||
|
|
3d957706e9 | ||
|
|
57ffb35f2b | ||
|
|
3eba8b3279 | ||
|
|
c10b088fb1 | ||
|
|
1ef41f04fc | ||
|
|
9a41f541e9 | ||
|
|
45c8722af8 | ||
|
|
e6d74cc318 | ||
|
|
779722fe85 | ||
|
|
48776424a7 | ||
|
|
4eaf6aab16 | ||
|
|
aa326f38a0 | ||
|
|
1ef2560faa | ||
|
|
6b3a915350 | ||
|
|
56af7dea42 | ||
|
|
e801ac9b5f | ||
|
|
9a312f76ad | ||
|
|
97148aa010 | ||
|
|
2f29f8774d | ||
|
|
3690d13407 | ||
|
|
ef7295ff7a | ||
|
|
574d17f201 | ||
|
|
ced9a4f8f2 | ||
|
|
adf28a7cf1 | ||
|
|
c5e1167beb | ||
|
|
7cfc0fd4f3 | ||
|
|
1d0f2f7c3e | ||
|
|
0fc61e2f94 | ||
|
|
59236f6dba | ||
|
|
e216b94e25 | ||
|
|
276241f3b0 | ||
|
|
e0d61cf91e | ||
|
|
4c5add2414 | ||
|
|
8284f5caee | ||
|
|
307a3c8320 | ||
|
|
13f454de9c | ||
|
|
6b5fcdc051 | ||
|
|
98c4a195e8 | ||
|
|
34d647d611 | ||
|
|
862ba9c01b | ||
|
|
53c33587fb | ||
|
|
8194438816 | ||
|
|
21f8fbcd0b | ||
|
|
08389601ac |
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 TTTK contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,18 +1,12 @@
|
|||||||
# TTRPG Tools
|
# TTTK — Tabletop Toolkit
|
||||||
|
|
||||||
一个基于 `solid.js` 和 `rsbuild` 的 TTRPG 工具箱,支持 Markdown 解析、自定义组件和 CLI 工具。
|
A TTRPG content preview and compilation tool based on `solid.js` and `rsbuild`. Write markdown, preview in browser.
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 安装依赖
|
# 无需安装,直接使用
|
||||||
npm install
|
npx tttk serve ./content
|
||||||
|
|
||||||
# 全局安装 CLI
|
|
||||||
npm link
|
|
||||||
|
|
||||||
# 预览内容
|
|
||||||
ttrpg serve ./content
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 文档导航
|
## 文档导航
|
||||||
@@ -20,6 +14,7 @@ ttrpg serve ./content
|
|||||||
| 文档 | 说明 |
|
| 文档 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| [📖 CLI 使用说明](./docs/cli.md) | CLI 安装、命令和用法 |
|
| [📖 CLI 使用说明](./docs/cli.md) | CLI 安装、命令和用法 |
|
||||||
|
| [📦 npm](https://www.npmjs.com/package/tttk) | `npx tttk serve ./content` |
|
||||||
| [🛠️ 开发指南](./docs/development.md) | 项目结构、开发规范和构建 |
|
| [🛠️ 开发指南](./docs/development.md) | 项目结构、开发规范和构建 |
|
||||||
| [📝 Markdown 编写说明](./docs/markdown.md) | Markdown 语法和组件用法 |
|
| [📝 Markdown 编写说明](./docs/markdown.md) | Markdown 语法和组件用法 |
|
||||||
| [📊 CSV 编写说明](./docs/csv.md) | CSV 文件格式、字段定义、变量语法 |
|
| [📊 CSV 编写说明](./docs/csv.md) | CSV 文件格式、字段定义、变量语法 |
|
||||||
@@ -27,7 +22,7 @@ ttrpg serve ./content
|
|||||||
|
|
||||||
## 功能概览
|
## 功能概览
|
||||||
|
|
||||||
- **CLI 工具**: `serve` 预览模式 和 `compile` 编译模式
|
- **CLI 工具**: `tttk serve` 预览模式 和 `tttk compile` 编译模式
|
||||||
- **Markdown 解析**: 支持指令语法、YAML 标签、mermaid 图表
|
- **Markdown 解析**: 支持指令语法、YAML 标签、mermaid 图表
|
||||||
- **TTRPG 组件**: 骰子、表格、卡牌、标记、命令追踪器等
|
- **TTRPG 组件**: 骰子、表格、卡牌、标记、命令追踪器等
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* CJS mock of `github-slugger` (v2 is ESM-only, which jest's CJS runtime
|
||||||
|
* cannot require). Auto-applied to all test files via the root `__mocks__`
|
||||||
|
* directory — no `jest.mock()` call needed.
|
||||||
|
*/
|
||||||
|
class Slugger {
|
||||||
|
constructor() {
|
||||||
|
this.seen = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
slug(value, maintainCase) {
|
||||||
|
let slug = String(value).trim();
|
||||||
|
if (!maintainCase) slug = slug.toLowerCase();
|
||||||
|
slug = slug
|
||||||
|
.replace(/[^\p{L}\p{N}\s_-]/gu, "")
|
||||||
|
.replace(/\s/g, "-");
|
||||||
|
|
||||||
|
// github-slugger dedups repeated slugs with a -1, -2, ... suffix
|
||||||
|
const count = this.seen.get(slug) || 0;
|
||||||
|
this.seen.set(slug, count + 1);
|
||||||
|
if (count > 0) return `${slug}-${count}`;
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Slugger;
|
||||||
+24
-4
@@ -1,13 +1,12 @@
|
|||||||
# yaml/tag 代码块格式测试
|
# yaml role=tag 代码块格式测试
|
||||||
|
|
||||||
:md-deck[./names.csv]{size="54x86" grid="5x8" bleed="1" padding="2" layers="name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s"}
|
:md-deck[./names.csv]{size="54x86" grid="5x8" bleed="1" padding="2" layers="name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s"}
|
||||||
|
|
||||||
:md-deck[./names.csv]{size="54x86" grid="5x8" layers="num1:1,1-2,2 num2:4,5-5,6f12s name1:1,1-2,2 name2:1,1-2,2 name1:1,1-2,2" }
|
:md-deck[./names.csv]{size="54x86" grid="5x8" layers="num1:1,1-2,2 num2:4,5-5,6f12s name1:1,1-2,2 name2:1,1-2,2 name1:1,1-2,2" }
|
||||||
|
|
||||||
## 使用 yaml/tag 语法创建 md-deck
|
## 使用 yaml role=tag 语法创建 md-deck(fence 行内联 tag,简洁写法)
|
||||||
|
|
||||||
```yaml/tag
|
```yaml role=tag tag=md-deck
|
||||||
tag: md-deck
|
|
||||||
body: ./names.csv
|
body: ./names.csv
|
||||||
size: 54x86
|
size: 54x86
|
||||||
grid: 5x8
|
grid: 5x8
|
||||||
@@ -15,3 +14,24 @@ bleed: 1
|
|||||||
padding: 2
|
padding: 2
|
||||||
layers: name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s
|
layers: name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```yaml role=tag
|
||||||
|
tag: md-deck
|
||||||
|
body: ./names.csv
|
||||||
|
data-config:
|
||||||
|
size: 54x86
|
||||||
|
grid: 5x8
|
||||||
|
layers:
|
||||||
|
- prop: name1
|
||||||
|
pos: 1,1-2,2
|
||||||
|
font: 12
|
||||||
|
- prop: num2
|
||||||
|
pos: 4,5-5,6
|
||||||
|
font: 12
|
||||||
|
orientation: s
|
||||||
|
- template: |
|
||||||
|
**{{name1}}** / {{num1}}
|
||||||
|
pos: 1,3-5,4
|
||||||
|
font: 6
|
||||||
|
align: l
|
||||||
|
```
|
||||||
|
|||||||
+16
-16
@@ -1,18 +1,18 @@
|
|||||||
# CLI 使用说明
|
# CLI 使用说明
|
||||||
|
|
||||||
TTRPG Tools 提供一个 CLI 工具,用于将目录内的各种 TTRPG 文档编译为 HTML。
|
TTTK 提供一个 CLI 工具,用于将目录内的各种 TTRPG 文档编译为 HTML。
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 克隆仓库后安装依赖
|
# 无需安装,直接运行
|
||||||
npm install
|
npx tttk serve ./content
|
||||||
|
|
||||||
# 全局链接 CLI 工具
|
# 或全局安装
|
||||||
npm link
|
npm install -g tttk
|
||||||
```
|
```
|
||||||
|
|
||||||
安装完成后,可在任意目录使用 `ttrpg` 命令。
|
安装完成后,可在任意目录使用 `tttk` 命令。
|
||||||
|
|
||||||
## 命令
|
## 命令
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ CLI 使用子命令组织:
|
|||||||
运行一个 Web 服务器预览目录中的内容,并实时监听文件更新。
|
运行一个 Web 服务器预览目录中的内容,并实时监听文件更新。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ttrpg serve [dir] -p 3000
|
tttk serve [dir] -p 3000
|
||||||
```
|
```
|
||||||
|
|
||||||
**参数:**
|
**参数:**
|
||||||
@@ -50,13 +50,13 @@ ttrpg serve [dir] -p 3000
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 预览当前目录
|
# 预览当前目录
|
||||||
ttrpg serve
|
tttk serve
|
||||||
|
|
||||||
# 预览指定目录
|
# 预览指定目录
|
||||||
ttrpg serve ./my-ttrpg-content
|
tttk serve ./my-ttrpg-content
|
||||||
|
|
||||||
# 指定端口
|
# 指定端口
|
||||||
ttrpg serve ./docs -p 8080
|
tttk serve ./docs -p 8080
|
||||||
```
|
```
|
||||||
|
|
||||||
### compile - 编译模式
|
### compile - 编译模式
|
||||||
@@ -64,7 +64,7 @@ ttrpg serve ./docs -p 8080
|
|||||||
将目录中的内容输出为带 hash 路由、单个 HTML 入口的 Web 应用。
|
将目录中的内容输出为带 hash 路由、单个 HTML 入口的 Web 应用。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ttrpg compile [dir] -o ./dist/output
|
tttk compile [dir] -o ./dist/output
|
||||||
```
|
```
|
||||||
|
|
||||||
**参数:**
|
**参数:**
|
||||||
@@ -90,13 +90,13 @@ ttrpg compile [dir] -o ./dist/output
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 编译当前目录
|
# 编译当前目录
|
||||||
ttrpg compile
|
tttk compile
|
||||||
|
|
||||||
# 编译指定目录并输出到指定位置
|
# 编译指定目录并输出到指定位置
|
||||||
ttrpg compile ./content -o ./build
|
tttk compile ./content -o ./build
|
||||||
|
|
||||||
# 编译并部署
|
# 编译并部署
|
||||||
ttrpg compile ./docs -o ./public && npm run preview
|
tttk compile ./docs -o ./public && npm run preview
|
||||||
```
|
```
|
||||||
|
|
||||||
## 输入文件
|
## 输入文件
|
||||||
@@ -162,7 +162,7 @@ my-ttrpg-content/
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 使用其他端口
|
# 使用其他端口
|
||||||
ttrpg serve -p 8080
|
tttk serve -p 8080
|
||||||
```
|
```
|
||||||
|
|
||||||
### 编译输出为空
|
### 编译输出为空
|
||||||
@@ -174,5 +174,5 @@ ttrpg serve -p 8080
|
|||||||
ls -R ./content
|
ls -R ./content
|
||||||
|
|
||||||
# 重新编译
|
# 重新编译
|
||||||
ttrpg compile ./content -o ./dist/output
|
tttk compile ./content -o ./dist/output
|
||||||
```
|
```
|
||||||
|
|||||||
+3
-3
@@ -28,7 +28,7 @@ npm test
|
|||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
ttrpg-tools/
|
tttk/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── cli/ # CLI 工具源码
|
│ ├── cli/ # CLI 工具源码
|
||||||
│ │ ├── commands/ # 命令实现 (serve, compile)
|
│ │ ├── commands/ # 命令实现 (serve, compile)
|
||||||
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
+55
-35
@@ -373,26 +373,30 @@ label,name,description
|
|||||||
:md-table[./quests.csv]{roll=true remix=true}
|
:md-table[./quests.csv]{roll=true remix=true}
|
||||||
```
|
```
|
||||||
|
|
||||||
**自动表格转换:**
|
**内联表格(显式声明):**
|
||||||
|
|
||||||
标准 Markdown 表格会自动转换为 `md-table` 组件,当表头包含 `label` 或 `md-table-label` 列时:
|
Markdown 表格不会自动转换。如需内联表格,用代码块并声明 `role=spark-table`(首列需为骰子公式,如 `d6`):
|
||||||
|
|
||||||
```markdown
|
````markdown
|
||||||
| label | name | description |
|
```markdown role=spark-table
|
||||||
|-------|------|-------------|
|
| d6 | 结果 |
|
||||||
| 1 | 战士 | 近战专家 |
|
|----|------|
|
||||||
| 2 | 法师 | 奥术施法者 |
|
| 1 | 遭遇强盗 |
|
||||||
|
| 2 | 平安无事 |
|
||||||
```
|
```
|
||||||
|
````
|
||||||
|
|
||||||
自动转换为 `:md-table` 组件。
|
扫描时转换为 CSV 并渲染为 `md-table` 组件。CSV 格式的内联表格用 `csv` 语言:
|
||||||
|
|
||||||
**特殊表头标识:**
|
````markdown
|
||||||
|
```csv role=spark-table
|
||||||
|
d6,结果
|
||||||
|
1,遭遇强盗
|
||||||
|
2,平安无事
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
| 表头 | 效果 |
|
普通 Markdown 表格(无 role 声明)始终按标准 GFM 表格渲染,不做任何转换。
|
||||||
|------|------|
|
|
||||||
| `label` 或 `md-table-label` | 转换为 md-table |
|
|
||||||
| `md-roll-label` 或骰子格式(如 `1d6`) | 添加 `roll=true` |
|
|
||||||
| `md-remix-label` | 添加 `roll=true remix=true` |
|
|
||||||
|
|
||||||
### 🃏 卡牌组件 (md-deck)
|
### 🃏 卡牌组件 (md-deck)
|
||||||
|
|
||||||
@@ -423,6 +427,32 @@ layers="字段:起始行,起始列 - 结束列,字体大小"
|
|||||||
|
|
||||||
示例:`title:1,1-5,1f8` 表示 title 字段从第 1 行开始,占据 1-5 列,8mm字体。
|
示例:`title:1,1-5,1f8` 表示 title 字段从第 1 行开始,占据 1-5 列,8mm字体。
|
||||||
|
|
||||||
|
**结构化配置(yaml 代码块):**
|
||||||
|
|
||||||
|
推荐使用 ```yaml role=tag 代码块(`yaml` 语言可被语法高亮),通过 `body` 指定 CSV 路径,`data-config` 提供结构化配置(图层可以是字段或模板):
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
```yaml role=tag
|
||||||
|
tag: md-deck
|
||||||
|
body: ./cards.csv
|
||||||
|
data-config:
|
||||||
|
size: 63x88
|
||||||
|
grid: 5x5
|
||||||
|
layers:
|
||||||
|
- prop: title
|
||||||
|
pos: 1,1-5,1
|
||||||
|
font: 12
|
||||||
|
- template: |
|
||||||
|
**{{name}}** — {{type}}
|
||||||
|
{{description}}
|
||||||
|
pos: 1,3-5,8
|
||||||
|
font: 3
|
||||||
|
align: l
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
每个图层通过 `prop`(读取 CSV 字段)或 `template`(渲染带 `{{变量}}` 替换的 markdown 模板)定义,`pos` 为网格位置 `x1,y1-x2,y2`(1-based,与紧凑 layers 字符串同格式),支持 `font`、`orientation`、`align`(水平 `l`/`c`/`r`,可加垂直前缀 `t`/`b` 组成 `tl`/`tc`/`tr`/`bl`/`bc`/`br`,如 `align: tl` 表示左上对齐)。`back_layers` 用于背面图层。
|
||||||
|
|
||||||
### 🧶 叙事线组件 (md-yarn-spinner)
|
### 🧶 叙事线组件 (md-yarn-spinner)
|
||||||
|
|
||||||
用于展示分支叙事结构,支持 Yarn Spinner 格式文件。
|
用于展示分支叙事结构,支持 Yarn Spinner 格式文件。
|
||||||
@@ -431,26 +461,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)
|
||||||
|
|
||||||
@@ -482,10 +493,10 @@ track npc#john.dwarf.warrior[hp=4/4 ac=15 name="John"]
|
|||||||
|
|
||||||
## YAML 标签
|
## YAML 标签
|
||||||
|
|
||||||
使用 ```yaml/tag 代码块创建自定义标签:
|
使用 ```yaml role=tag 代码块创建自定义标签(`yaml` 语言可被语法高亮):
|
||||||
|
|
||||||
````markdown
|
````markdown
|
||||||
```yaml/tag
|
```yaml role=tag
|
||||||
tag: tag-name
|
tag: tag-name
|
||||||
class: custom-class
|
class: custom-class
|
||||||
id: my-id
|
id: my-id
|
||||||
@@ -493,6 +504,15 @@ body: 标签内容
|
|||||||
```
|
```
|
||||||
````
|
````
|
||||||
|
|
||||||
|
`tag`、`id` 等简单属性也可以直接写在 fence 行上(fence 行属性优先于 YAML 内容):
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
```yaml role=tag tag=tag-name id=my-id
|
||||||
|
class: custom-class
|
||||||
|
body: 标签内容
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
渲染为:
|
渲染为:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
|
|||||||
+3
-2
@@ -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 图表
|
||||||
@@ -535,7 +535,8 @@ src/cli/
|
|||||||
│ │ └── write-frontmatter.ts
|
│ │ └── write-frontmatter.ts
|
||||||
│ ├── card/
|
│ ├── card/
|
||||||
│ │ └── card-crud.ts
|
│ │ └── card-crud.ts
|
||||||
│ ├── ensure-deck-preview.ts
|
│ ├── deck-utils.ts
|
||||||
|
│ ├── preview-deck.ts
|
||||||
│ └── generate-card-deck.ts
|
│ └── generate-card-deck.ts
|
||||||
└── index.ts
|
└── index.ts
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ export default {
|
|||||||
testEnvironment: 'node',
|
testEnvironment: 'node',
|
||||||
testMatch: ['**/*.test.ts'],
|
testMatch: ['**/*.test.ts'],
|
||||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
||||||
|
moduleNameMapper: {
|
||||||
|
// Resolve .js imports to .ts source files (ESM convention in TS source)
|
||||||
|
'^(.+)\\.js$': ['$1.ts', '$1.tsx', '$1.js'],
|
||||||
|
// github-slugger v2 is ESM-only; jest's CJS runtime cannot require it.
|
||||||
|
'^github-slugger$': '<rootDir>/__mocks__/github-slugger.js',
|
||||||
|
// Same for the browser ESM build of csv-parse — map to the CJS build.
|
||||||
|
'^csv-parse/browser/esm/sync$': 'csv-parse/sync',
|
||||||
|
},
|
||||||
transform: {
|
transform: {
|
||||||
'^.+\\.tsx?$': [
|
'^.+\\.tsx?$': [
|
||||||
'ts-jest',
|
'ts-jest',
|
||||||
|
|||||||
Generated
+828
-230
File diff suppressed because it is too large
Load Diff
+35
-19
@@ -1,27 +1,37 @@
|
|||||||
{
|
{
|
||||||
"name": "ttrpg-tools",
|
"name": "tttk",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"description": "A TTRPG toolbox based on solid.js and rsbuild",
|
"description": "A tabletop RPG toolkit for previewing and compiling TTRPG campaign content",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"ttrpg": "./dist/cli/index.js"
|
"tttk": "./dist/cli/index.js"
|
||||||
},
|
},
|
||||||
"main": "./dist/index.js",
|
"files": [
|
||||||
"module": "./dist/index.js",
|
"dist",
|
||||||
"exports": {
|
"LICENSE",
|
||||||
".": "./dist/index.js",
|
"README.md"
|
||||||
"./components": "./dist/components/index.js",
|
],
|
||||||
"./markdown": "./dist/markdown/index.js"
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/hypercross/ttrpg-tools"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "rsbuild dev",
|
"dev": "rsbuild dev",
|
||||||
"build": "rsbuild build",
|
"build": "npm run build:web && npm run build:cli",
|
||||||
|
"build:web": "rsbuild build",
|
||||||
|
"build:cli": "tsc -p tsconfig.cli.json",
|
||||||
"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": "npm run build:cli",
|
||||||
"ttrpg": "node --loader ts-node/esm ./src/cli/index.ts",
|
"ttrpg": "node --loader ts-node/esm ./src/cli/index.ts",
|
||||||
|
"tttk": "node --loader ts-node/esm ./src/cli/index.ts",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"prepare": "npx husky"
|
"prepare": "npx husky",
|
||||||
|
"prepublishOnly": "npm run build"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ttrpg",
|
"ttrpg",
|
||||||
@@ -29,14 +39,15 @@
|
|||||||
"markdown",
|
"markdown",
|
||||||
"toolbox"
|
"toolbox"
|
||||||
],
|
],
|
||||||
"author": "",
|
"author": "hypercross",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@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",
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
|
"comlink": "^4.4.2",
|
||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
"csv-parse": "^6.1.0",
|
"csv-parse": "^6.1.0",
|
||||||
"csv-stringify": "^6.7.0",
|
"csv-stringify": "^6.7.0",
|
||||||
@@ -46,14 +57,15 @@
|
|||||||
"marked-directive": "^1.0.7",
|
"marked-directive": "^1.0.7",
|
||||||
"marked-gfm-heading-id": "^4.1.3",
|
"marked-gfm-heading-id": "^4.1.3",
|
||||||
"mermaid": "^11.0.0",
|
"mermaid": "^11.0.0",
|
||||||
|
"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",
|
||||||
"yarn-spinner-loader": "^0.1.0"
|
"yarn-spinner-loader": "^0.1.0",
|
||||||
|
"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",
|
||||||
@@ -63,12 +75,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",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-16
@@ -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: [
|
||||||
@@ -20,28 +21,38 @@ export default defineConfig({
|
|||||||
module: {
|
module: {
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
test: /\.md|\.yarn|\.csv$/,
|
// Built-in doc entries are statically imported as strings
|
||||||
exclude: /\.schema\.csv$/,
|
test: /[\\/]doc-entries[\\/].*\.md$/,
|
||||||
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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
// Proxy content API requests to the CLI dev server (npm run tttk)
|
||||||
|
"/__CONTENT_INDEX.json": "http://localhost:3000",
|
||||||
|
"/__COMPLETIONS.json": "http://localhost:3000",
|
||||||
|
"/content": "http://localhost:3000",
|
||||||
|
"/.ttrpg": "http://localhost:3000",
|
||||||
|
},
|
||||||
|
historyApiFallback: true,
|
||||||
|
},
|
||||||
output: {
|
output: {
|
||||||
distPath: {
|
distPath: {
|
||||||
root: 'dist/web',
|
root: "dist/web",
|
||||||
},
|
},
|
||||||
copy: process.env.NODE_ENV === 'development' ? [
|
|
||||||
{ from: './content', to: 'content' }
|
|
||||||
] : [],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+109
-25
@@ -1,5 +1,13 @@
|
|||||||
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 "./components";
|
import "./components";
|
||||||
@@ -9,14 +17,38 @@ import {
|
|||||||
DesktopSidebar,
|
DesktopSidebar,
|
||||||
DocDialog,
|
DocDialog,
|
||||||
DataSourceDialog,
|
DataSourceDialog,
|
||||||
|
RevealManager,
|
||||||
|
ReactiveVariableManager,
|
||||||
|
CommandLinkManager,
|
||||||
} from "./components";
|
} from "./components";
|
||||||
import { generateToc, type FileNode, type TocNode } from "./data-loader";
|
import { generateToc, type FileNode, type TocNode } from "./data-loader";
|
||||||
|
import { JournalPanel } from "./components/journal";
|
||||||
|
import { useHeadingFlash } from "./components/useHeadingFlash";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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 [isSidebarOpen, setIsSidebarOpen] = createSignal(false);
|
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false);
|
||||||
const [isDocOpen, setIsDocOpen] = createSignal(false);
|
const [isDocOpen, setIsDocOpen] = createSignal(false);
|
||||||
const [isDataSourceOpen, setIsDataSourceOpen] = createSignal(false);
|
const [isDataSourceOpen, setIsDataSourceOpen] = createSignal(false);
|
||||||
|
const [isJournalOpen, setIsJournalOpen] = createSignal(false);
|
||||||
|
|
||||||
// Sidebar and TOC data — reload when source changes
|
// Sidebar and TOC data — reload when source changes
|
||||||
const [fileTree, setFileTree] = createSignal<FileNode[]>([]);
|
const [fileTree, setFileTree] = createSignal<FileNode[]>([]);
|
||||||
@@ -25,6 +57,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);
|
||||||
@@ -37,6 +71,8 @@ const App: Component = () => {
|
|||||||
void loadToc();
|
void loadToc();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useHeadingFlash();
|
||||||
|
|
||||||
const handleSourceChanged = () => {
|
const handleSourceChanged = () => {
|
||||||
setTocKey((k) => k + 1);
|
setTocKey((k) => k + 1);
|
||||||
};
|
};
|
||||||
@@ -53,18 +89,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 fileTree={fileTree()} pathHeadings={pathHeadings()} />
|
<header class="shrink-0 h-16 bg-white shadow z-30">
|
||||||
{/* 移动端抽屉式侧边栏 */}
|
<div class="h-full max-w-4xl mx-auto px-4 flex items-center gap-4">
|
||||||
<MobileSidebar
|
|
||||||
isOpen={isSidebarOpen()}
|
|
||||||
onClose={() => setIsSidebarOpen(false)}
|
|
||||||
fileTree={fileTree()}
|
|
||||||
pathHeadings={pathHeadings()}
|
|
||||||
/>
|
|
||||||
<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)}
|
||||||
@@ -73,14 +101,28 @@ 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 Toolkit</h1>
|
||||||
<div class="flex-1" />
|
<div class="flex-1" />
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsDataSourceOpen(true)}
|
onClick={() => setIsJournalOpen((v) => !v)}
|
||||||
class="text-sm text-gray-500 hover:text-gray-700 px-2 py-1 rounded hover:bg-gray-100"
|
class={`relative w-8 h-8 rounded flex items-center justify-center text-sm ${
|
||||||
title="Content Source"
|
isJournalOpen()
|
||||||
|
? "bg-blue-100 text-blue-600"
|
||||||
|
: "text-gray-500 hover:bg-gray-100"
|
||||||
|
}`}
|
||||||
|
title={`Journal (${stream.connectionStatus})`}
|
||||||
>
|
>
|
||||||
📂
|
<span
|
||||||
|
class="absolute top-0.5 right-0.5 w-2 h-2 rounded-full border border-white"
|
||||||
|
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",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
📋
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsDocOpen(true)}
|
onClick={() => setIsDocOpen(true)}
|
||||||
@@ -91,15 +133,57 @@ const App: Component = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{/* fill th rest of the space*/}
|
|
||||||
<div class="fixed top-16 left-0 right-0 bottom-0 overflow-auto">
|
{/* 移动端抽屉式侧边栏 (overlay) */}
|
||||||
<main class="max-w-4xl mx-auto px-4 py-8 pt-4 md:ml-64 2xl:ml-auto flex justify-center items-center">
|
<MobileSidebar
|
||||||
<Article
|
isOpen={isSidebarOpen()}
|
||||||
class="prose text-black prose-sm max-w-full flex-1"
|
onClose={() => setIsSidebarOpen(false)}
|
||||||
src={currentPath()}
|
fileTree={fileTree()}
|
||||||
|
pathHeadings={pathHeadings()}
|
||||||
|
onDataSourceOpen={() => setIsDataSourceOpen(true)}
|
||||||
/>
|
/>
|
||||||
</main>
|
|
||||||
|
{/* Body: sidebar + main + journal */}
|
||||||
|
<div class="flex flex-1 min-h-0">
|
||||||
|
{/* 桌面端固定侧边栏 (flex child) */}
|
||||||
|
<DesktopSidebar
|
||||||
|
fileTree={fileTree()}
|
||||||
|
pathHeadings={pathHeadings()}
|
||||||
|
onDataSourceOpen={() => setIsDataSourceOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main content — the scrollable region */}
|
||||||
|
<ScrollContainerCtx.Provider value={() => mainRef}>
|
||||||
|
<main ref={mainRef} class="flex-1 min-w-0 overflow-y-auto">
|
||||||
|
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||||
|
<Article
|
||||||
|
class="prose prose-sm max-w-full"
|
||||||
|
src={currentPath()}
|
||||||
|
>
|
||||||
|
<RevealManager />
|
||||||
|
<ReactiveVariableManager />
|
||||||
|
<CommandLinkManager />
|
||||||
|
</Article>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<DocDialog isOpen={isDocOpen()} onClose={() => setIsDocOpen(false)} />
|
<DocDialog isOpen={isDocOpen()} onClose={() => setIsDocOpen(false)} />
|
||||||
<DataSourceDialog
|
<DataSourceDialog
|
||||||
isOpen={isDataSourceOpen()}
|
isOpen={isDataSourceOpen()}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { CompileCommandHandler } from '../types.js';
|
import type { CompileCommandHandler } from "../types.js";
|
||||||
|
|
||||||
export const compileCommand: CompileCommandHandler = async (dir, options) => {
|
export const compileCommand: CompileCommandHandler = async (dir, options) => {
|
||||||
console.log(`开始编译...`);
|
console.log("开始编译...");
|
||||||
console.log(`目录:${dir}`);
|
console.log(`源目录:${dir}`);
|
||||||
console.log(`输出目录:${options.output}`);
|
console.log(`输出目录:${options.output}`);
|
||||||
|
|
||||||
// TODO: 实现编译逻辑
|
// TODO: 实现编译逻辑
|
||||||
@@ -10,5 +10,5 @@ export const compileCommand: CompileCommandHandler = async (dir, options) => {
|
|||||||
// 2. 解析 markdown 并生成路由
|
// 2. 解析 markdown 并生成路由
|
||||||
// 3. 打包为带 hash 路由的单个 HTML 入口
|
// 3. 打包为带 hash 路由的单个 HTML 入口
|
||||||
|
|
||||||
console.log('编译完成!');
|
console.log("编译完成!");
|
||||||
};
|
};
|
||||||
|
|||||||
+159
-48
@@ -2,23 +2,32 @@ import type { ServeCommandHandler } from "../types.js";
|
|||||||
import { createServer, Server, IncomingMessage, ServerResponse } from "http";
|
import { createServer, Server, IncomingMessage, ServerResponse } from "http";
|
||||||
import { readdirSync, statSync, readFileSync, existsSync } from "fs";
|
import { readdirSync, statSync, readFileSync, existsSync } from "fs";
|
||||||
import { createReadStream } from "fs";
|
import { createReadStream } from "fs";
|
||||||
import { join, resolve, extname, sep, relative, dirname } from "path";
|
import { join, resolve, extname, relative, dirname } from "path";
|
||||||
import { watch } from "chokidar";
|
import { watch } from "chokidar";
|
||||||
|
import { networkInterfaces } from "os";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
import { createJournalServer } from "../journal.js";
|
||||||
|
import {
|
||||||
|
buildRegistryFromIndex,
|
||||||
|
normalizePathKey,
|
||||||
|
deriveCompletions,
|
||||||
|
type ContentRegistry,
|
||||||
|
} from "../content-registry.js";
|
||||||
|
import type { CompletionsPayload } from "../completions/types.js";
|
||||||
|
|
||||||
interface ContentIndex {
|
interface ContentIndex {
|
||||||
[path: string]: string;
|
[path: string]: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 CLI 脚本文件所在目录路径(用于定位 dist 文件夹)
|
* 获取 CLI 脚本文件所在目录路径(用于定位 dist 目录)
|
||||||
*/
|
*/
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
const distDir = resolve(__dirname, "..", "..", "..", "dist", "web");
|
const distDir = resolve(__dirname, "..", "..", "..", "dist", "web");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MIME 类型映射
|
* MIME 类型映射表
|
||||||
*/
|
*/
|
||||||
const MIME_TYPES: Record<string, string> = {
|
const MIME_TYPES: Record<string, string> = {
|
||||||
".html": "text/html",
|
".html": "text/html",
|
||||||
@@ -37,7 +46,7 @@ const MIME_TYPES: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取文件扩展名对应的 MIME 类型
|
* 根据文件扩展名获取对应的 MIME 类型
|
||||||
*/
|
*/
|
||||||
function getMimeType(filePath: string): string {
|
function getMimeType(filePath: string): string {
|
||||||
const ext = extname(filePath).toLowerCase();
|
const ext = extname(filePath).toLowerCase();
|
||||||
@@ -45,10 +54,39 @@ function getMimeType(filePath: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 扫描目录内的 .md 文件,生成索引
|
* 获取最佳网络 IP 用于显示(优先局域网 IPv4,回退到 localhost)
|
||||||
*/
|
*/
|
||||||
export function scanDirectory(dir: string): ContentIndex {
|
function getBestIP(): string {
|
||||||
const index: ContentIndex = {};
|
const interfaces = networkInterfaces();
|
||||||
|
let best: string | null = null;
|
||||||
|
|
||||||
|
for (const [, addrs] of Object.entries(interfaces)) {
|
||||||
|
if (!addrs) continue;
|
||||||
|
for (const addr of addrs) {
|
||||||
|
if (addr.family !== "IPv4" || addr.internal) continue;
|
||||||
|
// Prefer a 192.168.x.x or 10.x.x.x address
|
||||||
|
if (addr.address.startsWith("192.168.")) {
|
||||||
|
best = addr.address;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!best &&
|
||||||
|
(addr.address.startsWith("10.") || addr.address.startsWith("172."))
|
||||||
|
) {
|
||||||
|
best = addr.address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best?.startsWith("192.168.")) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return best || "localhost";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描目录内的 .md 等文件,构建内容注册表(路径索引 + 每文档内联内容)
|
||||||
|
*/
|
||||||
|
export function buildRegistry(dir: string): ContentRegistry {
|
||||||
|
const index: Record<string, string> = {};
|
||||||
|
|
||||||
function scan(currentPath: string, relativePath: string) {
|
function scan(currentPath: string, relativePath: string) {
|
||||||
const entries = readdirSync(currentPath);
|
const entries = readdirSync(currentPath);
|
||||||
@@ -58,15 +96,19 @@ export function scanDirectory(dir: string): ContentIndex {
|
|||||||
|
|
||||||
const fullPath = join(currentPath, entry);
|
const fullPath = join(currentPath, entry);
|
||||||
const relPath = relativePath ? join(relativePath, entry) : entry;
|
const relPath = relativePath ? join(relativePath, entry) : entry;
|
||||||
const normalizedRelPath = "/" + relPath.split(sep).join("/");
|
const normalizedRelPath = normalizePathKey(relPath);
|
||||||
|
|
||||||
const stats = statSync(fullPath);
|
const stats = statSync(fullPath);
|
||||||
if (stats.isDirectory()) {
|
if (stats.isDirectory()) {
|
||||||
scan(fullPath, relPath);
|
scan(fullPath, relPath);
|
||||||
} else if (entry.endsWith(".md") || entry.endsWith(".csv") || entry.endsWith(".yarn")) {
|
} else if (
|
||||||
|
entry.endsWith(".md") ||
|
||||||
|
entry.endsWith(".csv") ||
|
||||||
|
entry.endsWith(".yarn") ||
|
||||||
|
entry.endsWith(".svg")
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(fullPath, "utf-8");
|
index[normalizedRelPath] = readFileSync(fullPath, "utf-8");
|
||||||
index[normalizedRelPath] = content;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`读取文件失败:${fullPath}`, e);
|
console.error(`读取文件失败:${fullPath}`, e);
|
||||||
}
|
}
|
||||||
@@ -75,11 +117,12 @@ export function scanDirectory(dir: string): ContentIndex {
|
|||||||
}
|
}
|
||||||
|
|
||||||
scan(dir, "");
|
scan(dir, "");
|
||||||
return index;
|
|
||||||
|
return buildRegistryFromIndex(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送文件响应
|
* 发送文件响应(流式传输)
|
||||||
*/
|
*/
|
||||||
function sendFile(res: ServerResponse, filePath: string) {
|
function sendFile(res: ServerResponse, filePath: string) {
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
@@ -90,7 +133,7 @@ function sendFile(res: ServerResponse, filePath: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送 404 响应
|
* 发送 404 未找到响应
|
||||||
*/
|
*/
|
||||||
function send404(res: ServerResponse) {
|
function send404(res: ServerResponse) {
|
||||||
res.writeHead(404, { "Content-Type": "text/plain" });
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
||||||
@@ -98,7 +141,7 @@ function send404(res: ServerResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送 JSON 响应
|
* 发送 JSON 格式的响应
|
||||||
*/
|
*/
|
||||||
function sendJson(res: ServerResponse, data: unknown) {
|
function sendJson(res: ServerResponse, data: unknown) {
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
@@ -110,7 +153,7 @@ function sendJson(res: ServerResponse, data: unknown) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 尝试提供静态文件
|
* 尝试提供静态文件
|
||||||
* @returns 如果文件存在并已成功发送则返回 true
|
* @returns 文件存在并成功发送时返回 true
|
||||||
*/
|
*/
|
||||||
function tryServeStatic(
|
function tryServeStatic(
|
||||||
res: ServerResponse,
|
res: ServerResponse,
|
||||||
@@ -133,12 +176,14 @@ function tryServeStatic(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建请求处理器
|
* 创建 HTTP 请求处理器
|
||||||
*/
|
*/
|
||||||
function createRequestHandler(
|
function createRequestHandler(
|
||||||
contentDir: string,
|
contentDir: string,
|
||||||
distDir: string,
|
distDir: string,
|
||||||
getIndex: () => ContentIndex,
|
getIndex: () => ContentIndex,
|
||||||
|
getCompletions: () => CompletionsPayload,
|
||||||
|
getRegistry: () => ContentRegistry,
|
||||||
) {
|
) {
|
||||||
return (req: IncomingMessage, res: ServerResponse) => {
|
return (req: IncomingMessage, res: ServerResponse) => {
|
||||||
const url = req.url || "/";
|
const url = req.url || "/";
|
||||||
@@ -150,6 +195,18 @@ function createRequestHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1b. 处理 /__COMPLETIONS.json
|
||||||
|
if (filePath === "/__COMPLETIONS.json") {
|
||||||
|
sendJson(res, getCompletions());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1c. 处理 /__CONTENT_REGISTRY.json(含每文档内联内容,供运行时解析)
|
||||||
|
if (filePath === "/__CONTENT_REGISTRY.json") {
|
||||||
|
sendJson(res, getRegistry());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 2. 处理 /static/ 目录(从 dist/web)
|
// 2. 处理 /static/ 目录(从 dist/web)
|
||||||
if (filePath.startsWith("/static/")) {
|
if (filePath.startsWith("/static/")) {
|
||||||
if (tryServeStatic(res, filePath, distDir)) {
|
if (tryServeStatic(res, filePath, distDir)) {
|
||||||
@@ -176,7 +233,7 @@ function createRequestHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 内容服务器接口
|
* 内容服务器实例接口
|
||||||
*/
|
*/
|
||||||
export interface ContentServer {
|
export interface ContentServer {
|
||||||
/**
|
/**
|
||||||
@@ -191,6 +248,10 @@ export interface ContentServer {
|
|||||||
* 内容索引
|
* 内容索引
|
||||||
*/
|
*/
|
||||||
index: ContentIndex;
|
index: ContentIndex;
|
||||||
|
/**
|
||||||
|
* 补全数据
|
||||||
|
*/
|
||||||
|
completions: CompletionsPayload;
|
||||||
/**
|
/**
|
||||||
* 关闭服务器
|
* 关闭服务器
|
||||||
*/
|
*/
|
||||||
@@ -198,7 +259,7 @@ export interface ContentServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建内容服务器
|
* 创建内容服务器(HTTP + WebSocket + 文件监听)
|
||||||
*/
|
*/
|
||||||
export function createContentServer(
|
export function createContentServer(
|
||||||
contentDir: string,
|
contentDir: string,
|
||||||
@@ -206,80 +267,124 @@ export function createContentServer(
|
|||||||
distPath: string = distDir,
|
distPath: string = distDir,
|
||||||
host: string = "0.0.0.0",
|
host: string = "0.0.0.0",
|
||||||
): ContentServer {
|
): ContentServer {
|
||||||
let contentIndex: ContentIndex = {};
|
let registry: ContentRegistry = { pathIndex: {}, docContent: {} };
|
||||||
|
let completionsIndex: CompletionsPayload = {
|
||||||
|
dice: [],
|
||||||
|
links: [],
|
||||||
|
sparkTables: [],
|
||||||
|
declarations: [],
|
||||||
|
tagModifiers: [],
|
||||||
|
};
|
||||||
|
|
||||||
// 扫描内容目录生成索引
|
/** 从当前注册表重新派生补全数据 */
|
||||||
console.log("扫描内容目录...");
|
function recomputeCompletions(): void {
|
||||||
contentIndex = scanDirectory(contentDir);
|
completionsIndex = deriveCompletions(registry);
|
||||||
console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`);
|
console.log(
|
||||||
|
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} declarations=${completionsIndex.declarations.length} tagModifiers=${completionsIndex.tagModifiers.length}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扫描内容目录生成注册表
|
||||||
|
console.log("正在扫描内容目录...");
|
||||||
|
registry = buildRegistry(contentDir);
|
||||||
|
console.log(`已索引 ${Object.keys(registry.pathIndex).length} 个文件`);
|
||||||
|
recomputeCompletions();
|
||||||
|
|
||||||
// 监听文件变化
|
// 监听文件变化
|
||||||
console.log("监听文件变化...");
|
console.log("正在监听文件变化...");
|
||||||
const watcher = watch(contentDir, {
|
const watcher = watch(contentDir, {
|
||||||
ignored: /(^|[\/\\])\../,
|
ignored: /(^|[\/\\])\./,
|
||||||
persistent: true,
|
persistent: true,
|
||||||
ignoreInitial: true,
|
ignoreInitial: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
watcher
|
watcher
|
||||||
.on("add", (path) => {
|
.on("add", (path) => {
|
||||||
if (path.endsWith(".md") || path.endsWith(".csv") || path.endsWith(".yarn")) {
|
if (
|
||||||
|
path.endsWith(".md") ||
|
||||||
|
path.endsWith(".csv") ||
|
||||||
|
path.endsWith(".yarn") ||
|
||||||
|
path.endsWith(".svg")
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(path, "utf-8");
|
// 全量重建注册表以刷新跨文件派生(spark 注入)
|
||||||
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
registry = buildRegistry(contentDir);
|
||||||
contentIndex[relPath] = content;
|
recomputeCompletions();
|
||||||
console.log(`[新增] ${relPath}`);
|
console.log(`[新增] ${path}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`读取新增文件失败:${path}`, e);
|
console.error(`读取新增文件失败:${path}`, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on("change", (path) => {
|
.on("change", (path) => {
|
||||||
if (path.endsWith(".md") || path.endsWith(".csv") || path.endsWith(".yarn")) {
|
if (
|
||||||
|
path.endsWith(".md") ||
|
||||||
|
path.endsWith(".csv") ||
|
||||||
|
path.endsWith(".yarn") ||
|
||||||
|
path.endsWith(".svg")
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(path, "utf-8");
|
registry = buildRegistry(contentDir);
|
||||||
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
recomputeCompletions();
|
||||||
contentIndex[relPath] = content;
|
console.log(`[更新] ${path}`);
|
||||||
console.log(`[更新] ${relPath}`);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`读取更新文件失败:${path}`, e);
|
console.error(`读取更新文件失败:${path}`, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on("unlink", (path) => {
|
.on("unlink", (path) => {
|
||||||
if (path.endsWith(".md") || path.endsWith(".csv") || path.endsWith(".yarn")) {
|
if (
|
||||||
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
path.endsWith(".md") ||
|
||||||
delete contentIndex[relPath];
|
path.endsWith(".csv") ||
|
||||||
console.log(`[删除] ${relPath}`);
|
path.endsWith(".yarn") ||
|
||||||
|
path.endsWith(".svg")
|
||||||
|
) {
|
||||||
|
registry = buildRegistry(contentDir);
|
||||||
|
recomputeCompletions();
|
||||||
|
console.log(`[删除] ${path}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 创建请求处理器
|
// ---- Journal / MQTT broker ----
|
||||||
|
let journal: Awaited<ReturnType<typeof createJournalServer>> | null = null;
|
||||||
|
|
||||||
|
// 在 journal 之前创建 HTTP 服务器,以便传入给 journal
|
||||||
const handleRequest = createRequestHandler(
|
const handleRequest = createRequestHandler(
|
||||||
contentDir,
|
contentDir,
|
||||||
distPath,
|
distPath,
|
||||||
() => contentIndex,
|
() => registry.pathIndex,
|
||||||
|
() => completionsIndex,
|
||||||
|
() => registry,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 创建 HTTP 服务器
|
|
||||||
const server = createServer(handleRequest);
|
const server = createServer(handleRequest);
|
||||||
|
|
||||||
|
createJournalServer(contentDir, server).then((j) => {
|
||||||
|
journal = j;
|
||||||
|
});
|
||||||
|
|
||||||
server.listen(port, host, () => {
|
server.listen(port, host, () => {
|
||||||
const displayHost = host === "0.0.0.0" ? "localhost" : host;
|
const bestIP = getBestIP();
|
||||||
|
const displayHost = host === "0.0.0.0" ? bestIP : host;
|
||||||
console.log(`\n开发服务器已启动:http://${displayHost}:${port}`);
|
console.log(`\n开发服务器已启动:http://${displayHost}:${port}`);
|
||||||
|
console.log(`本机访问:http://localhost:${port}`);
|
||||||
|
if (bestIP !== "localhost") {
|
||||||
|
console.log(`局域网访问:http://${bestIP}:${port}`);
|
||||||
|
}
|
||||||
console.log(`内容目录:${contentDir}`);
|
console.log(`内容目录:${contentDir}`);
|
||||||
console.log(`静态资源目录:${distPath}`);
|
console.log(`静态资源目录:${distPath}`);
|
||||||
console.log(`索引文件:http://${displayHost}:${port}/__CONTENT_INDEX.json\n`);
|
console.log(`日志连接:ws://${bestIP}:${port}\n`);
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
server,
|
server,
|
||||||
watcher,
|
watcher,
|
||||||
index: contentIndex,
|
index: registry.pathIndex,
|
||||||
|
completions: completionsIndex,
|
||||||
close() {
|
close() {
|
||||||
console.log("关闭内容服务器...");
|
console.log("正在关闭内容服务器...");
|
||||||
server.close();
|
server.close();
|
||||||
watcher.close();
|
watcher.close();
|
||||||
|
if (journal) journal.close();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -289,6 +394,12 @@ export function createContentServer(
|
|||||||
*/
|
*/
|
||||||
export const serveCommand: ServeCommandHandler = async (dir, options) => {
|
export const serveCommand: ServeCommandHandler = async (dir, options) => {
|
||||||
const contentDir = resolve(dir);
|
const contentDir = resolve(dir);
|
||||||
|
|
||||||
|
if (!existsSync(contentDir)) {
|
||||||
|
console.error(`错误:目录不存在:${contentDir}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
const port = parseInt(options.port, 10);
|
const port = parseInt(options.port, 10);
|
||||||
const host = options.host || "0.0.0.0";
|
const host = options.host || "0.0.0.0";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Jest mock for csv-parse/browser/esm/sync
|
||||||
|
*
|
||||||
|
* The real import path is csv-parse/browser/esm/sync which only works in
|
||||||
|
* browser bundlers. In Node (Jest), we redirect to csv-parse/sync.
|
||||||
|
*/
|
||||||
|
export { parse } from "csv-parse/sync";
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Shared block scanning utilities — safe for both CLI and browser.
|
||||||
|
*
|
||||||
|
* Parses fenced code blocks with attributes:
|
||||||
|
* ```lang id=xxx role=xxx key=value
|
||||||
|
*
|
||||||
|
* The `role` fully determines block behavior (see `scanDoc` in
|
||||||
|
* `content-registry.ts`):
|
||||||
|
* - spark-table → stored as CSV, rendered as an `:md-table` directive
|
||||||
|
* - declare / file → stored, block stripped
|
||||||
|
* - tag → block kept intact for the yaml-tag render extension
|
||||||
|
* - unknown role → warned about and stripped
|
||||||
|
* - no role → kept as a visible code block
|
||||||
|
*
|
||||||
|
* Attribute parsing itself lives in `src/markdown/block-attrs.ts` so render
|
||||||
|
* extensions can share the same syntax.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { parseBlockAttrs, type BlockAttrs } from "../../markdown/block-attrs.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Regex
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Matches fenced code blocks with an info string (at least one attr). */
|
||||||
|
export const FENCED_BLOCK_RE = /^```(\w*)\s+(\S.*?)\s*\n([\s\S]*?)```\s*$/gm;
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Declare parser — parses ```csv role=declare code blocks from markdown.
|
||||||
|
*
|
||||||
|
* Shared between CLI completions scanner and browser-side completions.
|
||||||
|
*
|
||||||
|
* Format (columns can be in any order; `tag` and `threshold` are optional):
|
||||||
|
* tag,threshold,key,expr
|
||||||
|
* ,,$hp,$con*5+$mod_hp ← variable declaration
|
||||||
|
* ,,$ac,10+$dex ← variable declaration
|
||||||
|
* #warrior,2,$mod_hp,20 ← tag modifier (threshold 2)
|
||||||
|
* #warrior,,$mod_str,1 ← tag modifier (threshold defaults to 1)
|
||||||
|
*
|
||||||
|
* When `tag` is empty: $key is a reactively computed variable.
|
||||||
|
* When `tag` is present: when #tag met (source's tagmap value >= threshold),
|
||||||
|
* $key gets expr added to its base.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { parse } from "csv-parse/browser/esm/sync";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface VarDeclaration {
|
||||||
|
key: string; // "$hp" (always starts with $)
|
||||||
|
expression: string; // "$con*5+$mod_hp"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TagModifier {
|
||||||
|
tag: string; // "#warrior"
|
||||||
|
target: string; // "$mod_hp"
|
||||||
|
expression: string; // "20"
|
||||||
|
threshold: number; // minimum tagmap count to activate (default 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeclareResult {
|
||||||
|
variables: VarDeclaration[];
|
||||||
|
tagModifiers: TagModifier[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a single ```csv role=declare block body.
|
||||||
|
*/
|
||||||
|
export function parseDeclareCsv(csv: string, source: string): DeclareResult {
|
||||||
|
const trimmed = csv.trim();
|
||||||
|
if (!trimmed) return { variables: [], tagModifiers: [] };
|
||||||
|
|
||||||
|
// Validate that required columns exist (order-agnostic, tag is optional)
|
||||||
|
const firstLine = trimmed.split(/\r?\n/)[0];
|
||||||
|
const headers = firstLine.split(",").map((h) => h.trim().toLowerCase());
|
||||||
|
if (!headers.includes("key") || !headers.includes("expr")) {
|
||||||
|
throw new Error(
|
||||||
|
`${source}: role=declare blocks must have "key" and "expr" columns. Got: ${headers.join(",")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = parse(trimmed, {
|
||||||
|
columns: true,
|
||||||
|
trim: true,
|
||||||
|
skipEmptyLines: true,
|
||||||
|
}) as Array<{ tag?: string; threshold?: string; key: string; expr: string }>;
|
||||||
|
|
||||||
|
const variables: VarDeclaration[] = [];
|
||||||
|
const tagModifiers: TagModifier[] = [];
|
||||||
|
|
||||||
|
for (const row of records) {
|
||||||
|
const tag = row.tag ?? "";
|
||||||
|
const key = row.key ?? "";
|
||||||
|
const expr = row.expr ?? "";
|
||||||
|
|
||||||
|
if (!key || !expr) {
|
||||||
|
console.warn(`${source}: skipping row with empty key or expr`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag) {
|
||||||
|
// Tag modifier
|
||||||
|
if (!tag.startsWith("#")) {
|
||||||
|
throw new Error(`${source}: tag must start with #, got "${tag}"`);
|
||||||
|
}
|
||||||
|
if (!key.startsWith("$")) {
|
||||||
|
throw new Error(`${source}: key must start with $, got "${key}"`);
|
||||||
|
}
|
||||||
|
const thresholdRaw = row.threshold?.trim() ?? "";
|
||||||
|
const threshold = thresholdRaw ? parseInt(thresholdRaw, 10) : 1;
|
||||||
|
if (isNaN(threshold) || threshold < 1) {
|
||||||
|
throw new Error(
|
||||||
|
`${source}: threshold must be a positive integer, got "${row.threshold}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tagModifiers.push({ tag, target: key, expression: expr, threshold });
|
||||||
|
} else {
|
||||||
|
// Variable declaration
|
||||||
|
if (!key.startsWith("$")) {
|
||||||
|
throw new Error(`${source}: key must start with $, got "${key}"`);
|
||||||
|
}
|
||||||
|
variables.push({ key, expression: expr });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { variables, tagModifiers };
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Completion source types — shared between CLI scanner and frontend consumer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { VarDeclaration, TagModifier } from "./declare-parser.js";
|
||||||
|
|
||||||
|
export type { VarDeclaration, TagModifier };
|
||||||
|
|
||||||
|
/** A single dice expression found in a markdown file */
|
||||||
|
export interface DiceCompletion {
|
||||||
|
/** Display text shown in the dropdown */
|
||||||
|
label: string;
|
||||||
|
/** Dice notation, e.g. "3d6kh1" or "1d20+5" */
|
||||||
|
notation: string;
|
||||||
|
/** Source file for attribution */
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single link target (article path, optionally scoped to a heading) */
|
||||||
|
export interface LinkCompletion {
|
||||||
|
/** Route-relative path without .md extension, e.g. "/rules/combat" */
|
||||||
|
path: string;
|
||||||
|
/** Human-readable label */
|
||||||
|
label: string;
|
||||||
|
/** Heading slug anchor (without #), or null for the whole file */
|
||||||
|
section: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A spark table found in a markdown file */
|
||||||
|
export interface SparkTableCompletion {
|
||||||
|
/** Display label: "file § slug" */
|
||||||
|
label: string;
|
||||||
|
/** Dice notation (e.g. "d6", "d20") parsed from the first column header */
|
||||||
|
notation: string;
|
||||||
|
/** Concatenated slug of data column headers */
|
||||||
|
slug: string;
|
||||||
|
/** File path of the containing .md file (without extension) */
|
||||||
|
filePath: string;
|
||||||
|
/** Path of the containing .md file (with extension) — for inline lookup */
|
||||||
|
docPath: string;
|
||||||
|
/** Resolved path to the .csv file backing this spark table */
|
||||||
|
csvPath: string;
|
||||||
|
/** Data column headers for display */
|
||||||
|
headers: string[];
|
||||||
|
/** Whether to roll each column independently (remix mode) */
|
||||||
|
remix: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompletionsPayload {
|
||||||
|
dice: DiceCompletion[];
|
||||||
|
links: LinkCompletion[];
|
||||||
|
sparkTables: SparkTableCompletion[];
|
||||||
|
declarations: VarDeclaration[];
|
||||||
|
tagModifiers: TagModifier[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A completion source plugin. Add new sources by implementing this
|
||||||
|
* interface and registering in the barrel.
|
||||||
|
*/
|
||||||
|
export interface CompletionSource {
|
||||||
|
/** Unique key — becomes the top-level key in CompletionsPayload */
|
||||||
|
key: string;
|
||||||
|
/** Scan the content index and return structured completion items */
|
||||||
|
scan(index: Record<string, string>): unknown[];
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,199 @@
|
|||||||
|
import {
|
||||||
|
scanDoc,
|
||||||
|
resolveContent,
|
||||||
|
resolveContentEntry,
|
||||||
|
buildRegistryFromIndex,
|
||||||
|
deriveCompletions,
|
||||||
|
type ContentRegistry,
|
||||||
|
} from "./content-registry";
|
||||||
|
|
||||||
|
function emptyRegistry(): ContentRegistry {
|
||||||
|
return { pathIndex: {}, docContent: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("scanDoc", () => {
|
||||||
|
test("csv role=spark-table block becomes an :md-table directive", () => {
|
||||||
|
const md = [
|
||||||
|
"```csv role=spark-table",
|
||||||
|
"d6,Name",
|
||||||
|
"1,Alice",
|
||||||
|
"2,Bob",
|
||||||
|
"```",
|
||||||
|
].join("\n");
|
||||||
|
const { stripped, content } = scanDoc(md, "test.md");
|
||||||
|
|
||||||
|
expect(stripped).toMatch(/^:md-table\[\.\/csv_/);
|
||||||
|
const [entry] = Object.values(content);
|
||||||
|
expect(entry.kind).toBe("csv");
|
||||||
|
expect(entry.body).toContain("d6,Name");
|
||||||
|
expect(entry.role).toBe("spark-table");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("markdown role=spark-table body is converted to CSV", () => {
|
||||||
|
const md = [
|
||||||
|
"```markdown role=spark-table",
|
||||||
|
"| d6 | Name |",
|
||||||
|
"|----|------|",
|
||||||
|
"| 1 | Alice |",
|
||||||
|
"| 2 | Bob |",
|
||||||
|
"```",
|
||||||
|
].join("\n");
|
||||||
|
const { stripped, content } = scanDoc(md, "test.md");
|
||||||
|
|
||||||
|
expect(stripped).toMatch(/^:md-table\[\.\/csv_/);
|
||||||
|
const [entry] = Object.values(content);
|
||||||
|
expect(entry.body).toBe("d6,Name\n1,Alice\n2,Bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("markdown role=spark-table with non-dice first column warns but still stores", () => {
|
||||||
|
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const md = [
|
||||||
|
"```markdown role=spark-table",
|
||||||
|
"| Name | Value |",
|
||||||
|
"|------|-------|",
|
||||||
|
"| Alice | 10 |",
|
||||||
|
"```",
|
||||||
|
].join("\n");
|
||||||
|
const { content } = scanDoc(md, "test.md");
|
||||||
|
|
||||||
|
expect(warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("is not a dice formula"),
|
||||||
|
);
|
||||||
|
const [entry] = Object.values(content);
|
||||||
|
expect(entry.body).toBe("Name,Value\nAlice,10");
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plain markdown tables are left untouched", () => {
|
||||||
|
const md = [
|
||||||
|
"| d6 | Name |",
|
||||||
|
"|----|------|",
|
||||||
|
"| 1 | Alice |",
|
||||||
|
].join("\n");
|
||||||
|
const { stripped, content } = scanDoc(md, "test.md");
|
||||||
|
|
||||||
|
expect(stripped).toBe(md);
|
||||||
|
expect(Object.keys(content)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plain markdown tables with label headers are left untouched", () => {
|
||||||
|
const md = [
|
||||||
|
"| md-table-label | body |",
|
||||||
|
"|----------------|------|",
|
||||||
|
"| 1 | text |",
|
||||||
|
].join("\n");
|
||||||
|
const { stripped, content } = scanDoc(md, "test.md");
|
||||||
|
|
||||||
|
expect(stripped).toBe(md);
|
||||||
|
expect(Object.keys(content)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tag blocks are kept intact", () => {
|
||||||
|
const md = "```yaml role=tag\ntag: md-deck\nbody: ./cards.csv\n```";
|
||||||
|
const { stripped, content } = scanDoc(md, "test.md");
|
||||||
|
|
||||||
|
expect(stripped).toBe(md);
|
||||||
|
expect(Object.keys(content)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("blocks without role are kept as code blocks", () => {
|
||||||
|
const md = "```csv\nlabel,body\n1,text\n```";
|
||||||
|
const { stripped, content } = scanDoc(md, "test.md");
|
||||||
|
|
||||||
|
expect(stripped).toBe(md);
|
||||||
|
expect(Object.keys(content)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown roles warn and strip", () => {
|
||||||
|
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const md = "```csv role=frobnicate\nlabel,body\n```";
|
||||||
|
const { stripped, content } = scanDoc(md, "test.md");
|
||||||
|
|
||||||
|
expect(stripped).toBe("");
|
||||||
|
expect(Object.keys(content)).toHaveLength(0);
|
||||||
|
expect(warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('unknown role "frobnicate"'),
|
||||||
|
);
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("legacy as= attribute is ignored", () => {
|
||||||
|
const md = [
|
||||||
|
"```csv role=spark-table as=codeblock",
|
||||||
|
"d6,Name",
|
||||||
|
"1,Alice",
|
||||||
|
"```",
|
||||||
|
].join("\n");
|
||||||
|
const { stripped } = scanDoc(md, "test.md");
|
||||||
|
|
||||||
|
// Role wins — the block still becomes a directive.
|
||||||
|
expect(stripped).toMatch(/^:md-table\[\.\/csv_/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveContent", () => {
|
||||||
|
test("resolves inline content ids", () => {
|
||||||
|
const registry = emptyRegistry();
|
||||||
|
registry.docContent["test.md"] = {
|
||||||
|
csv_abc: {
|
||||||
|
id: "csv_abc",
|
||||||
|
kind: "csv",
|
||||||
|
body: "d6,Name\n1,Alice",
|
||||||
|
role: "spark-table",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(resolveContent(registry, "test.md", "./csv_abc")).toBe(
|
||||||
|
"d6,Name\n1,Alice",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not sniff refs as inline CSV", () => {
|
||||||
|
const registry = emptyRegistry();
|
||||||
|
// A CSV-looking ref is treated as a relative path, not content.
|
||||||
|
expect(
|
||||||
|
resolveContent(registry, "test.md", "d6,Name\n1,Alice"),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveContentEntry reports inline vs file resolution", () => {
|
||||||
|
const registry = emptyRegistry();
|
||||||
|
registry.pathIndex["/test.md"] = "# Doc";
|
||||||
|
registry.pathIndex["/data/table.csv"] = "d6,Name\n1,Alice";
|
||||||
|
registry.docContent["/test.md"] = {
|
||||||
|
csv_abc: {
|
||||||
|
id: "csv_abc",
|
||||||
|
kind: "csv",
|
||||||
|
body: "d6,Name\n1,Bob",
|
||||||
|
role: "spark-table",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const inline = resolveContentEntry(registry, "/test.md", "./csv_abc");
|
||||||
|
expect(inline).toMatchObject({ path: "csv_abc", inline: true });
|
||||||
|
|
||||||
|
const file = resolveContentEntry(registry, "/test.md", "./data/table.csv");
|
||||||
|
expect(file).toMatchObject({ path: "/data/table.csv", inline: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deriveCompletions", () => {
|
||||||
|
test("headings inside fenced code blocks do not become link completions", () => {
|
||||||
|
const md = [
|
||||||
|
"# Real Heading",
|
||||||
|
"",
|
||||||
|
"```markdown",
|
||||||
|
"# Fake Heading In Code",
|
||||||
|
"```",
|
||||||
|
].join("\n");
|
||||||
|
const registry = buildRegistryFromIndex({ "test.md": md });
|
||||||
|
const { links } = deriveCompletions(registry);
|
||||||
|
|
||||||
|
const testLinks = links.filter((l) => l.path === "/test");
|
||||||
|
expect(testLinks.map((l) => l.label)).toEqual([
|
||||||
|
"test",
|
||||||
|
"test § Real Heading",
|
||||||
|
]);
|
||||||
|
expect(testLinks.some((l) => l.label.includes("Fake"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,628 @@
|
|||||||
|
/**
|
||||||
|
* Content registry — the single source of truth for all content in a
|
||||||
|
* TTRPG Tools project.
|
||||||
|
*
|
||||||
|
* Two stores:
|
||||||
|
* - `pathIndex`: real files on disk, keyed by path (`.md`, `.csv`, `.yarn`, `.svg`)
|
||||||
|
* - `docContent`: inline content *defined inside* a markdown doc, keyed by
|
||||||
|
* a stable id and owned by that doc.
|
||||||
|
*
|
||||||
|
* Everything structured (completions, declarations, tag modifiers) is a
|
||||||
|
* *derived* view over this registry — see `deriveCompletions`.
|
||||||
|
*
|
||||||
|
* This module is browser-safe (no Node-only imports) so the CLI and the
|
||||||
|
* frontend share one implementation. The filesystem walk lives in the CLI
|
||||||
|
* (`buildRegistry` in `commands/serve.ts`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Slugger from "github-slugger";
|
||||||
|
import {
|
||||||
|
parseDeclareCsv,
|
||||||
|
type VarDeclaration,
|
||||||
|
type TagModifier,
|
||||||
|
} from "./completions/declare-parser.js";
|
||||||
|
import {
|
||||||
|
FENCED_BLOCK_RE,
|
||||||
|
parseBlockAttrs,
|
||||||
|
} from "./completions/block-scanner.js";
|
||||||
|
import type {
|
||||||
|
CompletionsPayload,
|
||||||
|
DiceCompletion,
|
||||||
|
LinkCompletion,
|
||||||
|
SparkTableCompletion,
|
||||||
|
} from "./completions/types.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type ContentKind = "csv" | "text" | "declare";
|
||||||
|
|
||||||
|
/** A single piece of inline content defined inside a doc. */
|
||||||
|
export interface DocContent {
|
||||||
|
/** Stable id — author-supplied or derived from the body. */
|
||||||
|
id: string;
|
||||||
|
kind: ContentKind;
|
||||||
|
body: string;
|
||||||
|
/** Origin role that produced this content, for debugging. */
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentRegistry {
|
||||||
|
/** Real files on disk, keyed by path. */
|
||||||
|
pathIndex: Record<string, string>;
|
||||||
|
/** Inline content defined inside each doc, keyed by id. */
|
||||||
|
docContent: Record<string, Record<string, DocContent>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_REGISTRY: ContentRegistry = {
|
||||||
|
pathIndex: {},
|
||||||
|
docContent: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a file path into a stable registry key: forward slashes and a
|
||||||
|
* single leading `/`. Both the CLI filesystem walk and the browser folder
|
||||||
|
* scan funnel through this so identical files always share a key.
|
||||||
|
*/
|
||||||
|
export function normalizePathKey(path: string): string {
|
||||||
|
const normalized = path.split("\\").join("/");
|
||||||
|
return normalized.startsWith("/") ? normalized : `/${normalized}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Id / hash derivation (single source of truth)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Browser-safe content hash — stable across CLI and frontend. */
|
||||||
|
export function contentHash(body: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < body.length; i++) {
|
||||||
|
const ch = body.charCodeAt(i);
|
||||||
|
hash = ((hash << 5) - hash + ch) | 0;
|
||||||
|
}
|
||||||
|
return Math.abs(hash).toString(16).slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a stable content id.
|
||||||
|
* Author-supplied `id` wins; otherwise `{kind}_{hash}`.
|
||||||
|
*/
|
||||||
|
export function deriveContentId(
|
||||||
|
kind: ContentKind,
|
||||||
|
body: string,
|
||||||
|
id?: string,
|
||||||
|
): string {
|
||||||
|
if (id) return id;
|
||||||
|
return `${kind}_${contentHash(body)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Per-doc scanning
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface DocScanResult {
|
||||||
|
/** Content with blocks processed (stripped or replaced with directives). */
|
||||||
|
stripped: string;
|
||||||
|
/** Inline content defined in this doc, keyed by id. */
|
||||||
|
content: Record<string, DocContent>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a single markdown doc:
|
||||||
|
* - dispatches attributed fenced code blocks by `role`
|
||||||
|
* - converts `markdown`-lang `role=spark-table` bodies (pipe tables) to CSV
|
||||||
|
* - collects inline content (spark-table, declare, file) into the doc's
|
||||||
|
* content store
|
||||||
|
*
|
||||||
|
* Does NOT touch the path index — the caller assembles the registry.
|
||||||
|
*/
|
||||||
|
export function scanDoc(content: string, docPath: string): DocScanResult {
|
||||||
|
const contentStore: Record<string, DocContent> = {};
|
||||||
|
|
||||||
|
const stripped = content.replace(
|
||||||
|
FENCED_BLOCK_RE,
|
||||||
|
(
|
||||||
|
match: string,
|
||||||
|
lang: string,
|
||||||
|
infoString: string,
|
||||||
|
body: string,
|
||||||
|
): string => {
|
||||||
|
const attrs = parseBlockAttrs(infoString);
|
||||||
|
attrs.lang = attrs.lang || lang;
|
||||||
|
|
||||||
|
switch (attrs.role) {
|
||||||
|
case "declare": {
|
||||||
|
const id = deriveContentId("declare", body, attrs.id);
|
||||||
|
contentStore[id] = { id, kind: "declare", body, role: attrs.role };
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
case "file": {
|
||||||
|
const id = deriveContentId("text", body, attrs.id);
|
||||||
|
contentStore[id] = { id, kind: "text", body, role: attrs.role };
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
case "spark-table": {
|
||||||
|
let blockBody = body;
|
||||||
|
if (isMarkdownTableLang(attrs.lang)) {
|
||||||
|
blockBody = markdownTableBodyToCsv(body, docPath);
|
||||||
|
}
|
||||||
|
const id = deriveContentId("csv", blockBody, attrs.id);
|
||||||
|
contentStore[id] = {
|
||||||
|
id,
|
||||||
|
kind: "csv",
|
||||||
|
body: blockBody,
|
||||||
|
role: attrs.role,
|
||||||
|
};
|
||||||
|
|
||||||
|
const extraStr = Object.entries(attrs.extra)
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join(" ");
|
||||||
|
return `:md-table[./${id}]${extraStr ? `{${extraStr}}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "tag":
|
||||||
|
// Kept intact so the code-block-yaml-tag render extension sees it.
|
||||||
|
return match;
|
||||||
|
|
||||||
|
case undefined:
|
||||||
|
// No role declared — plain visible code block.
|
||||||
|
return match;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn(
|
||||||
|
`[content-registry] ${docPath}: unknown role "${attrs.role}" — stripping block`,
|
||||||
|
);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return { stripped, content: contentStore };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a `ContentRegistry` from a raw `{ path: content }` index.
|
||||||
|
*
|
||||||
|
* Shared by the CLI (`buildRegistry` in `commands/serve.ts`) and the browser
|
||||||
|
* folder scan (`scanClientSide` in `components/journal/completions.ts`) so
|
||||||
|
* both modes produce identical `pathIndex`/`docContent` — including the
|
||||||
|
* Shared by the CLI (`buildRegistry` in `commands/serve.ts`) and the browser
|
||||||
|
* folder scan (`scanClientSide` in `components/journal/completions.ts`) so
|
||||||
|
* both modes produce identical `pathIndex`/`docContent`.
|
||||||
|
*
|
||||||
|
* Keys are normalized via `normalizePathKey`; `.md` files are run through
|
||||||
|
* `scanDoc`, other extensions are stored raw.
|
||||||
|
*/
|
||||||
|
export function buildRegistryFromIndex(
|
||||||
|
index: Record<string, string>,
|
||||||
|
): ContentRegistry {
|
||||||
|
const registry: ContentRegistry = { pathIndex: {}, docContent: {} };
|
||||||
|
|
||||||
|
for (const [rawPath, content] of Object.entries(index)) {
|
||||||
|
const path = normalizePathKey(rawPath);
|
||||||
|
if (path.endsWith(".md")) {
|
||||||
|
const result = scanDoc(content, path);
|
||||||
|
registry.pathIndex[path] = result.stripped;
|
||||||
|
registry.docContent[path] = result.content;
|
||||||
|
} else {
|
||||||
|
registry.pathIndex[path] = content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Spark table coercion
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a table header cell is a dice formula (a "spark table" first
|
||||||
|
* column). Used to validate `role=spark-table` blocks (CSV or markdown
|
||||||
|
* pipe-table bodies).
|
||||||
|
*/
|
||||||
|
export function isSparkTableHeader(header: string): boolean {
|
||||||
|
return /^\d*d\d+(?:[+-]\d+)?$/i.test(header.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split a markdown table row into cells. */
|
||||||
|
function splitTableRow(row: string): string[] {
|
||||||
|
return row
|
||||||
|
.replace(/^\|/, "")
|
||||||
|
.replace(/\|$/, "")
|
||||||
|
.split("|")
|
||||||
|
.map((c) => c.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escape a cell value for CSV output. */
|
||||||
|
function escapeCsvCell(cell: string): string {
|
||||||
|
if (
|
||||||
|
cell.includes(",") ||
|
||||||
|
cell.includes("\n") ||
|
||||||
|
cell.includes('"') ||
|
||||||
|
cell.includes("#")
|
||||||
|
) {
|
||||||
|
return `"${cell.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a markdown table (header + separator + rows) to a CSV string. */
|
||||||
|
function markdownTableToCsv(
|
||||||
|
headerRow: string,
|
||||||
|
separatorRow: string,
|
||||||
|
bodyRows: string[],
|
||||||
|
): string | null {
|
||||||
|
const headers = splitTableRow(headerRow);
|
||||||
|
if (headers.length === 0) return null;
|
||||||
|
|
||||||
|
const sepCells = splitTableRow(separatorRow);
|
||||||
|
if (!sepCells.every((c) => /^:?-{3,}:?$/.test(c))) return null;
|
||||||
|
if (sepCells.length !== headers.length) return null;
|
||||||
|
|
||||||
|
const csvHeader = headers.map(escapeCsvCell).join(",");
|
||||||
|
const csvRows = bodyRows.map((row) => {
|
||||||
|
const cells = splitTableRow(row);
|
||||||
|
while (cells.length < headers.length) cells.push("");
|
||||||
|
return cells.slice(0, headers.length).map(escapeCsvCell).join(",");
|
||||||
|
});
|
||||||
|
|
||||||
|
return [csvHeader, ...csvRows].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Languages whose fenced-block bodies are markdown pipe tables. Used with
|
||||||
|
* `role=spark-table` to convert the table to CSV at scan time — explicitly
|
||||||
|
* authorized by the role, never by content shape.
|
||||||
|
*/
|
||||||
|
const MARKDOWN_TABLE_LANGS = new Set(["markdown", "md"]);
|
||||||
|
|
||||||
|
function isMarkdownTableLang(lang: string): boolean {
|
||||||
|
return MARKDOWN_TABLE_LANGS.has(lang.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a fenced markdown pipe-table body to CSV for `role=spark-table`
|
||||||
|
* blocks. Validates the dice-formula first column (warning only — the role
|
||||||
|
* already declared intent) and falls back to storing the body as-is when it
|
||||||
|
* is not a recognizable pipe table.
|
||||||
|
*/
|
||||||
|
function markdownTableBodyToCsv(body: string, docPath: string): string {
|
||||||
|
const lines = body
|
||||||
|
.trim()
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter((l) => l.trim().startsWith("|"));
|
||||||
|
if (lines.length < 2) {
|
||||||
|
console.warn(
|
||||||
|
`[content-registry] ${docPath}: role=spark-table markdown body is not a pipe table; storing as-is`,
|
||||||
|
);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [headerRow, separatorRow, ...rows] = lines;
|
||||||
|
const headers = splitTableRow(headerRow);
|
||||||
|
if (!isSparkTableHeader(headers[0] || "")) {
|
||||||
|
console.warn(
|
||||||
|
`[content-registry] ${docPath}: spark table first column "${headers[0]}" is not a dice formula`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return markdownTableToCsv(headerRow, separatorRow, rows) ?? body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Resolution
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A resolved content reference: the body plus how it was addressed.
|
||||||
|
*/
|
||||||
|
export interface ResolvedContent {
|
||||||
|
body: string;
|
||||||
|
/** Content id for inline content, path-index key for real files. */
|
||||||
|
path: string;
|
||||||
|
/** True when resolved from the doc's inline content store. */
|
||||||
|
inline: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a content reference from within a doc.
|
||||||
|
*
|
||||||
|
* - `ref` is an absolute path → path index.
|
||||||
|
* - `ref` is a relative path → resolved against the doc directory; checks
|
||||||
|
* the doc's inline content store first, then the path index.
|
||||||
|
*
|
||||||
|
* Refs are always ids or paths — inline content must be defined in a fenced
|
||||||
|
* block and referenced by its `./{id}`.
|
||||||
|
*
|
||||||
|
* Returns `null` when nothing matches.
|
||||||
|
*/
|
||||||
|
export function resolveContentEntry(
|
||||||
|
registry: ContentRegistry,
|
||||||
|
docPath: string,
|
||||||
|
ref: string,
|
||||||
|
): ResolvedContent | null {
|
||||||
|
const trimmed = ref.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
if (trimmed.startsWith("/")) {
|
||||||
|
const body = registry.pathIndex[trimmed];
|
||||||
|
return body == null ? null : { body, path: trimmed, inline: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline content in the same doc (e.g. ./{id}).
|
||||||
|
const docStore = registry.docContent[docPath];
|
||||||
|
const id = trimmed.startsWith("./") ? trimmed.slice(2) : trimmed;
|
||||||
|
const entry = docStore?.[id];
|
||||||
|
if (entry) return { body: entry.body, path: id, inline: true };
|
||||||
|
|
||||||
|
// Relative path resolved against the doc directory (`./` already stripped).
|
||||||
|
const resolved = posixJoin(posixDir(docPath), id);
|
||||||
|
const body = registry.pathIndex[resolved];
|
||||||
|
return body != null ? { body, path: resolved, inline: false } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a content reference to its body only. */
|
||||||
|
export function resolveContent(
|
||||||
|
registry: ContentRegistry,
|
||||||
|
docPath: string,
|
||||||
|
ref: string,
|
||||||
|
): string | null {
|
||||||
|
return resolveContentEntry(registry, docPath, ref)?.body ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a *resolved* path (e.g. `/content/csv_abc123`) to inline content
|
||||||
|
* by searching every doc's content store for a matching id. Used by the
|
||||||
|
* frontend when a directive ref has already been resolved to a path.
|
||||||
|
*
|
||||||
|
* Returns `null` when no inline content matches.
|
||||||
|
*/
|
||||||
|
export function resolveInlineByPath(
|
||||||
|
registry: ContentRegistry,
|
||||||
|
resolvedPath: string,
|
||||||
|
): string | null {
|
||||||
|
const id = resolvedPath.split("/").filter(Boolean).pop() || "";
|
||||||
|
if (!id) return null;
|
||||||
|
for (const store of Object.values(registry.docContent)) {
|
||||||
|
const entry = store[id];
|
||||||
|
if (entry) return entry.body;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Derived completions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the completions payload from the registry.
|
||||||
|
* Pure function — recompute on any file change.
|
||||||
|
*
|
||||||
|
* Dice + spark completions come from scanning each doc's stripped content
|
||||||
|
* for directives (resolving CSV refs through the registry), so both inline
|
||||||
|
* and real-file spark tables are covered. Declarations come from the doc
|
||||||
|
* content store.
|
||||||
|
*/
|
||||||
|
export function deriveCompletions(
|
||||||
|
registry: ContentRegistry,
|
||||||
|
): CompletionsPayload {
|
||||||
|
const links = deriveLinks(registry.pathIndex);
|
||||||
|
const dice: DiceCompletion[] = [];
|
||||||
|
const sparkTables: SparkTableCompletion[] = [];
|
||||||
|
const declarations: VarDeclaration[] = [];
|
||||||
|
const tagModifiers: TagModifier[] = [];
|
||||||
|
|
||||||
|
for (const [docPath, content] of Object.entries(registry.pathIndex)) {
|
||||||
|
if (!docPath.endsWith(".md")) continue;
|
||||||
|
const found = scanDocDirectives(content, docPath, registry);
|
||||||
|
dice.push(...found.dice);
|
||||||
|
sparkTables.push(...found.sparkTables);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks = deriveBlocks(registry);
|
||||||
|
declarations.push(...blocks.declarations);
|
||||||
|
tagModifiers.push(...blocks.tagModifiers);
|
||||||
|
|
||||||
|
return { dice, links, sparkTables, declarations, tagModifiers };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan a doc's stripped content for `:md-dice` and `:md-table`/`:md-card`
|
||||||
|
* directives, resolving CSV refs through the registry.
|
||||||
|
*/
|
||||||
|
function scanDocDirectives(
|
||||||
|
content: string,
|
||||||
|
docPath: string,
|
||||||
|
registry: ContentRegistry,
|
||||||
|
): { dice: DiceCompletion[]; sparkTables: SparkTableCompletion[] } {
|
||||||
|
const dice = scanDice(content, docPath);
|
||||||
|
const sparkTables: SparkTableCompletion[] = [];
|
||||||
|
|
||||||
|
const tableDirectiveRegex = /:md-(table|card)\[([^\[\]]+)\](?:\{([^}]*)\})?/gi;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = tableDirectiveRegex.exec(content)) !== null) {
|
||||||
|
const [, , ref, extraStr] = m;
|
||||||
|
const resolved = resolveContentEntry(registry, docPath, ref);
|
||||||
|
if (!resolved) continue;
|
||||||
|
|
||||||
|
const attrs = parseBlockAttrs(extraStr || "").extra;
|
||||||
|
const st = buildSparkTableCompletion(
|
||||||
|
resolved.body,
|
||||||
|
docPath,
|
||||||
|
resolved.path,
|
||||||
|
attrs["remix"] === "true",
|
||||||
|
);
|
||||||
|
if (st) sparkTables.push(st);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dice, sparkTables };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derive variable declarations + tag modifiers from the registry. */
|
||||||
|
export function deriveBlocks(registry: ContentRegistry): {
|
||||||
|
declarations: VarDeclaration[];
|
||||||
|
tagModifiers: TagModifier[];
|
||||||
|
} {
|
||||||
|
const declarations: VarDeclaration[] = [];
|
||||||
|
const tagModifiers: TagModifier[] = [];
|
||||||
|
|
||||||
|
for (const [docPath, store] of Object.entries(registry.docContent)) {
|
||||||
|
for (const entry of Object.values(store)) {
|
||||||
|
if (entry.kind !== "declare") continue;
|
||||||
|
try {
|
||||||
|
const result = parseDeclareCsv(entry.body, docPath);
|
||||||
|
declarations.push(...result.variables);
|
||||||
|
tagModifiers.push(...result.tagModifiers);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[content-registry] ${docPath}: ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { declarations, tagModifiers };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Derivation helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove fenced code blocks (backtick or tilde) from content, so text
|
||||||
|
* scanners (headings, dice directives) don't match example code.
|
||||||
|
*/
|
||||||
|
function stripFencedBlocks(content: string): string {
|
||||||
|
const out: string[] = [];
|
||||||
|
let fence: string | null = null;
|
||||||
|
for (const line of content.split(/\r?\n/)) {
|
||||||
|
const fenceMatch = /^(`{3,}|~{3,})/.exec(line);
|
||||||
|
if (fence) {
|
||||||
|
if (line.startsWith(fence)) fence = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fenceMatch) {
|
||||||
|
fence = fenceMatch[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(line);
|
||||||
|
}
|
||||||
|
return out.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract headings from all `.md` files as link completions. */
|
||||||
|
function deriveLinks(pathIndex: Record<string, string>): LinkCompletion[] {
|
||||||
|
const items: LinkCompletion[] = [];
|
||||||
|
for (const [filePath, rawContent] of Object.entries(pathIndex)) {
|
||||||
|
if (!filePath.endsWith(".md")) continue;
|
||||||
|
|
||||||
|
// Headings inside fenced code blocks (e.g. markdown examples) are not
|
||||||
|
// real headings — exclude them from link completions.
|
||||||
|
const content = stripFencedBlocks(rawContent);
|
||||||
|
const basePath = filePath.replace(/\.md$/, "");
|
||||||
|
const fileName = fileNameFromPath(basePath);
|
||||||
|
const slugger = new Slugger();
|
||||||
|
|
||||||
|
items.push({ path: basePath, label: fileName, section: null });
|
||||||
|
|
||||||
|
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = headingRegex.exec(content)) !== null) {
|
||||||
|
const title = match[2].trim();
|
||||||
|
const id = slugger.slug(title.toLowerCase());
|
||||||
|
items.push({
|
||||||
|
path: basePath,
|
||||||
|
label: `${fileName} § ${title}`,
|
||||||
|
section: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DICE_DIRECTIVE_RE = /:md-dice\[([^[\]]+)\]/gi;
|
||||||
|
|
||||||
|
function looksLikeDice(raw: string): boolean {
|
||||||
|
if (raw.length > 80) return false;
|
||||||
|
return /^\d*d\d+/i.test(raw) || /^[+-]/.test(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scan a text body for `:md-dice[...]` directives. */
|
||||||
|
function scanDice(body: string, source: string): DiceCompletion[] {
|
||||||
|
const dice: DiceCompletion[] = [];
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = DICE_DIRECTIVE_RE.exec(body)) !== null) {
|
||||||
|
const raw = m[1].trim();
|
||||||
|
if (!raw || !looksLikeDice(raw)) continue;
|
||||||
|
dice.push({ label: raw, notation: raw, source });
|
||||||
|
}
|
||||||
|
return dice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inspect a CSV body and return its data-column headers if it's a spark
|
||||||
|
* table (first column header is a dice formula), or null otherwise.
|
||||||
|
*/
|
||||||
|
export function inspectSparkTableCsv(csv: string): string[] | null {
|
||||||
|
const lines = csv.trim().split(/\r?\n/);
|
||||||
|
if (lines.length < 2) return null;
|
||||||
|
|
||||||
|
const headers = lines[0].split(",").map((h) => h.trim());
|
||||||
|
if (headers.length < 2) return null;
|
||||||
|
if (!isSparkTableHeader(headers[0])) return null;
|
||||||
|
|
||||||
|
return headers.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a SparkTableCompletion from a CSV body. */
|
||||||
|
export function buildSparkTableCompletion(
|
||||||
|
csv: string,
|
||||||
|
docPath: string,
|
||||||
|
contentId: string,
|
||||||
|
remix: boolean,
|
||||||
|
): SparkTableCompletion | null {
|
||||||
|
const dataHeaders = inspectSparkTableCsv(csv);
|
||||||
|
if (!dataHeaders) return null;
|
||||||
|
|
||||||
|
const notation = csv.trim().split(/\r?\n/)[0].split(",")[0].trim();
|
||||||
|
const slugger = new Slugger();
|
||||||
|
const slug = dataHeaders.map((h) => slugger.slug(h.toLowerCase())).join("-");
|
||||||
|
|
||||||
|
const basePath = docPath.replace(/\.md$/, "");
|
||||||
|
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
|
||||||
|
const combinedSlug = `${fileName}-${slug}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: `${fileName} § ${slug}`,
|
||||||
|
notation,
|
||||||
|
slug: combinedSlug,
|
||||||
|
filePath: basePath,
|
||||||
|
docPath,
|
||||||
|
csvPath: contentId,
|
||||||
|
headers: dataHeaders,
|
||||||
|
remix,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Path helpers (browser-safe posix)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function posixDir(path: string): string {
|
||||||
|
const idx = path.lastIndexOf("/");
|
||||||
|
return idx >= 0 ? path.slice(0, idx) : ".";
|
||||||
|
}
|
||||||
|
|
||||||
|
function posixJoin(dir: string, rel: string): string {
|
||||||
|
if (dir === ".") return rel.startsWith("/") ? rel : `/${rel}`;
|
||||||
|
const base = dir.replace(/\/+$/, "");
|
||||||
|
const r = rel.replace(/^\/+/, "");
|
||||||
|
return `${base}/${r}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileNameFromPath(path: string): string {
|
||||||
|
const parts = path.split("/").filter(Boolean);
|
||||||
|
return parts[parts.length - 1] || path;
|
||||||
|
}
|
||||||
+36
-20
@@ -1,33 +1,49 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { Command } from 'commander';
|
import { Command } from "commander";
|
||||||
import { serveCommand } from './commands/serve.js';
|
import { serveCommand } from "./commands/serve.js";
|
||||||
import { compileCommand } from './commands/compile.js';
|
import { compileCommand } from "./commands/compile.js";
|
||||||
import { mcpCommand } from './commands/mcp.js';
|
import { mcpCommand } from "./commands/mcp.js";
|
||||||
import type { ServeOptions, CompileOptions } from './types.js';
|
import type { ServeOptions, CompileOptions } from "./types.js";
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
program
|
program
|
||||||
.name('ttrpg')
|
.name("tttk")
|
||||||
.description('TTRPG 工具箱 - 用于编译和预览 TTRPG 文档')
|
.description(
|
||||||
.version('0.0.1');
|
"TTRPG 内容预览服务器。不带参数运行时默认启动当前目录的预览服务。",
|
||||||
|
)
|
||||||
|
.version("0.0.1");
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('serve')
|
.command("serve")
|
||||||
.description('运行一个 web 服务器预览目录中的内容,并实时监听更新')
|
.description("启动 web 服务器预览目录中的内容,并实时监听文件更新")
|
||||||
.argument('[dir]', '要预览的目录', '.')
|
.argument("[dir]", "要预览的目录", ".")
|
||||||
.option('-p, --port <port>', '端口号', '3000')
|
.option("-p, --port <port>", "HTTP 端口号", "3000")
|
||||||
.option('-h, --host <host>', '主机地址', '0.0.0.0')
|
.option("--host <host>", "主机地址", "0.0.0.0")
|
||||||
.action(serveCommand);
|
.action(serveCommand);
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('compile')
|
.command("compile")
|
||||||
.description('将目录中的内容输出为带 hash 路由、单个 html 入口的 web 应用')
|
.description("[实验性] 将目录内容编译为带 hash 路由的单页 Web 应用 (开发中)")
|
||||||
.argument('[dir]', '要编译的目录', '.')
|
.argument("[dir]", "要编译的目录", ".")
|
||||||
.option('-o, --output <dir>', '输出目录', './dist/output')
|
.option("-o, --output <dir>", "输出目录", "./dist/output")
|
||||||
.action(compileCommand);
|
.action(compileCommand);
|
||||||
|
|
||||||
program
|
program.addCommand(mcpCommand);
|
||||||
.addCommand(mcpCommand);
|
|
||||||
|
|
||||||
program.parse();
|
// 无子命令时默认执行 serve(即 `npx tttk` 等同于 `npx tttk serve .`)
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
if (args.length > 0) {
|
||||||
|
const first = args[0];
|
||||||
|
const isKnownCommand =
|
||||||
|
!first.startsWith("-") &&
|
||||||
|
program.commands.some(
|
||||||
|
(c) => c.name() === first || c.aliases().includes(first),
|
||||||
|
);
|
||||||
|
if (!isKnownCommand) {
|
||||||
|
args.unshift("serve");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
args.unshift("serve");
|
||||||
|
}
|
||||||
|
program.parse([process.argv[0], process.argv[1], ...args]);
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
/**
|
||||||
|
* 日志流持久化 — MQTT 代理 + JSONL 追加写入 + 会话清单
|
||||||
|
*
|
||||||
|
* 在现有 HTTP 服务器上挂载 aedes MQTT 代理(WebSocket 传输),
|
||||||
|
* 使用 ws v8 内置的 createWebSocketStream。
|
||||||
|
*
|
||||||
|
* 实现参考 aedes 文档:
|
||||||
|
* https://github.com/moscajs/aedes#mqtt-server-over-websocket
|
||||||
|
*
|
||||||
|
* 持久化使用 aedes 内部 subscribe/publish API,无需服务器进程
|
||||||
|
* 以 MQTT 客户端身份连接自身。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { join, resolve } from "path";
|
||||||
|
import {
|
||||||
|
appendFileSync,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
rmdirSync,
|
||||||
|
unlinkSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "fs";
|
||||||
|
import type { Server as HttpServer } from "http";
|
||||||
|
import type { AedesPublishPacket } from "aedes";
|
||||||
|
import { WebSocketServer, createWebSocketStream } from "ws";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 类型定义
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface SessionMeta {
|
||||||
|
name: string;
|
||||||
|
created: number;
|
||||||
|
players: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionManifest {
|
||||||
|
sessions: Record<string, SessionMeta>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JournalServer {
|
||||||
|
broker: import("aedes").Aedes;
|
||||||
|
wsServer: import("ws").WebSocketServer;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 路径工具函数
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function dataDir(contentDir: string): string {
|
||||||
|
const d = resolve(contentDir, ".ttrpg");
|
||||||
|
mkdirSync(d, { recursive: true });
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function manifestPath(dataRoot: string): string {
|
||||||
|
return join(dataRoot, "manifest.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionDir(dataRoot: string, id: string): string {
|
||||||
|
return join(dataRoot, "sessions", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamPath(dataRoot: string, id: string): string {
|
||||||
|
return join(sessionDir(dataRoot, id), "stream.jsonl");
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadManifest(dataRoot: string): SessionManifest {
|
||||||
|
const p = manifestPath(dataRoot);
|
||||||
|
if (!existsSync(p)) return { sessions: {} };
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(p, "utf-8"));
|
||||||
|
} catch {
|
||||||
|
return { sessions: {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveManifest(dataRoot: string, m: SessionManifest): void {
|
||||||
|
writeFileSync(manifestPath(dataRoot), JSON.stringify(m, null, 2), "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendStream(dataRoot: string, id: string, line: string): void {
|
||||||
|
const sd = sessionDir(dataRoot, id);
|
||||||
|
mkdirSync(sd, { recursive: true });
|
||||||
|
appendFileSync(streamPath(dataRoot, id), line + "\n", "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 服务器工厂
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const $SESSIONS = "ttrpg/$SESSIONS";
|
||||||
|
|
||||||
|
export async function createJournalServer(
|
||||||
|
contentDir: string,
|
||||||
|
httpServer: HttpServer,
|
||||||
|
): Promise<JournalServer> {
|
||||||
|
const root = dataDir(contentDir);
|
||||||
|
console.log(`[日志] 数据目录:${root}`);
|
||||||
|
|
||||||
|
// ---- MQTT 代理 ----
|
||||||
|
const { Aedes: AedesFactory } = await import("aedes");
|
||||||
|
// aedes 需要先 listen() 初始化持久化,再处理连接
|
||||||
|
const broker = await AedesFactory.createBroker();
|
||||||
|
|
||||||
|
// ---- WebSocket 服务器(挂载到现有 HTTP 服务器上) ----
|
||||||
|
// 参考 aedes 文档:给 WSS 传入 { server },使用 'connection' 事件
|
||||||
|
const wss = new WebSocketServer({ server: httpServer });
|
||||||
|
|
||||||
|
wss.on("connection", (ws, req) => {
|
||||||
|
const stream = createWebSocketStream(ws);
|
||||||
|
broker.handle(stream, req);
|
||||||
|
});
|
||||||
|
|
||||||
|
broker.on("client", (client: import("aedes").Client) => {
|
||||||
|
console.log(`[日志] 客户端已连接:${client.id}`);
|
||||||
|
});
|
||||||
|
broker.on("clientDisconnect", (client: import("aedes").Client) => {
|
||||||
|
console.log(`[日志] 客户端已断开:${client.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 持久化(内部订阅,无需独立 MQTT 客户端) ----
|
||||||
|
broker.subscribe(
|
||||||
|
"ttrpg/+/stream",
|
||||||
|
(packet, cb) => {
|
||||||
|
const sessionId = extractSessionId(packet.topic);
|
||||||
|
if (sessionId) {
|
||||||
|
try {
|
||||||
|
appendStream(root, sessionId, packet.payload.toString());
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[日志] 会话 ${sessionId} 流记录写入失败:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cb();
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
|
||||||
|
broker.subscribe(
|
||||||
|
"ttrpg/+/meta",
|
||||||
|
(packet, cb) => {
|
||||||
|
const sessionId = extractSessionId(packet.topic);
|
||||||
|
if (sessionId) {
|
||||||
|
handleMetaChange(root, sessionId, broker, packet.payload.toString());
|
||||||
|
}
|
||||||
|
cb();
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 启动时发布初始清单,让客户端能看到已有会话
|
||||||
|
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("[日志] 持久化监听已启动");
|
||||||
|
console.log("[日志] MQTT 代理已就绪:ws://<host>:<port>");
|
||||||
|
|
||||||
|
return {
|
||||||
|
broker,
|
||||||
|
wsServer: wss,
|
||||||
|
async close() {
|
||||||
|
console.log("[日志] 正在关闭...");
|
||||||
|
await new Promise<void>((r) => wss.close(() => r()));
|
||||||
|
await new Promise<void>((r) => broker.close(() => r()));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 工具函数
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function extractSessionId(topic: string): string | null {
|
||||||
|
const parts = topic.split("/");
|
||||||
|
return parts.length >= 3 ? parts[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 会话生命周期管理
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function handleMetaChange(
|
||||||
|
root: string,
|
||||||
|
sessionId: string,
|
||||||
|
broker: import("aedes").Aedes,
|
||||||
|
rawPayload: string,
|
||||||
|
): void {
|
||||||
|
const manifest = loadManifest(root);
|
||||||
|
try {
|
||||||
|
const meta = JSON.parse(rawPayload) as SessionMeta | null;
|
||||||
|
|
||||||
|
if (!meta || !meta.name) {
|
||||||
|
if (manifest.sessions[sessionId]) {
|
||||||
|
delete manifest.sessions[sessionId];
|
||||||
|
const sp = streamPath(root, sessionId);
|
||||||
|
if (existsSync(sp)) unlinkSync(sp);
|
||||||
|
const sd = sessionDir(root, sessionId);
|
||||||
|
try {
|
||||||
|
rmdirSync(sd);
|
||||||
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
|
console.log(`[日志] 会话已删除:${sessionId}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
manifest.sessions[sessionId] = {
|
||||||
|
name: meta.name,
|
||||||
|
created: meta.created || Date.now(),
|
||||||
|
players: meta.players || [],
|
||||||
|
};
|
||||||
|
console.log(`[日志] 会话已更新:${sessionId} (${meta.name})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveManifest(root, manifest);
|
||||||
|
broker.publish(
|
||||||
|
{
|
||||||
|
topic: $SESSIONS,
|
||||||
|
payload: Buffer.from(JSON.stringify(manifest)),
|
||||||
|
qos: 1,
|
||||||
|
retain: true,
|
||||||
|
cmd: "publish" as const,
|
||||||
|
dup: false,
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[日志] 会话 ${sessionId} 元数据解析失败:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||||
import { parse } from 'csv-parse/browser/esm/sync';
|
import { parse } from "csv-parse/browser/esm/sync";
|
||||||
import { stringify } from 'csv-stringify/browser/esm/sync';
|
import { stringify } from "csv-stringify/browser/esm/sync";
|
||||||
import yaml from 'js-yaml';
|
import type { DeckFrontmatter } from "../frontmatter/read-frontmatter.js";
|
||||||
import type { DeckFrontmatter } from '../frontmatter/read-frontmatter.js';
|
import {
|
||||||
|
parseFrontMatter,
|
||||||
|
serializeFrontMatter,
|
||||||
|
} from "../frontmatter/shared.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 卡牌数据
|
* 卡牌数据
|
||||||
@@ -23,7 +26,7 @@ export interface CardCrudParams {
|
|||||||
/**
|
/**
|
||||||
* 操作类型
|
* 操作类型
|
||||||
*/
|
*/
|
||||||
action: 'create' | 'read' | 'update' | 'delete';
|
action: "create" | "read" | "update" | "delete";
|
||||||
/**
|
/**
|
||||||
* 卡牌数据(单张或数组)
|
* 卡牌数据(单张或数组)
|
||||||
*/
|
*/
|
||||||
@@ -44,41 +47,6 @@ export interface CardCrudResult {
|
|||||||
count?: number;
|
count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析 CSV 文件的 frontmatter
|
|
||||||
*/
|
|
||||||
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
|
|
||||||
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
|
|
||||||
|
|
||||||
if (parts.length !== 3 || parts[0] !== '') {
|
|
||||||
return { csvContent: content };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const frontmatterStr = parts[1].trim();
|
|
||||||
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
|
|
||||||
const csvContent = parts.slice(2).join('---\n').trimStart();
|
|
||||||
return { frontmatter, csvContent };
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to parse front matter:', error);
|
|
||||||
return { csvContent: content };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 序列化 frontmatter 为 YAML 字符串
|
|
||||||
*/
|
|
||||||
function serializeFrontMatter(frontmatter: DeckFrontmatter): string {
|
|
||||||
const yamlStr = yaml.dump(frontmatter, {
|
|
||||||
indent: 2,
|
|
||||||
lineWidth: -1,
|
|
||||||
noRefs: true,
|
|
||||||
quotingType: '"',
|
|
||||||
forceQuotes: false
|
|
||||||
});
|
|
||||||
return `---\n${yamlStr}---\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载 CSV 数据(包含 frontmatter)
|
* 加载 CSV 数据(包含 frontmatter)
|
||||||
*/
|
*/
|
||||||
@@ -87,19 +55,19 @@ function loadCSVWithFrontmatter(filePath: string): {
|
|||||||
records: CardData[];
|
records: CardData[];
|
||||||
headers: string[];
|
headers: string[];
|
||||||
} {
|
} {
|
||||||
const content = readFileSync(filePath, 'utf-8');
|
const content = readFileSync(filePath, "utf-8");
|
||||||
const { frontmatter, csvContent } = parseFrontMatter(content);
|
const { frontmatter, csvContent } = parseFrontMatter(content);
|
||||||
|
|
||||||
const records = parse(csvContent, {
|
const records = parse(csvContent, {
|
||||||
columns: true,
|
columns: true,
|
||||||
comment: '#',
|
comment: "#",
|
||||||
trim: true,
|
trim: true,
|
||||||
skipEmptyLines: true
|
skipEmptyLines: true,
|
||||||
}) as CardData[];
|
}) as CardData[];
|
||||||
|
|
||||||
// 获取表头
|
// 获取表头
|
||||||
const firstLine = csvContent.split('\n')[0];
|
const firstLine = csvContent.split("\n")[0];
|
||||||
const headers = firstLine.split(',').map(h => h.trim());
|
const headers = firstLine.split(",").map((h) => h.trim());
|
||||||
|
|
||||||
return { frontmatter, records, headers };
|
return { frontmatter, records, headers };
|
||||||
}
|
}
|
||||||
@@ -111,30 +79,30 @@ function saveCSVWithFrontmatter(
|
|||||||
filePath: string,
|
filePath: string,
|
||||||
frontmatter: DeckFrontmatter | undefined,
|
frontmatter: DeckFrontmatter | undefined,
|
||||||
records: CardData[],
|
records: CardData[],
|
||||||
headers?: string[]
|
headers?: string[],
|
||||||
): void {
|
): void {
|
||||||
// 序列化 frontmatter
|
// 序列化 frontmatter
|
||||||
const frontmatterStr = frontmatter ? serializeFrontMatter(frontmatter) : '';
|
const frontmatterStr = frontmatter ? serializeFrontMatter(frontmatter) : "";
|
||||||
|
|
||||||
// 确定表头
|
// 确定表头
|
||||||
if (!headers || headers.length === 0) {
|
if (!headers || headers.length === 0) {
|
||||||
// 从 records 和 frontmatter.fields 推断表头
|
// 从 records 和 frontmatter.fields 推断表头
|
||||||
headers = ['label'];
|
headers = ["label"];
|
||||||
if (frontmatter?.fields && Array.isArray(frontmatter.fields)) {
|
if (frontmatter?.fields && Array.isArray(frontmatter.fields)) {
|
||||||
for (const field of frontmatter.fields) {
|
for (const field of frontmatter.fields) {
|
||||||
if (field.name && typeof field.name === 'string') {
|
if (field.name && typeof field.name === "string") {
|
||||||
headers.push(field.name);
|
headers.push(field.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
headers.push('body');
|
headers.push("body");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保所有 record 都有 headers 中的列
|
// 确保所有 record 都有 headers 中的列
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
for (const header of headers) {
|
for (const header of headers) {
|
||||||
if (!(header in record)) {
|
if (!(header in record)) {
|
||||||
record[header] = '';
|
record[header] = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,11 +110,11 @@ function saveCSVWithFrontmatter(
|
|||||||
// 序列化 CSV
|
// 序列化 CSV
|
||||||
const csvContent = stringify(records, {
|
const csvContent = stringify(records, {
|
||||||
header: true,
|
header: true,
|
||||||
columns: headers
|
columns: headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 写入文件
|
// 写入文件
|
||||||
writeFileSync(filePath, frontmatterStr + csvContent, 'utf-8');
|
writeFileSync(filePath, frontmatterStr + csvContent, "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -167,10 +135,10 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
|
|||||||
const { csv_file, action, cards, label } = params;
|
const { csv_file, action, cards, label } = params;
|
||||||
|
|
||||||
// 检查文件是否存在(create 操作可以不存在)
|
// 检查文件是否存在(create 操作可以不存在)
|
||||||
if (action !== 'create' && !existsSync(csv_file)) {
|
if (action !== "create" && !existsSync(csv_file)) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: `文件不存在:${csv_file}`
|
message: `文件不存在:${csv_file}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,8 +157,8 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
|
|||||||
|
|
||||||
// 执行操作
|
// 执行操作
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'create': {
|
case "create": {
|
||||||
const newCards = Array.isArray(cards) ? cards : (cards ? [cards] : []);
|
const newCards = Array.isArray(cards) ? cards : cards ? [cards] : [];
|
||||||
for (const card of newCards) {
|
for (const card of newCards) {
|
||||||
if (!card.label) {
|
if (!card.label) {
|
||||||
card.label = generateNextLabel(records);
|
card.label = generateNextLabel(records);
|
||||||
@@ -202,16 +170,22 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
|
|||||||
success: true,
|
success: true,
|
||||||
message: `成功创建 ${newCards.length} 张卡牌`,
|
message: `成功创建 ${newCards.length} 张卡牌`,
|
||||||
cards: newCards,
|
cards: newCards,
|
||||||
count: newCards.length
|
count: newCards.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'read': {
|
case "read": {
|
||||||
const labelsToRead = Array.isArray(label) ? label : (label ? [label] : null);
|
const labelsToRead = Array.isArray(label)
|
||||||
|
? label
|
||||||
|
: label
|
||||||
|
? [label]
|
||||||
|
: null;
|
||||||
let resultCards: CardData[];
|
let resultCards: CardData[];
|
||||||
|
|
||||||
if (labelsToRead && labelsToRead.length > 0) {
|
if (labelsToRead && labelsToRead.length > 0) {
|
||||||
resultCards = records.filter(r => labelsToRead.includes(r.label || ''));
|
resultCards = records.filter((r) =>
|
||||||
|
labelsToRead.includes(r.label || ""),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
resultCards = records;
|
resultCards = records;
|
||||||
}
|
}
|
||||||
@@ -220,20 +194,26 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
|
|||||||
success: true,
|
success: true,
|
||||||
message: `成功读取 ${resultCards.length} 张卡牌`,
|
message: `成功读取 ${resultCards.length} 张卡牌`,
|
||||||
cards: resultCards,
|
cards: resultCards,
|
||||||
count: resultCards.length
|
count: resultCards.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'update': {
|
case "update": {
|
||||||
const labelsToUpdate = Array.isArray(label) ? label : (label ? [label] : null);
|
const labelsToUpdate = Array.isArray(label)
|
||||||
const updateCards = Array.isArray(cards) ? cards : (cards ? [cards] : []);
|
? label
|
||||||
|
: label
|
||||||
|
? [label]
|
||||||
|
: null;
|
||||||
|
const updateCards = Array.isArray(cards) ? cards : cards ? [cards] : [];
|
||||||
let updatedCount = 0;
|
let updatedCount = 0;
|
||||||
|
|
||||||
if (labelsToUpdate && labelsToUpdate.length > 0) {
|
if (labelsToUpdate && labelsToUpdate.length > 0) {
|
||||||
// 按 label 更新
|
// 按 label 更新
|
||||||
for (const updateCard of updateCards) {
|
for (const updateCard of updateCards) {
|
||||||
const targetLabel = updateCard.label || labelsToUpdate[updatedCount % labelsToUpdate.length];
|
const targetLabel =
|
||||||
const index = records.findIndex(r => r.label === targetLabel);
|
updateCard.label ||
|
||||||
|
labelsToUpdate[updatedCount % labelsToUpdate.length];
|
||||||
|
const index = records.findIndex((r) => r.label === targetLabel);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
records[index] = { ...records[index], ...updateCard };
|
records[index] = { ...records[index], ...updateCard };
|
||||||
updatedCount++;
|
updatedCount++;
|
||||||
@@ -243,7 +223,9 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
|
|||||||
// 按 cards 中的 label 更新
|
// 按 cards 中的 label 更新
|
||||||
for (const updateCard of updateCards) {
|
for (const updateCard of updateCards) {
|
||||||
if (updateCard.label) {
|
if (updateCard.label) {
|
||||||
const index = records.findIndex(r => r.label === updateCard.label);
|
const index = records.findIndex(
|
||||||
|
(r) => r.label === updateCard.label,
|
||||||
|
);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
records[index] = { ...records[index], ...updateCard };
|
records[index] = { ...records[index], ...updateCard };
|
||||||
updatedCount++;
|
updatedCount++;
|
||||||
@@ -257,23 +239,29 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
|
|||||||
success: true,
|
success: true,
|
||||||
message: `成功更新 ${updatedCount} 张卡牌`,
|
message: `成功更新 ${updatedCount} 张卡牌`,
|
||||||
cards: updateCards,
|
cards: updateCards,
|
||||||
count: updatedCount
|
count: updatedCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'delete': {
|
case "delete": {
|
||||||
const labelsToDelete = Array.isArray(label) ? label : (label ? [label] : null);
|
const labelsToDelete = Array.isArray(label)
|
||||||
|
? label
|
||||||
|
: label
|
||||||
|
? [label]
|
||||||
|
: null;
|
||||||
let deletedCount = 0;
|
let deletedCount = 0;
|
||||||
|
|
||||||
if (labelsToDelete && labelsToDelete.length > 0) {
|
if (labelsToDelete && labelsToDelete.length > 0) {
|
||||||
const beforeCount = records.length;
|
const beforeCount = records.length;
|
||||||
records = records.filter(r => !labelsToDelete.includes(r.label || ''));
|
records = records.filter(
|
||||||
|
(r) => !labelsToDelete.includes(r.label || ""),
|
||||||
|
);
|
||||||
deletedCount = beforeCount - records.length;
|
deletedCount = beforeCount - records.length;
|
||||||
} else if (cards) {
|
} else if (cards) {
|
||||||
const cardsToDelete = Array.isArray(cards) ? cards : [cards];
|
const cardsToDelete = Array.isArray(cards) ? cards : [cards];
|
||||||
const beforeCount = records.length;
|
const beforeCount = records.length;
|
||||||
records = records.filter(r =>
|
records = records.filter(
|
||||||
!cardsToDelete.some(c => c.label && r.label === c.label)
|
(r) => !cardsToDelete.some((c) => c.label && r.label === c.label),
|
||||||
);
|
);
|
||||||
deletedCount = beforeCount - records.length;
|
deletedCount = beforeCount - records.length;
|
||||||
}
|
}
|
||||||
@@ -282,20 +270,20 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `成功删除 ${deletedCount} 张卡牌`,
|
message: `成功删除 ${deletedCount} 张卡牌`,
|
||||||
count: deletedCount
|
count: deletedCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: `未知操作:${action}`
|
message: `未知操作:${action}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: `操作失败:${error instanceof Error ? error.message : '未知错误'}`
|
message: `操作失败:${error instanceof Error ? error.message : "未知错误"}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* Shared deck utility functions — used by generate-card-deck and preview-deck.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { dirname, join, basename, extname, relative } from "path";
|
||||||
|
import type {
|
||||||
|
DeckConfig,
|
||||||
|
DeckFrontmatter,
|
||||||
|
} from "./frontmatter/read-frontmatter.js";
|
||||||
|
import { parseFrontMatter } from "./frontmatter/shared.js";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a `:md-deck[path]{attrs}` directive string from a CSV path and config.
|
||||||
|
*/
|
||||||
|
export function buildDeckComponent(
|
||||||
|
csvPath: string,
|
||||||
|
config?: DeckConfig,
|
||||||
|
): string {
|
||||||
|
const parts = [`:md-deck[${csvPath}]`];
|
||||||
|
const attrs: string[] = [];
|
||||||
|
|
||||||
|
if (config?.size) {
|
||||||
|
attrs.push(`size="${config.size}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.grid) {
|
||||||
|
attrs.push(`grid="${config.grid}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.bleed !== undefined && config.bleed !== 1) {
|
||||||
|
attrs.push(`bleed="${config.bleed}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.padding !== undefined && config.padding !== 2) {
|
||||||
|
attrs.push(`padding="${config.padding}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.shape && config.shape !== "rectangle") {
|
||||||
|
attrs.push(`shape="${config.shape}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.layers) {
|
||||||
|
attrs.push(`layers="${config.layers}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.back_layers) {
|
||||||
|
attrs.push(`back-layers="${config.back_layers}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.length > 0) {
|
||||||
|
parts.push(`{${attrs.join(" ")}}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a human-readable title from a CSV filename.
|
||||||
|
* e.g. "my-deck.csv" → "My Deck"
|
||||||
|
*/
|
||||||
|
export function titleFromCsvPath(csvPath: string): string {
|
||||||
|
const name = basename(csvPath, extname(csvPath));
|
||||||
|
return name.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the relative path from a markdown file to its CSV.
|
||||||
|
* Handles the case where they are in the same or different directories.
|
||||||
|
*/
|
||||||
|
export function relativeCsvPath(
|
||||||
|
mdFilePath: string,
|
||||||
|
csvFilePath: string,
|
||||||
|
): string {
|
||||||
|
const mdDir = dirname(mdFilePath);
|
||||||
|
const rel = relative(mdDir, csvFilePath);
|
||||||
|
return rel.startsWith(".") ? rel : `./${rel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the default markdown file path for a CSV.
|
||||||
|
* e.g. "foo/bar.csv" → "foo/bar.md"
|
||||||
|
*/
|
||||||
|
export function defaultMdPath(csvFilePath: string): string {
|
||||||
|
const dir = dirname(csvFilePath);
|
||||||
|
const name = basename(csvFilePath, extname(csvFilePath));
|
||||||
|
return join(dir, `${name}.md`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read DeckConfig from a CSV file's frontmatter, if present.
|
||||||
|
*/
|
||||||
|
export function readDeckConfig(csvFilePath: string): DeckConfig | undefined {
|
||||||
|
try {
|
||||||
|
const content = readFileSync(csvFilePath, "utf-8");
|
||||||
|
const { frontmatter } = parseFrontMatter(content);
|
||||||
|
return frontmatter?.deck;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the standard markdown content for a deck preview file.
|
||||||
|
*/
|
||||||
|
export function generateDeckPreviewMd(
|
||||||
|
deckTitle: string,
|
||||||
|
deckComponent: string,
|
||||||
|
description?: string,
|
||||||
|
): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
lines.push(`# ${deckTitle}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
if (description) {
|
||||||
|
lines.push(description);
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("## 卡牌预览");
|
||||||
|
lines.push("");
|
||||||
|
lines.push(deckComponent);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("## 使用说明");
|
||||||
|
lines.push("");
|
||||||
|
lines.push("- 点击卡牌可以查看详情");
|
||||||
|
lines.push("- 使用右上角的按钮可以随机抽取卡牌");
|
||||||
|
lines.push("- 可以通过编辑面板调整卡牌样式和布局");
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
||||||
import { dirname, join, basename, extname } from 'path';
|
|
||||||
import yaml from 'js-yaml';
|
|
||||||
import type { DeckFrontmatter, DeckConfig } from './frontmatter/read-frontmatter.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 确保 Deck 预览文件的参数
|
|
||||||
*/
|
|
||||||
export interface EnsureDeckPreviewParams {
|
|
||||||
/**
|
|
||||||
* CSV 文件路径
|
|
||||||
*/
|
|
||||||
csv_file: string;
|
|
||||||
/**
|
|
||||||
* Markdown 文件路径(可选,默认与 CSV 同名)
|
|
||||||
*/
|
|
||||||
md_file?: string;
|
|
||||||
/**
|
|
||||||
* 标题(可选,默认从 CSV 文件名推断)
|
|
||||||
*/
|
|
||||||
title?: string;
|
|
||||||
/**
|
|
||||||
* 描述(可选)
|
|
||||||
*/
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 确保 Deck 预览文件的结果
|
|
||||||
*/
|
|
||||||
export interface EnsureDeckPreviewResult {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
md_file?: string;
|
|
||||||
created?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析 CSV 文件的 frontmatter
|
|
||||||
*/
|
|
||||||
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
|
|
||||||
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
|
|
||||||
|
|
||||||
if (parts.length !== 3 || parts[0] !== '') {
|
|
||||||
return { csvContent: content };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const frontmatterStr = parts[1].trim();
|
|
||||||
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
|
|
||||||
const csvContent = parts.slice(2).join('---\n').trimStart();
|
|
||||||
return { frontmatter, csvContent };
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to parse front matter:', error);
|
|
||||||
return { csvContent: content };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 构建 md-deck 组件代码
|
|
||||||
*/
|
|
||||||
function buildDeckComponent(csvPath: string, config?: DeckConfig): string {
|
|
||||||
const parts = [`:md-deck[${csvPath}]`];
|
|
||||||
const attrs: string[] = [];
|
|
||||||
|
|
||||||
if (config?.size) {
|
|
||||||
attrs.push(`size="${config.size}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config?.grid) {
|
|
||||||
attrs.push(`grid="${config.grid}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config?.bleed !== undefined && config.bleed !== 1) {
|
|
||||||
attrs.push(`bleed="${config.bleed}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config?.padding !== undefined && config.padding !== 2) {
|
|
||||||
attrs.push(`padding="${config.padding}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config?.shape && config.shape !== 'rectangle') {
|
|
||||||
attrs.push(`shape="${config.shape}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config?.layers) {
|
|
||||||
attrs.push(`layers="${config.layers}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config?.back_layers) {
|
|
||||||
attrs.push(`back-layers="${config.back_layers}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attrs.length > 0) {
|
|
||||||
parts.push(`{${attrs.join(' ')}}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 确保 Markdown 预览文件存在
|
|
||||||
*/
|
|
||||||
export function ensureDeckPreview(params: EnsureDeckPreviewParams): EnsureDeckPreviewResult {
|
|
||||||
const { csv_file, md_file, title, description } = params;
|
|
||||||
|
|
||||||
// 检查 CSV 文件是否存在
|
|
||||||
if (!existsSync(csv_file)) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: `CSV 文件不存在:${csv_file}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确定 Markdown 文件路径
|
|
||||||
let mdFilePath = md_file;
|
|
||||||
if (!mdFilePath) {
|
|
||||||
// 使用与 CSV 相同的路径和文件名,只是扩展名不同
|
|
||||||
const dir = dirname(csv_file);
|
|
||||||
const name = basename(csv_file, extname(csv_file));
|
|
||||||
mdFilePath = join(dir, `${name}.md`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const created = !existsSync(mdFilePath);
|
|
||||||
|
|
||||||
// 如果文件已存在,检查是否已有 md-deck 组件
|
|
||||||
if (!created) {
|
|
||||||
try {
|
|
||||||
const existingContent = readFileSync(mdFilePath, 'utf-8');
|
|
||||||
// 如果已有 md-deck 组件引用该 CSV,直接返回
|
|
||||||
if (existingContent.includes(`:md-deck[${csv_file}]`) ||
|
|
||||||
existingContent.includes(`:md-deck[./${basename(csv_file)}]`)) {
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `预览文件 ${mdFilePath} 已存在`,
|
|
||||||
md_file: mdFilePath,
|
|
||||||
created: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// 读取失败,继续创建
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取 CSV 的 frontmatter 获取配置
|
|
||||||
let deckConfig: DeckConfig | undefined;
|
|
||||||
try {
|
|
||||||
const csvContent = readFileSync(csv_file, 'utf-8');
|
|
||||||
const { frontmatter } = parseFrontMatter(csvContent);
|
|
||||||
deckConfig = frontmatter?.deck;
|
|
||||||
} catch (error) {
|
|
||||||
// 忽略错误,使用默认配置
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确定标题
|
|
||||||
let deckTitle = title;
|
|
||||||
if (!deckTitle) {
|
|
||||||
// 从 CSV 文件名推断
|
|
||||||
const name = basename(csv_file, extname(csv_file));
|
|
||||||
deckTitle = name.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建 md-deck 组件代码(使用相对路径)
|
|
||||||
const mdDir = dirname(mdFilePath);
|
|
||||||
const csvDir = dirname(csv_file);
|
|
||||||
let relativeCsvPath: string;
|
|
||||||
|
|
||||||
if (mdDir === csvDir) {
|
|
||||||
relativeCsvPath = `./${basename(csv_file)}`;
|
|
||||||
} else {
|
|
||||||
relativeCsvPath = `./${basename(csv_file)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deckComponent = buildDeckComponent(relativeCsvPath, deckConfig);
|
|
||||||
|
|
||||||
// 生成 Markdown 内容
|
|
||||||
const mdLines: string[] = [];
|
|
||||||
|
|
||||||
mdLines.push(`# ${deckTitle}`);
|
|
||||||
mdLines.push('');
|
|
||||||
|
|
||||||
if (description) {
|
|
||||||
mdLines.push(description);
|
|
||||||
mdLines.push('');
|
|
||||||
}
|
|
||||||
|
|
||||||
mdLines.push('## 卡牌预览');
|
|
||||||
mdLines.push('');
|
|
||||||
mdLines.push(deckComponent);
|
|
||||||
mdLines.push('');
|
|
||||||
|
|
||||||
mdLines.push('## 使用说明');
|
|
||||||
mdLines.push('');
|
|
||||||
mdLines.push('- 点击卡牌可以查看详情');
|
|
||||||
mdLines.push('- 使用右上角的按钮可以随机抽取卡牌');
|
|
||||||
mdLines.push('- 可以通过编辑面板调整卡牌样式和布局');
|
|
||||||
mdLines.push('');
|
|
||||||
|
|
||||||
// 写入文件
|
|
||||||
try {
|
|
||||||
writeFileSync(mdFilePath, mdLines.join('\n'), 'utf-8');
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: created
|
|
||||||
? `创建预览文件 ${mdFilePath}`
|
|
||||||
: `更新预览文件 ${mdFilePath}`,
|
|
||||||
md_file: mdFilePath,
|
|
||||||
created
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: `写入失败:${error instanceof Error ? error.message : '未知错误'}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { readFileSync, existsSync } from 'fs';
|
import { readFileSync, existsSync } from "fs";
|
||||||
import yaml from 'js-yaml';
|
import { parseFrontMatter } from "./shared.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 读取 CSV frontmatter 的参数
|
* 读取 CSV frontmatter 的参数
|
||||||
@@ -52,7 +52,7 @@ export interface CardFieldStyle {
|
|||||||
/**
|
/**
|
||||||
* 朝向:上侧朝向 "n" | "w" | "s" | "e"(北/西/南/东)
|
* 朝向:上侧朝向 "n" | "w" | "s" | "e"(北/西/南/东)
|
||||||
*/
|
*/
|
||||||
up?: 'n' | 'w' | 's' | 'e';
|
up?: "n" | "w" | "s" | "e";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,7 +74,7 @@ export interface DeckConfig {
|
|||||||
grid?: string;
|
grid?: string;
|
||||||
bleed?: number;
|
bleed?: number;
|
||||||
padding?: number;
|
padding?: number;
|
||||||
shape?: 'rectangle' | 'circle' | 'hex' | 'diamond';
|
shape?: "rectangle" | "circle" | "hex" | "diamond";
|
||||||
layers?: string;
|
layers?: string;
|
||||||
back_layers?: string;
|
back_layers?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
@@ -89,45 +89,25 @@ export interface ReadFrontmatterResult {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析 CSV 文件的 frontmatter
|
|
||||||
*/
|
|
||||||
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
|
|
||||||
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
|
|
||||||
|
|
||||||
// 至少需要三个部分:空字符串、front matter、CSV 内容
|
|
||||||
if (parts.length !== 3 || parts[0] !== '') {
|
|
||||||
return { csvContent: content };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const frontmatterStr = parts[1].trim();
|
|
||||||
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
|
|
||||||
const csvContent = parts.slice(2).join('---\n').trimStart();
|
|
||||||
return { frontmatter, csvContent };
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to parse front matter:', error);
|
|
||||||
return { csvContent: content };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 读取 CSV 文件的 frontmatter
|
* 读取 CSV 文件的 frontmatter
|
||||||
*/
|
*/
|
||||||
export function readFrontmatter(params: ReadFrontmatterParams): ReadFrontmatterResult {
|
export function readFrontmatter(
|
||||||
|
params: ReadFrontmatterParams,
|
||||||
|
): ReadFrontmatterResult {
|
||||||
const { csv_file } = params;
|
const { csv_file } = params;
|
||||||
|
|
||||||
// 检查文件是否存在
|
// 检查文件是否存在
|
||||||
if (!existsSync(csv_file)) {
|
if (!existsSync(csv_file)) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: `文件不存在:${csv_file}`
|
message: `文件不存在:${csv_file}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 读取文件内容
|
// 读取文件内容
|
||||||
const content = readFileSync(csv_file, 'utf-8');
|
const content = readFileSync(csv_file, "utf-8");
|
||||||
|
|
||||||
// 解析 frontmatter
|
// 解析 frontmatter
|
||||||
const { frontmatter } = parseFrontMatter(content);
|
const { frontmatter } = parseFrontMatter(content);
|
||||||
@@ -136,19 +116,19 @@ export function readFrontmatter(params: ReadFrontmatterParams): ReadFrontmatterR
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
frontmatter: {},
|
frontmatter: {},
|
||||||
message: `文件 ${csv_file} 没有 frontmatter,返回空对象`
|
message: `文件 ${csv_file} 没有 frontmatter,返回空对象`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
frontmatter,
|
frontmatter,
|
||||||
message: `成功读取 ${csv_file} 的 frontmatter`
|
message: `成功读取 ${csv_file} 的 frontmatter`,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: `读取失败:${error instanceof Error ? error.message : '未知错误'}`
|
message: `读取失败:${error instanceof Error ? error.message : "未知错误"}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Shared frontmatter utilities — used by read-frontmatter, write-frontmatter,
|
||||||
|
* card-crud, preview-deck, and generate-card-deck.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import yaml from "js-yaml";
|
||||||
|
import type { DeckFrontmatter } from "./read-frontmatter.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse YAML frontmatter from a CSV file's raw content.
|
||||||
|
* Expects `---\n...\n---\n` delimited frontmatter at the top of the file.
|
||||||
|
*/
|
||||||
|
export function parseFrontMatter(content: string): {
|
||||||
|
frontmatter?: DeckFrontmatter;
|
||||||
|
csvContent: string;
|
||||||
|
} {
|
||||||
|
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
|
||||||
|
|
||||||
|
if (parts.length !== 3 || parts[0] !== "") {
|
||||||
|
return { csvContent: content };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const frontmatterStr = parts[1].trim();
|
||||||
|
const frontmatter = yaml.load(frontmatterStr) as
|
||||||
|
| DeckFrontmatter
|
||||||
|
| undefined;
|
||||||
|
const csvContent = parts.slice(2).join("---\n").trimStart();
|
||||||
|
return { frontmatter, csvContent };
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to parse front matter:", error);
|
||||||
|
return { csvContent: content };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a DeckFrontmatter object to YAML frontmatter string.
|
||||||
|
*/
|
||||||
|
export function serializeFrontMatter(frontmatter: DeckFrontmatter): string {
|
||||||
|
const yamlStr = yaml.dump(frontmatter, {
|
||||||
|
indent: 2,
|
||||||
|
lineWidth: -1,
|
||||||
|
noRefs: true,
|
||||||
|
quotingType: '"',
|
||||||
|
forceQuotes: false,
|
||||||
|
});
|
||||||
|
return `---\n${yamlStr}---\n`;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||||
import yaml from 'js-yaml';
|
import type { DeckFrontmatter } from "./read-frontmatter.js";
|
||||||
import type { DeckFrontmatter } from './read-frontmatter.js';
|
import { parseFrontMatter, serializeFrontMatter } from "./shared.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 写入 CSV frontmatter 的参数
|
* 写入 CSV frontmatter 的参数
|
||||||
@@ -29,68 +29,35 @@ export interface WriteFrontmatterResult {
|
|||||||
csv_file?: string;
|
csv_file?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析 CSV 文件的 frontmatter
|
|
||||||
*/
|
|
||||||
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
|
|
||||||
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
|
|
||||||
|
|
||||||
// 至少需要三个部分:空字符串、front matter、CSV 内容
|
|
||||||
if (parts.length !== 3 || parts[0] !== '') {
|
|
||||||
return { csvContent: content };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const frontmatterStr = parts[1].trim();
|
|
||||||
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
|
|
||||||
const csvContent = parts.slice(2).join('---\n').trimStart();
|
|
||||||
return { frontmatter, csvContent };
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to parse front matter:', error);
|
|
||||||
return { csvContent: content };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 序列化 frontmatter 为 YAML 字符串
|
|
||||||
*/
|
|
||||||
function serializeFrontMatter(frontmatter: DeckFrontmatter): string {
|
|
||||||
const yamlStr = yaml.dump(frontmatter, {
|
|
||||||
indent: 2,
|
|
||||||
lineWidth: -1, // 不限制行宽
|
|
||||||
noRefs: true, // 不使用引用
|
|
||||||
quotingType: '"',
|
|
||||||
forceQuotes: false
|
|
||||||
});
|
|
||||||
return `---\n${yamlStr}---\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 写入/更新 CSV 文件的 frontmatter
|
* 写入/更新 CSV 文件的 frontmatter
|
||||||
*/
|
*/
|
||||||
export function writeFrontmatter(params: WriteFrontmatterParams): WriteFrontmatterResult {
|
export function writeFrontmatter(
|
||||||
|
params: WriteFrontmatterParams,
|
||||||
|
): WriteFrontmatterResult {
|
||||||
const { csv_file, frontmatter, merge = true } = params;
|
const { csv_file, frontmatter, merge = true } = params;
|
||||||
|
|
||||||
let csvContent = '';
|
let csvContent = "";
|
||||||
let existingFrontmatter: DeckFrontmatter | undefined;
|
let existingFrontmatter: DeckFrontmatter | undefined;
|
||||||
|
|
||||||
// 如果文件存在且需要合并,先读取现有内容
|
// 如果文件存在且需要合并,先读取现有内容
|
||||||
if (merge && existsSync(csv_file)) {
|
if (merge && existsSync(csv_file)) {
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(csv_file, 'utf-8');
|
const content = readFileSync(csv_file, "utf-8");
|
||||||
const result = parseFrontMatter(content);
|
const result = parseFrontMatter(content);
|
||||||
existingFrontmatter = result.frontmatter;
|
existingFrontmatter = result.frontmatter;
|
||||||
csvContent = result.csvContent;
|
csvContent = result.csvContent;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: `读取现有文件失败:${error instanceof Error ? error.message : '未知错误'}`
|
message: `读取现有文件失败:${error instanceof Error ? error.message : "未知错误"}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 合并或替换 frontmatter
|
// 合并或替换 frontmatter
|
||||||
const finalFrontmatter = merge && existingFrontmatter
|
const finalFrontmatter =
|
||||||
|
merge && existingFrontmatter
|
||||||
? { ...existingFrontmatter, ...frontmatter }
|
? { ...existingFrontmatter, ...frontmatter }
|
||||||
: frontmatter;
|
: frontmatter;
|
||||||
|
|
||||||
@@ -100,32 +67,32 @@ export function writeFrontmatter(params: WriteFrontmatterParams): WriteFrontmatt
|
|||||||
// 如果文件不存在或没有 CSV 内容,创建一个空的 CSV 内容
|
// 如果文件不存在或没有 CSV 内容,创建一个空的 CSV 内容
|
||||||
if (!csvContent.trim()) {
|
if (!csvContent.trim()) {
|
||||||
// 从 frontmatter 推断 CSV 表头
|
// 从 frontmatter 推断 CSV 表头
|
||||||
const headers = ['label'];
|
const headers = ["label"];
|
||||||
if (finalFrontmatter.fields && Array.isArray(finalFrontmatter.fields)) {
|
if (finalFrontmatter.fields && Array.isArray(finalFrontmatter.fields)) {
|
||||||
for (const field of finalFrontmatter.fields) {
|
for (const field of finalFrontmatter.fields) {
|
||||||
if (field.name && typeof field.name === 'string') {
|
if (field.name && typeof field.name === "string") {
|
||||||
headers.push(field.name);
|
headers.push(field.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
headers.push('body');
|
headers.push("body");
|
||||||
csvContent = headers.join(',') + '\n';
|
csvContent = headers.join(",") + "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 写入文件
|
// 写入文件
|
||||||
try {
|
try {
|
||||||
const fullContent = frontmatterStr + csvContent;
|
const fullContent = frontmatterStr + csvContent;
|
||||||
writeFileSync(csv_file, fullContent, 'utf-8');
|
writeFileSync(csv_file, fullContent, "utf-8");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `成功写入 frontmatter 到 ${csv_file}`,
|
message: `成功写入 frontmatter 到 ${csv_file}`,
|
||||||
csv_file
|
csv_file,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: `写入失败:${error instanceof Error ? error.message : '未知错误'}`
|
message: `写入失败:${error instanceof Error ? error.message : "未知错误"}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { writeFileSync, mkdirSync, existsSync } from 'fs';
|
import { writeFileSync, mkdirSync, existsSync } from "fs";
|
||||||
import { join } from 'path';
|
import { join } from "path";
|
||||||
|
import { buildDeckComponent, generateDeckPreviewMd } from "./deck-utils.js";
|
||||||
|
import type { DeckConfig } from "./frontmatter/read-frontmatter.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 字段样式配置
|
* 字段样式配置
|
||||||
@@ -24,7 +26,7 @@ export interface CardFieldStyle {
|
|||||||
/**
|
/**
|
||||||
* 朝向:上侧朝向 "n" | "w" | "s" | "e"(北/西/南/东)
|
* 朝向:上侧朝向 "n" | "w" | "s" | "e"(北/西/南/东)
|
||||||
*/
|
*/
|
||||||
up?: 'n' | 'w' | 's' | 'e';
|
up?: "n" | "w" | "s" | "e";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,18 +47,7 @@ export interface CardTemplate {
|
|||||||
examples?: Record<string, string>[];
|
examples?: Record<string, string>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export type { DeckConfig };
|
||||||
* Deck 配置
|
|
||||||
*/
|
|
||||||
export interface DeckConfig {
|
|
||||||
size?: string;
|
|
||||||
grid?: string;
|
|
||||||
bleed?: number;
|
|
||||||
padding?: number;
|
|
||||||
shape?: 'rectangle' | 'circle' | 'hex' | 'diamond';
|
|
||||||
layers?: string;
|
|
||||||
backLayers?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成卡牌组的参数
|
* 生成卡牌组的参数
|
||||||
@@ -73,14 +64,11 @@ export interface GenerateCardDeckParams {
|
|||||||
/**
|
/**
|
||||||
* 生成卡牌数据 CSV
|
* 生成卡牌数据 CSV
|
||||||
*/
|
*/
|
||||||
function generateCardCSV(
|
function generateCardCSV(template: CardTemplate, cardCount: number): string {
|
||||||
template: CardTemplate,
|
|
||||||
cardCount: number
|
|
||||||
): string {
|
|
||||||
const fields = template.fields;
|
const fields = template.fields;
|
||||||
|
|
||||||
// 构建 CSV 表头
|
// 构建 CSV 表头
|
||||||
const headers = ['label', ...fields.map(f => f.name), 'body'];
|
const headers = ["label", ...fields.map((f) => f.name), "body"];
|
||||||
|
|
||||||
// 生成示例数据
|
// 生成示例数据
|
||||||
const rows: string[][] = [];
|
const rows: string[][] = [];
|
||||||
@@ -91,13 +79,16 @@ function generateCardCSV(
|
|||||||
|
|
||||||
// 为每个字段生成值
|
// 为每个字段生成值
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
let value = '';
|
let value = "";
|
||||||
|
|
||||||
if (examples.length > 0) {
|
if (examples.length > 0) {
|
||||||
// 从示例中循环取值
|
// 从示例中循环取值
|
||||||
const exampleIndex = i % examples.length;
|
const exampleIndex = i % examples.length;
|
||||||
const example = examples[exampleIndex];
|
const example = examples[exampleIndex];
|
||||||
value = example[field.name] || field.examples?.[i % (field.examples?.length || 1)] || '';
|
value =
|
||||||
|
example[field.name] ||
|
||||||
|
field.examples?.[i % (field.examples?.length || 1)] ||
|
||||||
|
"";
|
||||||
} else if (field.examples && field.examples.length > 0) {
|
} else if (field.examples && field.examples.length > 0) {
|
||||||
// 从字段的示例中取值
|
// 从字段的示例中取值
|
||||||
value = field.examples[i % field.examples.length];
|
value = field.examples[i % field.examples.length];
|
||||||
@@ -114,117 +105,35 @@ function generateCardCSV(
|
|||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
bodyParts.push(`**${field.name}:** {{${field.name}}}`);
|
bodyParts.push(`**${field.name}:** {{${field.name}}}`);
|
||||||
}
|
}
|
||||||
row.push(bodyParts.join('\n\n'));
|
row.push(bodyParts.join("\n\n"));
|
||||||
|
|
||||||
rows.push(row);
|
rows.push(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 组合 CSV 内容
|
// 组合 CSV 内容
|
||||||
const csvLines = [headers.join(',')];
|
const csvLines = [headers.join(",")];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
csvLines.push(row.map(cell => {
|
csvLines.push(
|
||||||
|
row
|
||||||
|
.map((cell) => {
|
||||||
// 处理包含逗号或换行的单元格
|
// 处理包含逗号或换行的单元格
|
||||||
if (cell.includes(',') || cell.includes('\n') || cell.includes('"')) {
|
if (cell.includes(",") || cell.includes("\n") || cell.includes('"')) {
|
||||||
return `"${cell.replace(/"/g, '""')}"`;
|
return `"${cell.replace(/"/g, '""')}"`;
|
||||||
}
|
}
|
||||||
return cell;
|
return cell;
|
||||||
}).join(','));
|
})
|
||||||
|
.join(","),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return csvLines.join('\n');
|
return csvLines.join("\n");
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成卡牌介绍的 Markdown 文件
|
|
||||||
*/
|
|
||||||
function generateDeckMarkdown(
|
|
||||||
deckName: string,
|
|
||||||
csvFileName: string,
|
|
||||||
deckConfig: DeckConfig,
|
|
||||||
description?: string
|
|
||||||
): string {
|
|
||||||
const mdLines: string[] = [];
|
|
||||||
|
|
||||||
// 标题
|
|
||||||
mdLines.push(`# ${deckName}`);
|
|
||||||
mdLines.push('');
|
|
||||||
|
|
||||||
// 描述
|
|
||||||
if (description) {
|
|
||||||
mdLines.push(description);
|
|
||||||
mdLines.push('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 卡牌预览组件
|
|
||||||
mdLines.push('## 卡牌预览');
|
|
||||||
mdLines.push('');
|
|
||||||
|
|
||||||
// 构建 :md-deck 组件代码
|
|
||||||
const deckComponent = buildDeckComponent(csvFileName, deckConfig);
|
|
||||||
mdLines.push(deckComponent);
|
|
||||||
mdLines.push('');
|
|
||||||
|
|
||||||
// 使用说明
|
|
||||||
mdLines.push('## 使用说明');
|
|
||||||
mdLines.push('');
|
|
||||||
mdLines.push('- 点击卡牌可以查看详情');
|
|
||||||
mdLines.push('- 使用右上角的按钮可以随机抽取卡牌');
|
|
||||||
mdLines.push('- 可以通过编辑面板调整卡牌样式和布局');
|
|
||||||
mdLines.push('');
|
|
||||||
|
|
||||||
return mdLines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 构建 :md-deck 组件代码
|
|
||||||
*/
|
|
||||||
function buildDeckComponent(
|
|
||||||
csvFileName: string,
|
|
||||||
config: DeckConfig
|
|
||||||
): string {
|
|
||||||
const parts = [`:md-deck[${csvFileName}]`];
|
|
||||||
const attrs: string[] = [];
|
|
||||||
|
|
||||||
if (config.size) {
|
|
||||||
attrs.push(`size="${config.size}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.grid) {
|
|
||||||
attrs.push(`grid="${config.grid}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.bleed !== undefined && config.bleed !== 1) {
|
|
||||||
attrs.push(`bleed="${config.bleed}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.padding !== undefined && config.padding !== 2) {
|
|
||||||
attrs.push(`padding="${config.padding}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.shape && config.shape !== 'rectangle') {
|
|
||||||
attrs.push(`shape="${config.shape}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.layers) {
|
|
||||||
attrs.push(`layers="${config.layers}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.backLayers) {
|
|
||||||
attrs.push(`back-layers="${config.backLayers}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attrs.length > 0) {
|
|
||||||
parts.push(`{${attrs.join(' ')}}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.join('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 自动生成图层配置
|
* 自动生成图层配置
|
||||||
*/
|
*/
|
||||||
function autoGenerateLayers(fields: CardField[]): string {
|
function autoGenerateLayers(fields: CardField[]): string {
|
||||||
if (fields.length === 0) return '';
|
if (fields.length === 0) return "";
|
||||||
|
|
||||||
const layers: string[] = [];
|
const layers: string[] = [];
|
||||||
const totalHeight = 8;
|
const totalHeight = 8;
|
||||||
@@ -238,7 +147,7 @@ function autoGenerateLayers(fields: CardField[]): string {
|
|||||||
layers.push(`${field.name}:1,${y1}-${y2},${fontSize}`);
|
layers.push(`${field.name}:1,${y1}-${y2},${fontSize}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return layers.join(' ');
|
return layers.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -256,7 +165,7 @@ export function generateCardDeck(params: GenerateCardDeckParams): {
|
|||||||
card_count = 10,
|
card_count = 10,
|
||||||
card_template,
|
card_template,
|
||||||
deck_config = {},
|
deck_config = {},
|
||||||
description
|
description,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
// 确保输出目录存在
|
// 确保输出目录存在
|
||||||
@@ -265,52 +174,57 @@ export function generateCardDeck(params: GenerateCardDeckParams): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 生成文件名
|
// 生成文件名
|
||||||
const safeName = deck_name.toLowerCase().replace(/\s+/g, '-');
|
const safeName = deck_name.toLowerCase().replace(/\s+/g, "-");
|
||||||
const csvFileName = `${safeName}.csv`;
|
const csvFileName = `${safeName}.csv`;
|
||||||
const mdFileName = `${safeName}.md`;
|
const mdFileName = `${safeName}.md`;
|
||||||
|
|
||||||
// 创建默认模板(如果没有提供)
|
// 创建默认模板(如果没有提供)
|
||||||
const template: CardTemplate = card_template || {
|
const template: CardTemplate = card_template || {
|
||||||
fields: [
|
fields: [
|
||||||
{ name: 'name', description: '卡牌名称', examples: ['示例卡牌 1', '示例卡牌 2'] },
|
{
|
||||||
{ name: 'type', description: '卡牌类型', examples: ['物品', '法术'] },
|
name: "name",
|
||||||
{ name: 'cost', description: '费用', examples: ['1', '2'] },
|
description: "卡牌名称",
|
||||||
{ name: 'description', description: '效果描述', examples: ['这是一个效果描述', '这是另一个效果'] }
|
examples: ["示例卡牌 1", "示例卡牌 2"],
|
||||||
]
|
},
|
||||||
|
{ name: "type", description: "卡牌类型", examples: ["物品", "法术"] },
|
||||||
|
{ name: "cost", description: "费用", examples: ["1", "2"] },
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
description: "效果描述",
|
||||||
|
examples: ["这是一个效果描述", "这是另一个效果"],
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
// 创建默认配置(如果没有提供)
|
// 创建默认配置(如果没有提供)
|
||||||
const config: DeckConfig = {
|
const config: DeckConfig = {
|
||||||
size: deck_config.size || '54x86',
|
size: deck_config.size || "54x86",
|
||||||
grid: deck_config.grid || '5x8',
|
grid: deck_config.grid || "5x8",
|
||||||
bleed: deck_config.bleed ?? 1,
|
bleed: deck_config.bleed ?? 1,
|
||||||
padding: deck_config.padding ?? 2,
|
padding: deck_config.padding ?? 2,
|
||||||
shape: deck_config.shape || 'rectangle',
|
shape: deck_config.shape || "rectangle",
|
||||||
layers: deck_config.layers || autoGenerateLayers(template.fields)
|
layers: deck_config.layers || autoGenerateLayers(template.fields),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 生成 CSV 内容
|
// 生成 CSV 内容
|
||||||
const csvContent = generateCardCSV(template, card_count);
|
const csvContent = generateCardCSV(template, card_count);
|
||||||
const csvPath = join(output_dir, csvFileName);
|
const csvPath = join(output_dir, csvFileName);
|
||||||
writeFileSync(csvPath, csvContent, 'utf-8');
|
writeFileSync(csvPath, csvContent, "utf-8");
|
||||||
|
|
||||||
// 生成 Markdown 内容
|
// 生成 Markdown 内容
|
||||||
const mdContent = generateDeckMarkdown(
|
const deckComponent = buildDeckComponent(`./${csvFileName}`, config);
|
||||||
|
const mdContent = generateDeckPreviewMd(
|
||||||
deck_name,
|
deck_name,
|
||||||
`./${csvFileName}`,
|
deckComponent,
|
||||||
config,
|
description,
|
||||||
description
|
|
||||||
);
|
);
|
||||||
const mdPath = join(output_dir, mdFileName);
|
const mdPath = join(output_dir, mdFileName);
|
||||||
writeFileSync(mdPath, mdContent, 'utf-8');
|
writeFileSync(mdPath, mdContent, "utf-8");
|
||||||
|
|
||||||
// 构建完整的 deck 组件代码
|
|
||||||
const deckComponent = buildDeckComponent(`./${csvFileName}`, config);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mdFile: mdPath,
|
mdFile: mdPath,
|
||||||
csvFile: csvPath,
|
csvFile: csvPath,
|
||||||
deckComponent,
|
deckComponent,
|
||||||
message: `已生成卡牌组 "${deck_name}":\n- Markdown 文件:${mdPath}\n- CSV 数据:${csvPath}\n- 组件代码:${deckComponent}`
|
message: `已生成卡牌组 "${deck_name}":\n- Markdown 文件:${mdPath}\n- CSV 数据:${csvPath}\n- 组件代码:${deckComponent}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-148
@@ -1,8 +1,13 @@
|
|||||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||||
import { dirname, join, basename, extname, relative } from 'path';
|
import { relative } from "path";
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from "child_process";
|
||||||
import yaml from 'js-yaml';
|
import {
|
||||||
import type { DeckFrontmatter, DeckConfig } from './frontmatter/read-frontmatter.js';
|
buildDeckComponent,
|
||||||
|
titleFromCsvPath,
|
||||||
|
defaultMdPath,
|
||||||
|
readDeckConfig,
|
||||||
|
generateDeckPreviewMd,
|
||||||
|
} from "./deck-utils.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 预览 Deck 的参数
|
* 预览 Deck 的参数
|
||||||
@@ -37,88 +42,35 @@ export interface PreviewDeckResult {
|
|||||||
preview_url?: string;
|
preview_url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析 CSV 文件的 frontmatter
|
|
||||||
*/
|
|
||||||
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
|
|
||||||
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
|
|
||||||
|
|
||||||
if (parts.length !== 3 || parts[0] !== '') {
|
|
||||||
return { csvContent: content };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const frontmatterStr = parts[1].trim();
|
|
||||||
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
|
|
||||||
const csvContent = parts.slice(2).join('---\n').trimStart();
|
|
||||||
return { frontmatter, csvContent };
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to parse front matter:', error);
|
|
||||||
return { csvContent: content };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 构建 md-deck 组件代码
|
|
||||||
*/
|
|
||||||
function buildDeckComponent(csvPath: string, config?: DeckConfig): string {
|
|
||||||
const parts = [`:md-deck[${csvPath}]`];
|
|
||||||
const attrs: string[] = [];
|
|
||||||
|
|
||||||
if (config?.size) {
|
|
||||||
attrs.push(`size="${config.size}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config?.grid) {
|
|
||||||
attrs.push(`grid="${config.grid}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config?.bleed !== undefined && config.bleed !== 1) {
|
|
||||||
attrs.push(`bleed="${config.bleed}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config?.padding !== undefined && config.padding !== 2) {
|
|
||||||
attrs.push(`padding="${config.padding}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config?.shape && config.shape !== 'rectangle') {
|
|
||||||
attrs.push(`shape="${config.shape}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config?.layers) {
|
|
||||||
attrs.push(`layers="${config.layers}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config?.back_layers) {
|
|
||||||
attrs.push(`back-layers="${config.back_layers}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attrs.length > 0) {
|
|
||||||
parts.push(`{${attrs.join(' ')}}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 打开浏览器
|
* 打开浏览器
|
||||||
*/
|
*/
|
||||||
function openBrowser(url: string) {
|
function openBrowser(url: string) {
|
||||||
try {
|
try {
|
||||||
// Windows 使用 start 命令
|
if (process.platform === "win32") {
|
||||||
if (process.platform === 'win32') {
|
execSync(`start "" "${url}"`, { stdio: "ignore" });
|
||||||
execSync(`start "" "${url}"`, { stdio: 'ignore' });
|
} else if (process.platform === "darwin") {
|
||||||
} else if (process.platform === 'darwin') {
|
execSync(`open "${url}"`, { stdio: "ignore" });
|
||||||
execSync(`open "${url}"`, { stdio: 'ignore' });
|
|
||||||
} else {
|
} else {
|
||||||
execSync(`xdg-open "${url}"`, { stdio: 'ignore' });
|
execSync(`xdg-open "${url}"`, { stdio: "ignore" });
|
||||||
}
|
}
|
||||||
console.error(`已打开浏览器:${url}`);
|
console.error(`已打开浏览器:${url}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('打开浏览器失败:', error);
|
console.error("打开浏览器失败:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算预览 URL
|
||||||
|
*/
|
||||||
|
function computePreviewUrl(mdFilePath: string): string {
|
||||||
|
const relativeMdPath = relative(process.cwd(), mdFilePath)
|
||||||
|
.split("\\")
|
||||||
|
.join("/")
|
||||||
|
.replace(/\.md$/, "");
|
||||||
|
return `http://localhost:3000/${relativeMdPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 预览 Deck - 保存 Markdown 并打开浏览器
|
* 预览 Deck - 保存 Markdown 并打开浏览器
|
||||||
*/
|
*/
|
||||||
@@ -129,108 +81,61 @@ export function previewDeck(params: PreviewDeckParams): PreviewDeckResult {
|
|||||||
if (!existsSync(csv_file)) {
|
if (!existsSync(csv_file)) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: `CSV 文件不存在:${csv_file}`
|
message: `CSV 文件不存在:${csv_file}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确定 Markdown 文件路径
|
// 确定 Markdown 文件路径
|
||||||
let mdFilePath = md_file;
|
const mdFilePath = md_file || defaultMdPath(csv_file);
|
||||||
if (!mdFilePath) {
|
|
||||||
// 使用与 CSV 相同的路径和文件名,只是扩展名不同
|
|
||||||
const dir = dirname(csv_file);
|
|
||||||
const name = basename(csv_file, extname(csv_file));
|
|
||||||
mdFilePath = join(dir, `${name}.md`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const created = !existsSync(mdFilePath);
|
const created = !existsSync(mdFilePath);
|
||||||
|
|
||||||
// 如果文件已存在,检查是否已有 md-deck 组件
|
// 如果文件已存在,检查是否已有 md-deck 组件
|
||||||
if (!created) {
|
if (!created) {
|
||||||
try {
|
try {
|
||||||
const existingContent = readFileSync(mdFilePath, 'utf-8');
|
const existingContent = readFileSync(mdFilePath, "utf-8");
|
||||||
// 如果已有 md-deck 组件引用该 CSV,直接返回
|
if (
|
||||||
if (existingContent.includes(`:md-deck[${csv_file}]`) ||
|
existingContent.includes(`:md-deck[${csv_file}]`) ||
|
||||||
existingContent.includes(`:md-deck[./${basename(csv_file)}]`)) {
|
existingContent.includes(`:md-deck[./${csv_file.split("/").pop()}]`)
|
||||||
// 仍然打开浏览器
|
) {
|
||||||
const relativeMdPath = relative(process.cwd(), mdFilePath).split('\\').join('/');
|
const previewUrl = computePreviewUrl(mdFilePath);
|
||||||
const previewUrl = `http://localhost:3000/${relativeMdPath}`;
|
|
||||||
openBrowser(previewUrl);
|
openBrowser(previewUrl);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `预览文件 ${mdFilePath} 已存在`,
|
message: `预览文件 ${mdFilePath} 已存在`,
|
||||||
md_file: mdFilePath,
|
md_file: mdFilePath,
|
||||||
created: false,
|
created: false,
|
||||||
preview_url: previewUrl
|
preview_url: previewUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
// 读取失败,继续创建
|
// 读取失败,继续创建
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 读取 CSV 的 frontmatter 获取配置
|
// 读取 CSV 的 frontmatter 获取配置
|
||||||
let deckConfig: DeckConfig | undefined;
|
const deckConfig = readDeckConfig(csv_file);
|
||||||
try {
|
|
||||||
const csvContent = readFileSync(csv_file, 'utf-8');
|
|
||||||
const { frontmatter } = parseFrontMatter(csvContent);
|
|
||||||
deckConfig = frontmatter?.deck;
|
|
||||||
} catch (error) {
|
|
||||||
// 忽略错误,使用默认配置
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确定标题
|
// 确定标题
|
||||||
let deckTitle = title;
|
const deckTitle = title || titleFromCsvPath(csv_file);
|
||||||
if (!deckTitle) {
|
|
||||||
// 从 CSV 文件名推断
|
|
||||||
const name = basename(csv_file, extname(csv_file));
|
|
||||||
deckTitle = name.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建 md-deck 组件代码(使用相对路径)
|
// 构建 md-deck 组件代码
|
||||||
const mdDir = dirname(mdFilePath);
|
const deckComponent = buildDeckComponent(
|
||||||
const csvDir = dirname(csv_file);
|
`./${csv_file.split("/").pop()}`,
|
||||||
let relativeCsvPath: string;
|
deckConfig,
|
||||||
|
);
|
||||||
if (mdDir === csvDir) {
|
|
||||||
relativeCsvPath = `./${basename(csv_file)}`;
|
|
||||||
} else {
|
|
||||||
relativeCsvPath = `./${basename(csv_file)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deckComponent = buildDeckComponent(relativeCsvPath, deckConfig);
|
|
||||||
|
|
||||||
// 生成 Markdown 内容
|
// 生成 Markdown 内容
|
||||||
const mdLines: string[] = [];
|
const mdContent = generateDeckPreviewMd(
|
||||||
|
deckTitle,
|
||||||
mdLines.push(`# ${deckTitle}`);
|
deckComponent,
|
||||||
mdLines.push('');
|
description,
|
||||||
|
);
|
||||||
if (description) {
|
|
||||||
mdLines.push(description);
|
|
||||||
mdLines.push('');
|
|
||||||
}
|
|
||||||
|
|
||||||
mdLines.push('## 卡牌预览');
|
|
||||||
mdLines.push('');
|
|
||||||
mdLines.push(deckComponent);
|
|
||||||
mdLines.push('');
|
|
||||||
|
|
||||||
mdLines.push('## 使用说明');
|
|
||||||
mdLines.push('');
|
|
||||||
mdLines.push('- 点击卡牌可以查看详情');
|
|
||||||
mdLines.push('- 使用右上角的按钮可以随机抽取卡牌');
|
|
||||||
mdLines.push('- 可以通过编辑面板调整卡牌样式和布局');
|
|
||||||
mdLines.push('');
|
|
||||||
|
|
||||||
// 写入文件
|
// 写入文件
|
||||||
try {
|
try {
|
||||||
writeFileSync(mdFilePath, mdLines.join('\n'), 'utf-8');
|
writeFileSync(mdFilePath, mdContent, "utf-8");
|
||||||
|
|
||||||
// 计算预览 URL
|
const previewUrl = computePreviewUrl(mdFilePath);
|
||||||
const relativeMdPath = relative(process.cwd(), mdFilePath).split('\\').join('/');
|
|
||||||
const previewUrl = `http://localhost:3000/${relativeMdPath.slice(0, -3)}`;
|
|
||||||
|
|
||||||
// 打开浏览器
|
|
||||||
openBrowser(previewUrl);
|
openBrowser(previewUrl);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -240,12 +145,12 @@ export function previewDeck(params: PreviewDeckParams): PreviewDeckResult {
|
|||||||
: `更新预览文件 ${mdFilePath}`,
|
: `更新预览文件 ${mdFilePath}`,
|
||||||
md_file: mdFilePath,
|
md_file: mdFilePath,
|
||||||
created,
|
created,
|
||||||
preview_url: previewUrl
|
preview_url: previewUrl,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: `写入失败:${error instanceof Error ? error.message : '未知错误'}`
|
message: `写入失败:${error instanceof Error ? error.message : "未知错误"}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-2
@@ -7,8 +7,14 @@ export interface CompileOptions {
|
|||||||
output: string;
|
output: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServeCommandHandler = (dir: string, options: ServeOptions) => Promise<void>;
|
export type ServeCommandHandler = (
|
||||||
export type CompileCommandHandler = (dir: string, options: CompileOptions) => Promise<void>;
|
dir: string,
|
||||||
|
options: ServeOptions,
|
||||||
|
) => Promise<void>;
|
||||||
|
export type CompileCommandHandler = (
|
||||||
|
dir: string,
|
||||||
|
options: CompileOptions,
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
export interface MarkdownFile {
|
export interface MarkdownFile {
|
||||||
path: string;
|
path: string;
|
||||||
|
|||||||
+59
-15
@@ -1,28 +1,42 @@
|
|||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
|
ParentProps,
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
createEffect,
|
createEffect,
|
||||||
onCleanup,
|
onCleanup,
|
||||||
Show,
|
Show,
|
||||||
createResource,
|
createResource,
|
||||||
createMemo,
|
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 { useLocation } from "@solidjs/router";
|
|
||||||
|
|
||||||
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 用于样式控制
|
||||||
scrollToHash?: boolean; // 是否自动滚动到 hash
|
scrollToHash?: boolean; // 是否自动滚动到 hash
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Article DOM context – lets child components react to the content container
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ArticleDomCtx = createContext<() => HTMLDivElement | undefined>();
|
||||||
|
|
||||||
|
/** Access the article's content DOM element from a child component. */
|
||||||
|
export function useArticleDom(): () => HTMLDivElement | undefined {
|
||||||
|
const ctx = useContext(ArticleDomCtx);
|
||||||
|
if (!ctx) throw new Error("useArticleDom must be used inside an <Article>");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchArticleContent(params: {
|
async function fetchArticleContent(params: {
|
||||||
src: string;
|
src: string;
|
||||||
section?: string;
|
section?: string;
|
||||||
@@ -58,18 +72,13 @@ function scrollToHash(hash: string) {
|
|||||||
* Article 组件
|
* Article 组件
|
||||||
* 用于将特定 src 位置的 md 文件显示为 markdown 文章
|
* 用于将特定 src 位置的 md 文件显示为 markdown 文章
|
||||||
*/
|
*/
|
||||||
export const Article: Component<ArticleProps> = (props) => {
|
export const Article: Component<ArticleProps & ParentProps> = (props) => {
|
||||||
const location = useLocation();
|
const navigate = useNavigateWithParams();
|
||||||
const [content, { refetch }] = createResource(
|
const [content, { refetch }] = createResource(
|
||||||
() => ({ src: props.src, section: props.section }),
|
() => ({ src: props.src, section: props.section }),
|
||||||
fetchArticleContent,
|
fetchArticleContent,
|
||||||
);
|
);
|
||||||
|
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();
|
||||||
@@ -79,17 +88,48 @@ export const Article: Component<ArticleProps> = (props) => {
|
|||||||
void mermaid.run();
|
void mermaid.run();
|
||||||
|
|
||||||
// 内容渲染后检查 hash 并滚动
|
// 内容渲染后检查 hash 并滚动
|
||||||
scrollToHash(location.hash);
|
scrollToHash(window.location.hash);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Intercept markdown <a> links to use client-side navigation with
|
||||||
|
// preserved URL search params.
|
||||||
|
createEffect(() => {
|
||||||
|
const dom = contentDom();
|
||||||
|
if (!dom) return;
|
||||||
|
|
||||||
|
const onClick = (e: MouseEvent) => {
|
||||||
|
const anchor = (e.target as HTMLElement).closest("a[href]");
|
||||||
|
if (!anchor) return;
|
||||||
|
const href = anchor.getAttribute("href");
|
||||||
|
if (!href) return;
|
||||||
|
// Only intercept same-origin navigation links (not external, not anchors)
|
||||||
|
if (href.startsWith("http") || href.startsWith("//")) return;
|
||||||
|
if (href.startsWith("#")) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
// Resolve relative hrefs against the current page path so that
|
||||||
|
// e.g. "blah.md" on /content/page.md becomes /content/blah
|
||||||
|
const resolved = new URL(href, window.location.origin + window.location.pathname);
|
||||||
|
let pathname = resolved.pathname;
|
||||||
|
// Strip .md extension so refreshes hit the SPA fallback, not the raw file
|
||||||
|
if (pathname.endsWith(".md")) {
|
||||||
|
pathname = pathname.slice(0, -3);
|
||||||
|
}
|
||||||
|
navigate(pathname + resolved.hash);
|
||||||
|
};
|
||||||
|
|
||||||
|
dom.addEventListener("click", onClick);
|
||||||
|
onCleanup(() => dom.removeEventListener("click", onClick));
|
||||||
|
});
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
// 清理时清空内容,触发内部组件的销毁
|
// 清理时清空内容,触发内部组件的销毁
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
class={`prose text-black ${props.class || ""}`}
|
class={`prose ${props.class || ""}`}
|
||||||
data-src={props.src}
|
data-src={props.src}
|
||||||
>
|
>
|
||||||
<Show when={content.loading}>
|
<Show when={content.loading}>
|
||||||
@@ -99,10 +139,14 @@ export const Article: Component<ArticleProps> = (props) => {
|
|||||||
<div class="text-red-500">加载失败:{content.error?.message}</div>
|
<div class="text-red-500">加载失败:{content.error?.message}</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!content.loading && !content.error && content()}>
|
<Show when={!content.loading && !content.error && content()}>
|
||||||
|
<ArticleDomCtx.Provider value={contentDom}>
|
||||||
<div
|
<div
|
||||||
class="relative"
|
class="relative"
|
||||||
innerHTML={parseMarkdown(content()!, iconPrefix())}
|
ref={setContentDom}
|
||||||
|
innerHTML={parseMarkdown(content()!, props.src)}
|
||||||
/>
|
/>
|
||||||
|
{props.children}
|
||||||
|
</ArticleDomCtx.Provider>
|
||||||
</Show>
|
</Show>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* CommandLinkManager — mounts as a child of <Article> and intercepts
|
||||||
|
* clicks on :cmd[] directive spans to dispatch them via the shared
|
||||||
|
* command dispatcher. Errors are surfaced through the shared
|
||||||
|
* dispatchError signal, shown by JournalInput above the textarea.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, onCleanup } from "solid-js";
|
||||||
|
import { useArticleDom } from "./Article";
|
||||||
|
import { useJournalStream } from "./stores/journalStream";
|
||||||
|
import { useJournalCompletions } from "./journal/completions";
|
||||||
|
import { dispatchCommand, setDispatchError } from "./journal/command-dispatcher";
|
||||||
|
|
||||||
|
export const CommandLinkManager: Component = () => {
|
||||||
|
const contentDom = useArticleDom();
|
||||||
|
const stream = useJournalStream();
|
||||||
|
const comp = useJournalCompletions();
|
||||||
|
|
||||||
|
const onClick = (e: MouseEvent) => {
|
||||||
|
const cmdSpan = (e.target as HTMLElement).closest("[data-cmd]");
|
||||||
|
if (!cmdSpan) return;
|
||||||
|
|
||||||
|
const command = cmdSpan.getAttribute("data-cmd");
|
||||||
|
if (!command) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
|
||||||
|
dispatchCommand({
|
||||||
|
role: stream.myRole as "gm" | "player" | "observer",
|
||||||
|
myName: stream.myName,
|
||||||
|
command,
|
||||||
|
sparkTables: comp.data.sparkTables,
|
||||||
|
variables: stream.variables,
|
||||||
|
declarations: comp.data.declarations,
|
||||||
|
tagModifiers: comp.data.tagModifiers,
|
||||||
|
}).then((result) => {
|
||||||
|
if (!result.ok) {
|
||||||
|
setDispatchError(result.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const dom = contentDom();
|
||||||
|
if (dom) {
|
||||||
|
dom.addEventListener("click", onClick, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
const d = contentDom();
|
||||||
|
if (d) d.removeEventListener("click", onClick, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CommandLinkManager;
|
||||||
@@ -7,6 +7,10 @@ import {
|
|||||||
onMount,
|
onMount,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { docEntries, type DocEntry } from "./doc-data";
|
import { docEntries, type DocEntry } from "./doc-data";
|
||||||
|
import { journalDocEntries, type JournalDocEntry } from "./journal-doc-data";
|
||||||
|
|
||||||
|
type DocSet = "directives" | "journal";
|
||||||
|
type AnyEntry = DocEntry | JournalDocEntry;
|
||||||
|
|
||||||
export interface DocDialogProps {
|
export interface DocDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -14,17 +18,42 @@ export interface DocDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DocDialog: Component<DocDialogProps> = (props) => {
|
const DocDialog: Component<DocDialogProps> = (props) => {
|
||||||
|
const [docSet, setDocSet] = createSignal<DocSet>("directives");
|
||||||
const [selectedTag, setSelectedTag] = createSignal(docEntries[0]?.tag ?? "");
|
const [selectedTag, setSelectedTag] = createSignal(docEntries[0]?.tag ?? "");
|
||||||
|
const [dropdownOpen, setDropdownOpen] = createSignal(false);
|
||||||
|
let dropdownRef!: HTMLDivElement;
|
||||||
|
|
||||||
|
const currentEntries = () =>
|
||||||
|
docSet() === "directives"
|
||||||
|
? (docEntries as AnyEntry[])
|
||||||
|
: (journalDocEntries as AnyEntry[]);
|
||||||
|
|
||||||
const selectedEntry = () =>
|
const selectedEntry = () =>
|
||||||
docEntries.find((e) => e.tag === selectedTag()) ?? docEntries[0];
|
currentEntries().find((e) => e.tag === selectedTag()) ??
|
||||||
|
currentEntries()[0];
|
||||||
|
|
||||||
|
const docSetLabel = () =>
|
||||||
|
docSet() === "directives" ? "指令组件文档" : "Journal 服务器文档";
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") props.onClose();
|
if (e.key === "Escape") props.onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const switchDocSet = (set: DocSet) => {
|
||||||
|
setDocSet(set);
|
||||||
|
setDropdownOpen(false);
|
||||||
|
const entries = set === "directives" ? docEntries : journalDocEntries;
|
||||||
|
setSelectedTag(entries[0]?.tag ?? "");
|
||||||
|
};
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
// Close dropdown on outside click
|
||||||
|
document.addEventListener("click", (e) => {
|
||||||
|
if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
@@ -34,14 +63,54 @@ 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();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="bg-white rounded-lg shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col overflow-hidden">
|
<div class="bg-white rounded-lg shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col overflow-hidden">
|
||||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 shrink-0">
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 shrink-0">
|
||||||
<h2 class="text-lg font-bold text-gray-900">指令组件文档</h2>
|
<div ref={dropdownRef} class="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setDropdownOpen((v) => !v)}
|
||||||
|
class="text-lg font-bold text-gray-900 hover:text-blue-600 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{docSetLabel()}
|
||||||
|
<span class="text-gray-400 text-sm">▾</span>
|
||||||
|
</button>
|
||||||
|
<Show when={dropdownOpen()}>
|
||||||
|
<div class="absolute top-full left-0 mt-1 w-52 bg-white border border-gray-200 rounded shadow-lg z-60 py-1">
|
||||||
|
<button
|
||||||
|
onClick={() => switchDocSet("directives")}
|
||||||
|
class={`w-full text-left px-3 py-2 text-sm hover:bg-gray-100 flex items-center gap-2 ${
|
||||||
|
docSet() === "directives"
|
||||||
|
? "bg-blue-50 text-blue-700"
|
||||||
|
: "text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>📖</span>
|
||||||
|
<span>指令组件文档</span>
|
||||||
|
<Show when={docSet() === "directives"}>
|
||||||
|
<span class="ml-auto text-blue-500">✓</span>
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => switchDocSet("journal")}
|
||||||
|
class={`w-full text-left px-3 py-2 text-sm hover:bg-gray-100 flex items-center gap-2 ${
|
||||||
|
docSet() === "journal"
|
||||||
|
? "bg-blue-50 text-blue-700"
|
||||||
|
: "text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>📋</span>
|
||||||
|
<span>Journal 服务器文档</span>
|
||||||
|
<Show when={docSet() === "journal"}>
|
||||||
|
<span class="ml-auto text-blue-500">✓</span>
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={props.onClose}
|
onClick={props.onClose}
|
||||||
class="text-gray-400 hover:text-gray-600 text-xl leading-none p-1"
|
class="text-gray-400 hover:text-gray-600 text-xl leading-none p-1"
|
||||||
@@ -53,7 +122,7 @@ const DocDialog: Component<DocDialogProps> = (props) => {
|
|||||||
|
|
||||||
<div class="flex flex-1 min-h-0">
|
<div class="flex flex-1 min-h-0">
|
||||||
<nav class="w-48 shrink-0 border-r border-gray-200 overflow-y-auto p-3 bg-gray-50">
|
<nav class="w-48 shrink-0 border-r border-gray-200 overflow-y-auto p-3 bg-gray-50">
|
||||||
<For each={docEntries}>
|
<For each={currentEntries()}>
|
||||||
{(entry) => (
|
{(entry) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedTag(entry.tag)}
|
onClick={() => setSelectedTag(entry.tag)}
|
||||||
@@ -64,7 +133,12 @@ const DocDialog: Component<DocDialogProps> = (props) => {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span class="mr-2">{entry.icon}</span>
|
<span class="mr-2">{entry.icon}</span>
|
||||||
|
<Show
|
||||||
|
when={docSet() === "directives"}
|
||||||
|
fallback={<span class="text-xs">{entry.title}</span>}
|
||||||
|
>
|
||||||
<span class="font-mono text-xs">{`:${entry.tag}`}</span>
|
<span class="font-mono text-xs">{`:${entry.tag}`}</span>
|
||||||
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
@@ -83,80 +157,19 @@ const DocDialog: Component<DocDialogProps> = (props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Single entry documentation content */
|
/** Single entry documentation content */
|
||||||
const DocContent: Component<{ entry: DocEntry }> = (props) => {
|
const DocContent: Component<{ entry: AnyEntry }> = (props) => {
|
||||||
const e = props.entry;
|
const e = props.entry;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="max-w-none">
|
<div class="max-w-none">
|
||||||
<h2 class="text-2xl font-bold text-gray-900 mb-2">
|
<h2 class="text-2xl font-bold text-gray-900 mb-6">
|
||||||
{e.icon} {e.title}
|
{e.icon} {e.title}
|
||||||
<code class="ml-3 text-base font-mono bg-gray-100 px-2 py-0.5 rounded text-blue-700">
|
|
||||||
:{e.tag}
|
|
||||||
</code>
|
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p class="text-gray-600 mb-6">{e.description}</p>
|
<div class="text-sm leading-relaxed whitespace-pre-wrap text-gray-800">
|
||||||
|
|
||||||
<div class="mb-6">
|
|
||||||
<h3 class="text-sm font-semibold text-gray-500 uppercase mb-2">
|
|
||||||
基本语法
|
|
||||||
</h3>
|
|
||||||
<pre class="bg-gray-900 text-gray-100 px-4 py-3 rounded-lg text-sm overflow-x-auto">
|
|
||||||
<code>{e.syntax}</code>
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={e.props.length > 0}>
|
|
||||||
<div class="mb-6">
|
|
||||||
<h3 class="text-sm font-semibold text-gray-500 uppercase mb-2">
|
|
||||||
属性
|
|
||||||
</h3>
|
|
||||||
<table class="w-full text-sm border-collapse">
|
|
||||||
<thead>
|
|
||||||
<tr class="border-b border-gray-200">
|
|
||||||
<th class="text-left py-2 pr-4 font-medium text-gray-600">
|
|
||||||
属性
|
|
||||||
</th>
|
|
||||||
<th class="text-left py-2 pr-4 font-medium text-gray-600">
|
|
||||||
类型
|
|
||||||
</th>
|
|
||||||
<th class="text-left py-2 pr-4 font-medium text-gray-600">
|
|
||||||
默认值
|
|
||||||
</th>
|
|
||||||
<th class="text-left py-2 font-medium text-gray-600">说明</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<For each={e.props}>
|
|
||||||
{(prop) => (
|
|
||||||
<tr class="border-b border-gray-100">
|
|
||||||
<td class="py-2 pr-4">
|
|
||||||
<code class="bg-gray-100 px-1.5 py-0.5 rounded text-xs text-pink-700">
|
|
||||||
{prop.name}
|
|
||||||
</code>
|
|
||||||
</td>
|
|
||||||
<td class="py-2 pr-4">
|
|
||||||
<code class="text-xs text-gray-600">{prop.type}</code>
|
|
||||||
</td>
|
|
||||||
<td class="py-2 pr-4 text-xs text-gray-400">
|
|
||||||
{prop.default ?? "—"}
|
|
||||||
</td>
|
|
||||||
<td class="py-2 text-sm text-gray-600">{prop.desc}</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<div class="prose-sm max-w-none">
|
|
||||||
<h3 class="text-sm font-semibold text-gray-500 uppercase mb-2">示例</h3>
|
|
||||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 text-sm leading-relaxed whitespace-pre-wrap font-mono text-gray-800">
|
|
||||||
{e.body}
|
{e.body}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+44
-14
@@ -1,6 +1,8 @@
|
|||||||
import { Component, createMemo, createSignal, Show } from "solid-js";
|
import { Component, createMemo, createSignal, Show } from "solid-js";
|
||||||
|
import { useLocation } from "@solidjs/router";
|
||||||
import { type FileNode, type TocNode } from "../data-loader";
|
import { type FileNode, type TocNode } from "../data-loader";
|
||||||
import { useNavigate } from "@solidjs/router";
|
import { useNavigateWithParams } from "./useNavigateWithParams";
|
||||||
|
import { useScrollContainer } from "../App";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查当前文件路径是否在文件夹内
|
* 检查当前文件路径是否在文件夹内
|
||||||
@@ -20,8 +22,10 @@ export const FileTreeNode: Component<{
|
|||||||
pathHeadings: Record<string, TocNode[]>;
|
pathHeadings: Record<string, TocNode[]>;
|
||||||
depth: number;
|
depth: number;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
isHidden?: (node: FileNode) => boolean;
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigateWithParams();
|
||||||
|
const location = useLocation();
|
||||||
const isDir = !!props.node.children;
|
const isDir = !!props.node.children;
|
||||||
const isActive = createMemo(() => props.currentPath === props.node.path);
|
const isActive = createMemo(() => props.currentPath === props.node.path);
|
||||||
// 默认收起,除非当前文件在该文件夹内
|
// 默认收起,除非当前文件在该文件夹内
|
||||||
@@ -29,21 +33,28 @@ export const FileTreeNode: Component<{
|
|||||||
isDir && isPathInDir(props.currentPath, props.node.path),
|
isDir && isPathInDir(props.currentPath, props.node.path),
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClick = () => {
|
const href = () => props.node.path + location.search;
|
||||||
|
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
if (isDir) {
|
if (isDir) {
|
||||||
|
e.preventDefault();
|
||||||
setIsExpanded(!isExpanded());
|
setIsExpanded(!isExpanded());
|
||||||
} else {
|
} else if (e.button === 0) {
|
||||||
|
// Left-click: use SPA navigation
|
||||||
|
e.preventDefault();
|
||||||
navigate(props.node.path);
|
navigate(props.node.path);
|
||||||
props.onClose();
|
props.onClose();
|
||||||
}
|
}
|
||||||
|
// Middle-click / right-click: let the browser handle the <a> natively
|
||||||
};
|
};
|
||||||
|
|
||||||
const indent = props.depth * 12;
|
const indent = props.depth * 12;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div class={props.isHidden?.(props.node) === true ? "hidden" : ""}>
|
||||||
<div
|
<a
|
||||||
class={`flex items-center py-1 px-2 cursor-pointer hover:bg-gray-100 rounded ${
|
href={href()}
|
||||||
|
class={`flex items-center py-1 px-2 cursor-pointer hover:bg-gray-100 rounded no-underline ${
|
||||||
isActive() ? "bg-blue-50 text-blue-700" : "text-gray-700"
|
isActive() ? "bg-blue-50 text-blue-700" : "text-gray-700"
|
||||||
}`}
|
}`}
|
||||||
style={{ "padding-left": `${indent + 8}px` }}
|
style={{ "padding-left": `${indent + 8}px` }}
|
||||||
@@ -56,7 +67,7 @@ export const FileTreeNode: Component<{
|
|||||||
<span class="mr-1 text-gray-400">📄</span>
|
<span class="mr-1 text-gray-400">📄</span>
|
||||||
</Show>
|
</Show>
|
||||||
<span class="text-sm truncate">{props.node.name}</span>
|
<span class="text-sm truncate">{props.node.name}</span>
|
||||||
</div>
|
</a>
|
||||||
<Show when={isDir && isExpanded() && props.node.children}>
|
<Show when={isDir && isExpanded() && props.node.children}>
|
||||||
<div>
|
<div>
|
||||||
{props.node.children!.map((child) => (
|
{props.node.children!.map((child) => (
|
||||||
@@ -66,6 +77,7 @@ export const FileTreeNode: Component<{
|
|||||||
pathHeadings={props.pathHeadings}
|
pathHeadings={props.pathHeadings}
|
||||||
depth={props.depth + 1}
|
depth={props.depth + 1}
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
|
isHidden={props.isHidden}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -81,10 +93,12 @@ export const HeadingNode: Component<{
|
|||||||
node: TocNode;
|
node: TocNode;
|
||||||
basePath: string;
|
basePath: string;
|
||||||
depth: number;
|
depth: number;
|
||||||
|
isHidden?: (node: TocNode) => boolean;
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigateWithParams();
|
||||||
|
const location = useLocation();
|
||||||
const anchor = props.node.id || "";
|
const anchor = props.node.id || "";
|
||||||
const href = `${props.basePath}#${anchor}`;
|
const href = () => `${props.basePath}${location.search}#${anchor}`;
|
||||||
const hasChildren = !!props.node.children;
|
const hasChildren = !!props.node.children;
|
||||||
// 默认收起,除非当前锚点在该节点内
|
// 默认收起,除非当前锚点在该节点内
|
||||||
const [isExpanded, setIsExpanded] = createSignal(props.depth <= 0);
|
const [isExpanded, setIsExpanded] = createSignal(props.depth <= 0);
|
||||||
@@ -95,24 +109,39 @@ export const HeadingNode: Component<{
|
|||||||
setIsExpanded(!isExpanded());
|
setIsExpanded(!isExpanded());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const scrollContainer = useScrollContainer();
|
||||||
|
|
||||||
const handleClick = (e: MouseEvent) => {
|
const handleClick = (e: MouseEvent) => {
|
||||||
navigate(href);
|
if (e.button === 0) {
|
||||||
|
// Left-click: use SPA navigation
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(`${props.basePath}#${anchor}`);
|
||||||
|
}
|
||||||
|
// Middle-click / right-click: let the browser handle the <a> natively
|
||||||
// 滚动到目标元素,考虑导航栏高度偏移
|
// 滚动到目标元素,考虑导航栏高度偏移
|
||||||
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;
|
||||||
|
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" });
|
window.scrollTo({ top: offsetPosition, behavior: "instant" });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const indent = props.depth * 12;
|
const indent = props.depth * 12;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div class={props.isHidden?.(props.node) === true ? "hidden" : ""}>
|
||||||
<div class="flex flex-row">
|
<div class="flex flex-row">
|
||||||
<span
|
<span
|
||||||
class={`cursor-pointer mr-1 text-gray-400 text-xs w-3 shrink-0`}
|
class={`cursor-pointer mr-1 text-gray-400 text-xs w-3 shrink-0`}
|
||||||
@@ -126,7 +155,7 @@ export const HeadingNode: Component<{
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={href()}
|
||||||
class="inline-flex items-center py-0.5 px-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded truncate cursor-pointer"
|
class="inline-flex items-center py-0.5 px-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded truncate cursor-pointer"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
@@ -140,6 +169,7 @@ export const HeadingNode: Component<{
|
|||||||
node={child}
|
node={child}
|
||||||
basePath={props.basePath}
|
basePath={props.basePath}
|
||||||
depth={props.depth + 1}
|
depth={props.depth + 1}
|
||||||
|
isHidden={props.isHidden}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* RevealManager — mounts as a child of <Article> and reactively applies
|
||||||
|
* reveal classes (non-GM) or injects action buttons (GM) whenever the
|
||||||
|
* content DOM, stream connection state, role, or completions change.
|
||||||
|
*
|
||||||
|
* All action buttons are proper Solid components rendered via Portal.
|
||||||
|
* Nothing is injected into the custom element DOM — listeners are attached
|
||||||
|
* via addEventListener with proper cleanup.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Component,
|
||||||
|
createEffect,
|
||||||
|
createSignal,
|
||||||
|
onCleanup,
|
||||||
|
Show,
|
||||||
|
} from "solid-js";
|
||||||
|
import { Portal } from "solid-js/web";
|
||||||
|
import { useLocation } from "@solidjs/router";
|
||||||
|
import { useArticleDom } from "./Article";
|
||||||
|
import { journalStreamState } from "./stores/journalStream";
|
||||||
|
import { useJournalCompletions } from "./journal/completions";
|
||||||
|
import {
|
||||||
|
addRevealedClasses,
|
||||||
|
cleanupInjections,
|
||||||
|
setActionPrefill,
|
||||||
|
normalizePath,
|
||||||
|
} from "./stores/reveal";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SVG icons
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const LINK_SVG =
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" ' +
|
||||||
|
'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
|
||||||
|
'stroke-linecap="round" stroke-linejoin="round">' +
|
||||||
|
'<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>' +
|
||||||
|
'<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>' +
|
||||||
|
"</svg>";
|
||||||
|
|
||||||
|
const SPARK_SVG =
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" ' +
|
||||||
|
'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
|
||||||
|
'stroke-linecap="round" stroke-linejoin="round">' +
|
||||||
|
'<path d="M12 2l1.5 6.5L18 7l-4.5 4.5L16 16l-4-2.5L8 16l1.5-4.5L5 7l4.5-.5z"/>' +
|
||||||
|
"</svg>";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Floating button state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface FloatingButton {
|
||||||
|
rect: DOMRect;
|
||||||
|
action: () => void;
|
||||||
|
title: string;
|
||||||
|
svg: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [floating, setFloating] = createSignal<FloatingButton | null>(null);
|
||||||
|
let hideTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
function show(btn: FloatingButton) {
|
||||||
|
clearTimeout(hideTimer);
|
||||||
|
setFloating(btn);
|
||||||
|
}
|
||||||
|
function hide() {
|
||||||
|
hideTimer = setTimeout(() => setFloating(null), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// RevealManager
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const RevealManager: Component = () => {
|
||||||
|
const contentDom = useArticleDom();
|
||||||
|
const location = useLocation();
|
||||||
|
const comp = useJournalCompletions();
|
||||||
|
|
||||||
|
// ---- Reveal / conceal classes ----
|
||||||
|
createEffect(() => {
|
||||||
|
const dom = contentDom();
|
||||||
|
if (!dom) return;
|
||||||
|
|
||||||
|
const state = journalStreamState;
|
||||||
|
void state.connected;
|
||||||
|
void state.myRole;
|
||||||
|
|
||||||
|
addRevealedClasses(dom, location.pathname);
|
||||||
|
});
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
const dom = contentDom();
|
||||||
|
if (dom) cleanupInjections(dom);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- GM hover listeners (only when connected as GM) ----
|
||||||
|
createEffect(() => {
|
||||||
|
const dom = contentDom();
|
||||||
|
const state = journalStreamState;
|
||||||
|
if (!dom || state.myRole !== "gm" || !state.connected) return;
|
||||||
|
|
||||||
|
const normalized = normalizePath(decodeURIComponent(location.pathname));
|
||||||
|
const data = comp.data;
|
||||||
|
|
||||||
|
// Spark tables on this page — matched by suffix on column slug.
|
||||||
|
// filePath in completions may have a leading slash; normalize both sides.
|
||||||
|
const pageSparkSlugs = data.sparkTables
|
||||||
|
.filter((st) => st.filePath.replace(/^\//, "") === normalized)
|
||||||
|
.map((st) => st.slug);
|
||||||
|
|
||||||
|
// Single delegated handler for both link and spark
|
||||||
|
const onMouseOver = (e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
|
||||||
|
// Spark table
|
||||||
|
const sparkTable = target.closest("md-table[data-spark]");
|
||||||
|
if (sparkTable) {
|
||||||
|
const colSlug = sparkTable.getAttribute("data-spark");
|
||||||
|
const combinedSlug = colSlug
|
||||||
|
? pageSparkSlugs.find((s) => s.endsWith(`-${colSlug}`))
|
||||||
|
: undefined;
|
||||||
|
if (combinedSlug) {
|
||||||
|
show({
|
||||||
|
rect: sparkTable.getBoundingClientRect(),
|
||||||
|
action: () =>
|
||||||
|
setActionPrefill({
|
||||||
|
command: "/roll",
|
||||||
|
text: combinedSlug,
|
||||||
|
}),
|
||||||
|
title: "Roll spark table",
|
||||||
|
svg: SPARK_SVG,
|
||||||
|
color: "text-purple-400 hover:text-purple-600 hover:bg-purple-50",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heading
|
||||||
|
const heading = target.closest("h1, h2, h3, h4, h5, h6");
|
||||||
|
if (heading) {
|
||||||
|
const text = heading.id || heading.textContent?.trim() || "";
|
||||||
|
if (text) {
|
||||||
|
show({
|
||||||
|
rect: heading.getBoundingClientRect(),
|
||||||
|
action: () =>
|
||||||
|
setActionPrefill({
|
||||||
|
command: "/link",
|
||||||
|
text: `${normalized}#${text}`,
|
||||||
|
}),
|
||||||
|
title: "Send /link to stream",
|
||||||
|
svg: LINK_SVG,
|
||||||
|
color: "text-blue-500 hover:bg-blue-50",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseOut = (e: MouseEvent) => {
|
||||||
|
// Hide only if the mouse actually left the content area
|
||||||
|
const related = e.relatedTarget as HTMLElement | null;
|
||||||
|
if (!dom.contains(related)) hide();
|
||||||
|
};
|
||||||
|
|
||||||
|
dom.addEventListener("mouseover", onMouseOver);
|
||||||
|
dom.addEventListener("mouseout", onMouseOut);
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
dom.removeEventListener("mouseover", onMouseOver);
|
||||||
|
dom.removeEventListener("mouseout", onMouseOut);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={floating()}>
|
||||||
|
{(btn) => (
|
||||||
|
<Portal>
|
||||||
|
<button
|
||||||
|
class={`fixed z-50 inline-flex items-center justify-center w-5 h-5 rounded cursor-pointer ${btn().color}`}
|
||||||
|
style={{
|
||||||
|
top: `${btn().rect.top - 6}px`,
|
||||||
|
left: `${btn().rect.left - 20}px`,
|
||||||
|
}}
|
||||||
|
title={btn().title}
|
||||||
|
innerHTML={btn().svg}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
btn().action();
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => clearTimeout(hideTimer)}
|
||||||
|
onMouseLeave={hide}
|
||||||
|
/>
|
||||||
|
</Portal>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RevealManager;
|
||||||
@@ -2,12 +2,14 @@ import { Component, createMemo, createSignal, onMount, Show } from "solid-js";
|
|||||||
import { generateToc, type FileNode, type TocNode } from "../data-loader";
|
import { generateToc, type FileNode, type TocNode } from "../data-loader";
|
||||||
import { useLocation } from "@solidjs/router";
|
import { useLocation } from "@solidjs/router";
|
||||||
import { FileTreeNode, HeadingNode } from "./FileTree";
|
import { FileTreeNode, HeadingNode } from "./FileTree";
|
||||||
|
import { isPathRevealed } from "./stores/reveal";
|
||||||
|
|
||||||
export interface SidebarProps {
|
export interface SidebarProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
fileTree?: FileNode[];
|
fileTree?: FileNode[];
|
||||||
pathHeadings?: Record<string, TocNode[]>;
|
pathHeadings?: Record<string, TocNode[]>;
|
||||||
|
onDataSourceOpen?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SidebarContentProps {
|
interface SidebarContentProps {
|
||||||
@@ -16,6 +18,7 @@ interface SidebarContentProps {
|
|||||||
currentPath: string;
|
currentPath: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
isDesktop?: boolean;
|
isDesktop?: boolean;
|
||||||
|
onDataSourceOpen?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,12 +34,31 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
|||||||
props.pathHeadings[pathname] || props.pathHeadings[`${pathname}.md`] || []
|
props.pathHeadings[pathname] || props.pathHeadings[`${pathname}.md`] || []
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
const isFileHidden = (node: FileNode): boolean => {
|
||||||
|
if (isPathRevealed(node.path)) return false;
|
||||||
|
if (node.children?.some((child) => !isFileHidden(child))) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const isHeadingHidden = (node: TocNode): boolean => {
|
||||||
|
const pathname = decodeURIComponent(location.pathname);
|
||||||
|
if (isPathRevealed(pathname, node.id ?? node.title)) return false;
|
||||||
|
if (node.children?.some((child) => !isHeadingHidden(child))) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full">
|
||||||
<div class="p-4 border-b border-b-gray-200">
|
<div class="p-4 border-b border-b-gray-200">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="text-lg font-bold text-gray-900">目录</h2>
|
<h2 class="text-lg font-bold text-gray-900">目录</h2>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={props.onDataSourceOpen}
|
||||||
|
class="text-gray-400 hover:text-gray-600 p-1 rounded hover:bg-gray-100"
|
||||||
|
title="Content Source"
|
||||||
|
>
|
||||||
|
📂
|
||||||
|
</button>
|
||||||
<Show when={!props.isDesktop}>
|
<Show when={!props.isDesktop}>
|
||||||
<button
|
<button
|
||||||
onClick={props.onClose}
|
onClick={props.onClose}
|
||||||
@@ -48,6 +70,7 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 文件树滚动区域 */}
|
{/* 文件树滚动区域 */}
|
||||||
<div class="flex-1 overflow-y-auto p-4 min-h-0">
|
<div class="flex-1 overflow-y-auto p-4 min-h-0">
|
||||||
@@ -61,6 +84,7 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
|||||||
pathHeadings={props.pathHeadings}
|
pathHeadings={props.pathHeadings}
|
||||||
depth={0}
|
depth={0}
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
|
isHidden={isFileHidden}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -72,7 +96,12 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
|||||||
本页
|
本页
|
||||||
</h3>
|
</h3>
|
||||||
{currentFileHeadings().map((node) => (
|
{currentFileHeadings().map((node) => (
|
||||||
<HeadingNode node={node} basePath={location.pathname} depth={0} />
|
<HeadingNode
|
||||||
|
node={node}
|
||||||
|
basePath={location.pathname}
|
||||||
|
depth={0}
|
||||||
|
isHidden={isHeadingHidden}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -121,6 +150,7 @@ export const MobileSidebar: Component<SidebarProps> = (props) => {
|
|||||||
pathHeadings={pathHeadings()}
|
pathHeadings={pathHeadings()}
|
||||||
currentPath={location.pathname}
|
currentPath={location.pathname}
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
|
onDataSourceOpen={props.onDataSourceOpen}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
@@ -128,11 +158,12 @@ export const MobileSidebar: Component<SidebarProps> = (props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 桌面端固定侧边栏
|
* 桌面端侧边栏 — flex child, no fixed positioning
|
||||||
*/
|
*/
|
||||||
export const DesktopSidebar: Component<{
|
export const DesktopSidebar: Component<{
|
||||||
fileTree?: FileNode[];
|
fileTree?: FileNode[];
|
||||||
pathHeadings?: Record<string, TocNode[]>;
|
pathHeadings?: Record<string, TocNode[]>;
|
||||||
|
onDataSourceOpen?: () => void;
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [selfFileTree, setSelfFileTree] = createSignal<FileNode[]>([]);
|
const [selfFileTree, setSelfFileTree] = createSignal<FileNode[]>([]);
|
||||||
@@ -151,13 +182,14 @@ 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()}
|
||||||
currentPath={location.pathname}
|
currentPath={location.pathname}
|
||||||
onClose={() => {}}
|
onClose={() => {}}
|
||||||
isDesktop
|
isDesktop
|
||||||
|
onDataSourceOpen={props.onDataSourceOpen}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,16 +4,14 @@ export interface DocEntry {
|
|||||||
tag: string;
|
tag: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
/** Full markdown body (everything after frontmatter ---) */
|
||||||
syntax: string;
|
|
||||||
props: { name: string; type: string; default?: string; desc: string }[];
|
|
||||||
/** Markdown body (everything after frontmatter ---) */
|
|
||||||
body: string;
|
body: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Splits frontmatter and markdown body from a raw .md string. */
|
/** Splits frontmatter and markdown body from a raw .md string. */
|
||||||
function parseFrontmatter(raw: string): Record<string, unknown> | null {
|
function parseFrontmatter(raw: string): Record<string, unknown> | null {
|
||||||
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
const normalized = raw.replace(/\r\n/g, "\n");
|
||||||
|
const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
try {
|
try {
|
||||||
return yaml.load(match[1]) as Record<string, unknown>;
|
return yaml.load(match[1]) as Record<string, unknown>;
|
||||||
@@ -23,8 +21,9 @@ function parseFrontmatter(raw: string): Record<string, unknown> | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getBody(raw: string): string {
|
function getBody(raw: string): string {
|
||||||
const match = raw.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/);
|
const normalized = raw.replace(/\r\n/g, "\n");
|
||||||
return match ? match[1] : raw;
|
const match = normalized.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/);
|
||||||
|
return (match ? match[1] : raw).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseEntry(raw: string): DocEntry | null {
|
function parseEntry(raw: string): DocEntry | null {
|
||||||
@@ -34,9 +33,6 @@ function parseEntry(raw: string): DocEntry | null {
|
|||||||
tag: fm.tag as string,
|
tag: fm.tag as string,
|
||||||
icon: fm.icon as string,
|
icon: fm.icon as string,
|
||||||
title: fm.title as string,
|
title: fm.title as string,
|
||||||
description: fm.description as string,
|
|
||||||
syntax: fm.syntax as string,
|
|
||||||
props: (fm.props as DocEntry["props"]) ?? [],
|
|
||||||
body: getBody(raw),
|
body: getBody(raw),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -53,8 +49,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[] = [
|
||||||
@@ -68,8 +63,7 @@ const rawDocuments: string[] = [
|
|||||||
mdEmbedRaw,
|
mdEmbedRaw,
|
||||||
mdDeckRaw,
|
mdDeckRaw,
|
||||||
mdYarnRaw,
|
mdYarnRaw,
|
||||||
mdTokenRaw,
|
|
||||||
mdTokenViewerRaw,
|
|
||||||
mdCommanderRaw,
|
mdCommanderRaw,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ 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";
|
||||||
export type { ArticleProps } from "./Article";
|
export type { ArticleProps } from "./Article";
|
||||||
|
export { RevealManager } from "./RevealManager";
|
||||||
|
export { CommandLinkManager } from "./CommandLinkManager";
|
||||||
|
export { ReactiveVariableManager } from "./journal/ReactiveVariableManager";
|
||||||
export { MobileSidebar, DesktopSidebar } from "./Sidebar";
|
export { MobileSidebar, DesktopSidebar } from "./Sidebar";
|
||||||
export type { SidebarProps } from "./Sidebar";
|
export type { SidebarProps } from "./Sidebar";
|
||||||
export { FileTreeNode, HeadingNode } from "./FileTree";
|
export { FileTreeNode, HeadingNode } from "./FileTree";
|
||||||
@@ -32,7 +33,6 @@ export type { FontProps } from "./md-font";
|
|||||||
export type { BorderProps } from "./md-border";
|
export type { BorderProps } from "./md-border";
|
||||||
export type { EmbedProps } from "./md-embed";
|
export type { EmbedProps } from "./md-embed";
|
||||||
export type { YarnSpinnerProps } from "./md-yarn-spinner";
|
export type { YarnSpinnerProps } from "./md-yarn-spinner";
|
||||||
export type { TokenProps } from "./md-token";
|
|
||||||
|
|
||||||
// 导出 md-commander 相关
|
// 导出 md-commander 相关
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import yaml from "js-yaml";
|
||||||
|
|
||||||
|
export interface JournalDocEntry {
|
||||||
|
tag: string;
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
/** Full markdown body (everything after frontmatter ---) */
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFrontmatter(raw: string): Record<string, unknown> | null {
|
||||||
|
const normalized = raw.replace(/\r\n/g, "\n");
|
||||||
|
const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
try {
|
||||||
|
return yaml.load(match[1]) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBody(raw: string): string {
|
||||||
|
const normalized = raw.replace(/\r\n/g, "\n");
|
||||||
|
const match = normalized.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/);
|
||||||
|
return (match ? match[1] : raw).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEntry(raw: string): JournalDocEntry | null {
|
||||||
|
const fm = parseFrontmatter(raw);
|
||||||
|
if (!fm) return null;
|
||||||
|
return {
|
||||||
|
tag: fm.tag as string,
|
||||||
|
icon: fm.icon as string,
|
||||||
|
title: fm.title as string,
|
||||||
|
body: getBody(raw),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
import journalGmRaw from "../doc-entries/journal-gm.md";
|
||||||
|
import journalPlayerRaw from "../doc-entries/journal-player.md";
|
||||||
|
import journalSetRaw from "../doc-entries/journal-set.md";
|
||||||
|
import journalRollRaw from "../doc-entries/journal-roll.md";
|
||||||
|
import journalLinkRaw from "../doc-entries/journal-link.md";
|
||||||
|
|
||||||
|
const rawDocuments: string[] = [
|
||||||
|
journalGmRaw,
|
||||||
|
journalPlayerRaw,
|
||||||
|
journalSetRaw,
|
||||||
|
journalRollRaw,
|
||||||
|
journalLinkRaw,
|
||||||
|
];
|
||||||
|
|
||||||
|
let _entries: JournalDocEntry[] | null = null;
|
||||||
|
|
||||||
|
function loadJournalDocEntries(): JournalDocEntry[] {
|
||||||
|
if (_entries) return _entries;
|
||||||
|
_entries = rawDocuments.map(parseEntry).filter(Boolean) as JournalDocEntry[];
|
||||||
|
return _entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const journalDocEntries = loadJournalDocEntries();
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* CompletionsDropdown — autocomplete popover for journal command input.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, For, Show, createEffect, onMount, onCleanup } from "solid-js";
|
||||||
|
import type { CompletionItem } from "./command-parser";
|
||||||
|
import type { CompletionsState } from "./completions";
|
||||||
|
|
||||||
|
export interface CompletionsDropdownProps {
|
||||||
|
show: boolean;
|
||||||
|
items: CompletionItem[];
|
||||||
|
selectedIdx: number;
|
||||||
|
onSelect: (item: CompletionItem) => void;
|
||||||
|
onHover: (idx: number) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
state: CompletionsState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CompletionsDropdown: Component<CompletionsDropdownProps> = (
|
||||||
|
props,
|
||||||
|
) => {
|
||||||
|
let containerRef!: HTMLDivElement;
|
||||||
|
|
||||||
|
// Scroll selected into view
|
||||||
|
createEffect(() => {
|
||||||
|
const idx = props.selectedIdx;
|
||||||
|
if (!props.show || !containerRef) return;
|
||||||
|
const el = containerRef.querySelector(`[data-comp-idx="${idx}"]`);
|
||||||
|
if (el) el.scrollIntoView({ block: "nearest" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click outside
|
||||||
|
onMount(() => {
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (containerRef && !containerRef.contains(e.target as Node)) {
|
||||||
|
props.onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
onCleanup(() => document.removeEventListener("mousedown", handler));
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={props.show}>
|
||||||
|
<Show
|
||||||
|
when={props.state.status === "loaded" || props.state.status === "empty"}
|
||||||
|
fallback={
|
||||||
|
<Show
|
||||||
|
when={props.state.status === "loading"}
|
||||||
|
fallback={
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
class="absolute bottom-full left-0 right-0 bg-white border border-red-200 rounded-t shadow-lg mb-0.5 z-50 px-3 py-2 text-xs text-red-500"
|
||||||
|
>
|
||||||
|
{(props.state as any).message || "无法加载补全数据"}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
class="absolute bottom-full left-0 right-0 bg-white border border-gray-200 rounded-t shadow-lg mb-0.5 z-50 px-3 py-2 text-xs text-gray-400 italic"
|
||||||
|
>
|
||||||
|
正在加载补全数据…
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show when={props.items.length > 0}>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
class="absolute bottom-full left-0 right-0 bg-white border border-gray-200 rounded-t shadow-lg mb-0.5 max-h-40 overflow-y-auto z-50"
|
||||||
|
>
|
||||||
|
<For each={props.items}>
|
||||||
|
{(item, idx) => (
|
||||||
|
<div
|
||||||
|
data-comp-idx={idx()}
|
||||||
|
class={`flex items-center gap-2 px-3 py-1.5 cursor-pointer text-sm ${
|
||||||
|
item.kind === "no-results"
|
||||||
|
? "text-gray-400 italic cursor-default"
|
||||||
|
: idx() === props.selectedIdx
|
||||||
|
? "bg-blue-50 text-blue-700"
|
||||||
|
: "hover:bg-gray-100 text-gray-700"
|
||||||
|
}`}
|
||||||
|
onClick={() => props.onSelect(item)}
|
||||||
|
onMouseEnter={() =>
|
||||||
|
item.kind !== "no-results" && props.onHover(idx())
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={item.kind !== "no-results"}
|
||||||
|
fallback={
|
||||||
|
<span class="text-xs text-gray-300 shrink-0">—</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class={`text-xs px-1 rounded shrink-0 ${
|
||||||
|
item.kind === "command"
|
||||||
|
? "bg-purple-100 text-purple-700"
|
||||||
|
: "bg-gray-100 text-gray-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.kind === "command" ? "cmd" : "val"}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<span class="font-mono truncate flex-1">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* ComposePanel — type picker + dynamic form + send button
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, createSignal, For, Show, createMemo } from "solid-js";
|
||||||
|
import { registeredTypes, canEmit } from "./registry";
|
||||||
|
import { sendMessage, useJournalStream } from "../stores/journalStream";
|
||||||
|
import { DynamicForm } from "./DynamicForm";
|
||||||
|
|
||||||
|
export const ComposePanel: Component = () => {
|
||||||
|
const stream = useJournalStream();
|
||||||
|
const [selectedType, setSelectedType] = createSignal<string>("chat");
|
||||||
|
const [formData, setFormData] = createSignal<Record<string, unknown>>({});
|
||||||
|
const [error, setError] = createSignal<string | null>(null);
|
||||||
|
const [sending, setSending] = createSignal(false);
|
||||||
|
|
||||||
|
const role = () => stream.myRole;
|
||||||
|
|
||||||
|
// Available types filtered by role
|
||||||
|
const availableTypes = createMemo(() => {
|
||||||
|
const types: string[] = [];
|
||||||
|
for (const type of registeredTypes()) {
|
||||||
|
if (canEmit(type, role())) {
|
||||||
|
types.push(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Default to chat if available, otherwise first
|
||||||
|
if (types.includes("chat")) {
|
||||||
|
return types;
|
||||||
|
}
|
||||||
|
return types;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
setError(null);
|
||||||
|
setSending(true);
|
||||||
|
|
||||||
|
const result = sendMessage(selectedType(), formData());
|
||||||
|
if (!result.success) {
|
||||||
|
setError(result.error);
|
||||||
|
} else {
|
||||||
|
setFormData({}); // Reset on success
|
||||||
|
}
|
||||||
|
|
||||||
|
setSending(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="border-t border-gray-200 p-2 space-y-2 bg-white">
|
||||||
|
{/* Type picker */}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={selectedType()}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedType(e.currentTarget.value);
|
||||||
|
setFormData({});
|
||||||
|
}}
|
||||||
|
class="text-xs border border-gray-300 rounded px-2 py-1 bg-white"
|
||||||
|
>
|
||||||
|
<For each={availableTypes()}>
|
||||||
|
{(type) => <option value={type}>{type}</option>}
|
||||||
|
</For>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={sending()}
|
||||||
|
class="ml-auto bg-blue-600 text-white rounded px-3 py-1 text-xs hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{sending() ? "..." : "发送"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dynamic form */}
|
||||||
|
<DynamicForm
|
||||||
|
type={selectedType()}
|
||||||
|
value={formData()}
|
||||||
|
onChange={setFormData}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Show when={error()}>
|
||||||
|
<p class="text-red-500 text-xs">{error()}</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
// In dev, the MQTT broker lives on the CLI server (port 3000), not the
|
||||||
|
// rsbuild dev server. In production, the CLI serves everything itself.
|
||||||
|
const brokerUrl =
|
||||||
|
process.env.NODE_ENV === "development"
|
||||||
|
? `ws://localhost:3000`
|
||||||
|
: `ws://${window.location.host}`;
|
||||||
|
|
||||||
|
setConnecting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setMyName(name);
|
||||||
|
setMyRole(role());
|
||||||
|
const sessionId = stream.sessionId || "default";
|
||||||
|
setSessionId(sessionId);
|
||||||
|
await hydrateFromServer(sessionId);
|
||||||
|
await connectStream(sessionId, brokerUrl);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : "Connection failed";
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setConnecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show
|
||||||
|
when={!autoJoined() || error()}
|
||||||
|
fallback={
|
||||||
|
<p class="text-sm text-gray-500 text-center">
|
||||||
|
{connecting() ? "正在加入会话…" : "已加入!"}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="w-full max-w-sm space-y-3">
|
||||||
|
<p class="text-sm text-gray-600 text-center">
|
||||||
|
请输入你的名字和角色来加入会话。
|
||||||
|
<br />
|
||||||
|
<span class="text-xs text-gray-400">
|
||||||
|
连接到 {window.location.host}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={playerName()}
|
||||||
|
onInput={(e) => setPlayerName(e.currentTarget.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleConnect()}
|
||||||
|
placeholder="你的名字"
|
||||||
|
class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
{/* Role selector */}
|
||||||
|
<div class="flex gap-1">
|
||||||
|
{(["gm", "player", "observer"] as const).map((r) => (
|
||||||
|
<button
|
||||||
|
onClick={() => setRole(r)}
|
||||||
|
class={`flex-1 rounded px-2 py-1.5 text-xs font-medium border transition-colors ${
|
||||||
|
role() === r
|
||||||
|
? "bg-blue-600 text-white border-blue-600"
|
||||||
|
: "bg-white text-gray-600 border-gray-300 hover:border-gray-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{r === "gm" ? "主持人" : r === "player" ? "玩家" : "观察者"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleConnect}
|
||||||
|
disabled={connecting() || !playerName().trim()}
|
||||||
|
class="w-full bg-blue-600 text-white rounded px-3 py-2 text-sm font-medium
|
||||||
|
hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{connecting() ? "连接中..." : "加入"}
|
||||||
|
</button>
|
||||||
|
<Show when={error()}>
|
||||||
|
<p class="text-red-500 text-sm text-center">{error()}</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* ConnectionBar — session picker, player info (connected state only)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, createSignal, Show, For } from "solid-js";
|
||||||
|
import {
|
||||||
|
hydrateFromServer,
|
||||||
|
useJournalStream,
|
||||||
|
sessions,
|
||||||
|
createSession,
|
||||||
|
deleteSession,
|
||||||
|
disconnectStream,
|
||||||
|
setSessionId,
|
||||||
|
} from "../stores/journalStream";
|
||||||
|
import { journalSetState } from "../stores/journalStream";
|
||||||
|
|
||||||
|
export const ConnectionBar: Component = () => {
|
||||||
|
const stream = useJournalStream();
|
||||||
|
const [newSessionName, setNewSessionName] = createSignal("");
|
||||||
|
const [showNewSession, setShowNewSession] = createSignal(false);
|
||||||
|
const [error, setError] = createSignal<string | null>(null);
|
||||||
|
|
||||||
|
const manifest = sessions;
|
||||||
|
|
||||||
|
const handleCreateSession = () => {
|
||||||
|
const name = newSessionName().trim();
|
||||||
|
if (!name) return;
|
||||||
|
const id = createSession(name);
|
||||||
|
if (id) {
|
||||||
|
setSessionId(id);
|
||||||
|
hydrateFromServer(id).catch(() => {});
|
||||||
|
}
|
||||||
|
setNewSessionName("");
|
||||||
|
setShowNewSession(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteSession = (id: string) => {
|
||||||
|
if (confirm(`确定要删除会话 "${id}"?此操作不可撤销。`)) {
|
||||||
|
deleteSession(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSessionSelect = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await hydrateFromServer(id);
|
||||||
|
setSessionId(id);
|
||||||
|
} catch (e) {
|
||||||
|
setError("无法加载会话");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisconnect = () => {
|
||||||
|
disconnectStream();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="border-b border-gray-200 px-3 py-2 space-y-2 text-sm">
|
||||||
|
{/* Status row */}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-green-500" />
|
||||||
|
<span class="text-xs text-gray-500">已连接</span>
|
||||||
|
<Show when={stream.sessionId}>
|
||||||
|
<span class="text-xs font-mono bg-gray-100 px-1.5 py-0.5 rounded">
|
||||||
|
{stream.sessionName || stream.sessionId}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<div class="flex-1" />
|
||||||
|
<Show when={stream.myName !== "gm"}>
|
||||||
|
<span class="text-xs text-gray-400">扮演 {stream.myName}</span>
|
||||||
|
</Show>
|
||||||
|
<button
|
||||||
|
onClick={handleDisconnect}
|
||||||
|
class="text-xs text-gray-400 hover:text-red-500"
|
||||||
|
title="断开连接"
|
||||||
|
>
|
||||||
|
⏻
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GM session management */}
|
||||||
|
<Show when={stream.myName === "gm"}>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span class="text-xs text-gray-500">会话列表:</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewSession((v) => !v)}
|
||||||
|
class="text-xs text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
+ 新建
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={showNewSession()}>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newSessionName()}
|
||||||
|
onInput={(e) => setNewSessionName(e.currentTarget.value)}
|
||||||
|
placeholder="会话名称"
|
||||||
|
class="flex-1 border border-gray-300 rounded px-2 py-0.5 text-xs"
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleCreateSession()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateSession}
|
||||||
|
class="bg-green-600 text-white rounded px-2 py-0.5 text-xs hover:bg-green-700"
|
||||||
|
>
|
||||||
|
Create Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<For each={Object.entries(manifest().sessions)}>
|
||||||
|
{([id, meta]) => (
|
||||||
|
<div
|
||||||
|
class={`flex items-center gap-1 px-1 py-0.5 rounded cursor-pointer text-xs ${
|
||||||
|
id === stream.sessionId
|
||||||
|
? "bg-blue-100 text-blue-800"
|
||||||
|
: "hover:bg-gray-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex-1 truncate"
|
||||||
|
onClick={() => handleSessionSelect(id)}
|
||||||
|
>
|
||||||
|
{meta.name || id}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeleteSession(id);
|
||||||
|
}}
|
||||||
|
class="text-gray-400 hover:text-red-500 text-xs"
|
||||||
|
title="删除会话"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={error()}>
|
||||||
|
<p class="text-red-500 text-xs">{error()}</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* CreateSessionDialog — modal for naming a new session
|
||||||
|
*/
|
||||||
|
import { Component, createSignal, onMount } from "solid-js";
|
||||||
|
import { createSession, setSessionId, hydrateFromServer } from "../stores/journalStream";
|
||||||
|
|
||||||
|
interface CreateSessionDialogProps {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateSessionDialog: Component<CreateSessionDialogProps> = (
|
||||||
|
props,
|
||||||
|
) => {
|
||||||
|
const [name, setName] = createSignal("");
|
||||||
|
const [creating, setCreating] = createSignal(false);
|
||||||
|
let inputRef!: HTMLInputElement;
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
const trimmed = name().trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
setCreating(true);
|
||||||
|
const id = createSession(trimmed);
|
||||||
|
if (id) {
|
||||||
|
setSessionId(id);
|
||||||
|
// Hydrate the new (empty) session so the UI reflects it immediately
|
||||||
|
await hydrateFromServer(id).catch(() => {});
|
||||||
|
}
|
||||||
|
setName("");
|
||||||
|
setCreating(false);
|
||||||
|
props.onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => inputRef?.focus());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 z-50 flex items-center justify-center bg-black/20"
|
||||||
|
onClick={props.onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-white rounded-lg border border-gray-200 shadow-xl w-[280px] p-4 space-y-3"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-800">新建会话</h3>
|
||||||
|
<button
|
||||||
|
onClick={props.onClose}
|
||||||
|
class="text-gray-400 hover:text-gray-600 text-sm"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500">输入新会话的名称。</p>
|
||||||
|
<input
|
||||||
|
ref={inputRef!}
|
||||||
|
type="text"
|
||||||
|
value={name()}
|
||||||
|
onInput={(e) => setName(e.currentTarget.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleCreate();
|
||||||
|
if (e.key === "Escape") props.onClose();
|
||||||
|
}}
|
||||||
|
placeholder="会话名称"
|
||||||
|
class="w-full border border-gray-300 rounded px-2 py-1.5 text-sm"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<div class="flex justify-end gap-1">
|
||||||
|
<button
|
||||||
|
onClick={props.onClose}
|
||||||
|
class="px-3 py-1 text-xs text-gray-600 hover:text-gray-800"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={!name().trim()}
|
||||||
|
class="bg-blue-600 text-white rounded px-3 py-1 text-xs font-medium hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
创建
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
/**
|
||||||
|
* DynamicForm — renders form inputs from a Zod schema
|
||||||
|
*
|
||||||
|
* Walks the schema's shape and renders appropriate inputs for each field.
|
||||||
|
* Supports: string, number, boolean, enum, array, and nested objects.
|
||||||
|
* Optional fields get a checkbox to toggle inclusion.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, For, Show, createMemo } from "solid-js";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { getMessageType } from "./registry";
|
||||||
|
|
||||||
|
type FormData = Record<string, unknown>;
|
||||||
|
|
||||||
|
interface DynamicFormProps {
|
||||||
|
type: string;
|
||||||
|
value: FormData;
|
||||||
|
onChange: (data: FormData) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DynamicForm: Component<DynamicFormProps> = (props) => {
|
||||||
|
const def = createMemo(() => getMessageType(props.type));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Show when={def()}>
|
||||||
|
{(d) => (
|
||||||
|
<SchemaFields
|
||||||
|
schema={d().schema}
|
||||||
|
value={props.value}
|
||||||
|
onChange={props.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SchemaFields — recursive
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const SchemaFields: Component<{
|
||||||
|
schema: z.ZodTypeAny;
|
||||||
|
value: FormData;
|
||||||
|
onChange: (data: FormData) => void;
|
||||||
|
prefix?: string;
|
||||||
|
}> = (props) => {
|
||||||
|
const shape = unwrapShape(props.schema);
|
||||||
|
|
||||||
|
if (!shape) {
|
||||||
|
return <p class="text-xs text-gray-400">无字段</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<For each={Object.entries(shape)}>
|
||||||
|
{([key, fieldSchema]) => {
|
||||||
|
const def = (fieldSchema as z.ZodDefault<any>)._def;
|
||||||
|
const isOptional = fieldSchema.isOptional?.() ?? false;
|
||||||
|
const fieldType = getFieldType(fieldSchema);
|
||||||
|
|
||||||
|
// Nested object
|
||||||
|
if (fieldType === "object" && fieldSchema instanceof z.ZodObject) {
|
||||||
|
const nestedValue = (props.value[key] as FormData) ?? {};
|
||||||
|
return (
|
||||||
|
<fieldset class="border border-gray-200 rounded px-2 py-1">
|
||||||
|
<legend class="text-xs text-gray-500 px-1">{key}</legend>
|
||||||
|
<SchemaFields
|
||||||
|
schema={fieldSchema}
|
||||||
|
value={nestedValue}
|
||||||
|
onChange={(v) => updateField(props, key, v)}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional toggle
|
||||||
|
const isSet = () => props.value[key] !== undefined;
|
||||||
|
const toggleOptional = () => {
|
||||||
|
if (isSet()) {
|
||||||
|
const next = { ...props.value };
|
||||||
|
delete next[key];
|
||||||
|
props.onChange(next);
|
||||||
|
} else {
|
||||||
|
props.onChange({ ...props.value, [key]: getDefault(fieldSchema) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex items-start gap-1.5">
|
||||||
|
<Show when={isOptional}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSet()}
|
||||||
|
onChange={toggleOptional}
|
||||||
|
class="mt-1.5"
|
||||||
|
title={`包含 ${key}`}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<div class={`flex-1 ${isOptional && !isSet() ? "opacity-40" : ""}`}>
|
||||||
|
<FieldInput
|
||||||
|
label={key}
|
||||||
|
schema={fieldSchema}
|
||||||
|
value={isSet() ? props.value[key] : getDefault(fieldSchema)}
|
||||||
|
onChange={(v) => updateField(props, key, v)}
|
||||||
|
disabled={isOptional && !isSet()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// FieldInput
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const FieldInput: Component<{
|
||||||
|
label: string;
|
||||||
|
schema: z.ZodTypeAny;
|
||||||
|
value: unknown;
|
||||||
|
onChange: (v: unknown) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}> = (props) => {
|
||||||
|
const fieldType = createMemo(() => getFieldType(props.schema));
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"w-full border border-gray-300 rounded px-2 py-1 text-xs disabled:bg-gray-100 disabled:text-gray-400";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<label
|
||||||
|
class="text-xs text-gray-500 w-20 shrink-0 truncate"
|
||||||
|
title={props.label}
|
||||||
|
>
|
||||||
|
{props.label}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Show when={fieldType() === "string"}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={(props.value as string) ?? ""}
|
||||||
|
onInput={(e) => props.onChange(e.currentTarget.value)}
|
||||||
|
disabled={props.disabled}
|
||||||
|
class={inputClass}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={fieldType() === "number"}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={(props.value as number) ?? ""}
|
||||||
|
onInput={(e) =>
|
||||||
|
props.onChange(parseFloat(e.currentTarget.value) || 0)
|
||||||
|
}
|
||||||
|
disabled={props.disabled}
|
||||||
|
class={inputClass}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={fieldType() === "boolean"}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(props.value as boolean) ?? false}
|
||||||
|
onChange={(e) => props.onChange(e.currentTarget.checked)}
|
||||||
|
disabled={props.disabled}
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={fieldType() === "enum"}>
|
||||||
|
<select
|
||||||
|
value={(props.value as string) ?? ""}
|
||||||
|
onChange={(e) => props.onChange(e.currentTarget.value)}
|
||||||
|
disabled={props.disabled}
|
||||||
|
class={inputClass}
|
||||||
|
>
|
||||||
|
<For each={getEnumValues(props.schema)}>
|
||||||
|
{(v) => <option value={v}>{v}</option>}
|
||||||
|
</For>
|
||||||
|
</select>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={fieldType() === "array"}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={
|
||||||
|
Array.isArray(props.value) ? (props.value as any[]).join(", ") : ""
|
||||||
|
}
|
||||||
|
onInput={(e) => {
|
||||||
|
const raw = e.currentTarget.value;
|
||||||
|
const parsed = raw
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((s) => (isNaN(Number(s)) ? s : Number(s)));
|
||||||
|
props.onChange(parsed);
|
||||||
|
}}
|
||||||
|
disabled={props.disabled}
|
||||||
|
class={inputClass}
|
||||||
|
placeholder="逗号分隔"
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function unwrapShape(
|
||||||
|
schema: z.ZodTypeAny,
|
||||||
|
): Record<string, z.ZodTypeAny> | null {
|
||||||
|
try {
|
||||||
|
if (schema instanceof z.ZodObject) {
|
||||||
|
return schema.shape as Record<string, z.ZodTypeAny>;
|
||||||
|
}
|
||||||
|
// Unwrap optional/branded/etc
|
||||||
|
const inner = (schema as any)?._def?.innerType;
|
||||||
|
if (inner) return unwrapShape(inner);
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FieldType =
|
||||||
|
"string" | "number" | "boolean" | "enum" | "array" | "object" | "unknown";
|
||||||
|
|
||||||
|
function getFieldType(schema: z.ZodTypeAny): FieldType {
|
||||||
|
if (schema instanceof z.ZodString) return "string";
|
||||||
|
if (schema instanceof z.ZodNumber) return "number";
|
||||||
|
if (schema instanceof z.ZodBoolean) return "boolean";
|
||||||
|
if (schema instanceof z.ZodEnum) return "enum";
|
||||||
|
// Zod v4: nativeEnum returns ZodEnum, catch with duck-type check
|
||||||
|
if (isZodEnum(schema)) return "enum";
|
||||||
|
if (schema instanceof z.ZodArray) return "array";
|
||||||
|
if (schema instanceof z.ZodObject) return "object";
|
||||||
|
// Unwrap optional
|
||||||
|
const inner = (schema as any)?._def?.innerType;
|
||||||
|
if (inner) return getFieldType(inner);
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Duck-type check: Zod v4's nativeEnum returns a ZodEnum instance */
|
||||||
|
function isZodEnum(schema: z.ZodTypeAny): boolean {
|
||||||
|
return (schema as any)?._def?.typeName === "ZodEnum";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEnumValues(schema: z.ZodTypeAny): string[] {
|
||||||
|
try {
|
||||||
|
if (schema instanceof z.ZodEnum) return schema.options as string[];
|
||||||
|
if (isZodEnum(schema) && (schema as any).enum) {
|
||||||
|
return Object.values((schema as any).enum).filter(
|
||||||
|
(v: unknown) => typeof v === "string",
|
||||||
|
) as string[];
|
||||||
|
}
|
||||||
|
const inner = (schema as any)?._def?.innerType;
|
||||||
|
if (inner) return getEnumValues(inner);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefault(schema: z.ZodTypeAny): unknown {
|
||||||
|
const ft = getFieldType(schema);
|
||||||
|
switch (ft) {
|
||||||
|
case "string":
|
||||||
|
return "";
|
||||||
|
case "number":
|
||||||
|
return 0;
|
||||||
|
case "boolean":
|
||||||
|
return false;
|
||||||
|
case "array":
|
||||||
|
return [];
|
||||||
|
case "object":
|
||||||
|
return {};
|
||||||
|
case "enum":
|
||||||
|
return getEnumValues(schema)[0] ?? "";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateField(
|
||||||
|
parent: { value: FormData; onChange: (data: FormData) => void },
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
): void {
|
||||||
|
parent.onChange({ ...parent.value, [key]: value });
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* ErrorPopup — shows the full error message in a floating panel above
|
||||||
|
* the journal input area. Toggled by clicking the truncated error text.
|
||||||
|
*/
|
||||||
|
import { Component, Show } from "solid-js";
|
||||||
|
|
||||||
|
interface ErrorPopupProps {
|
||||||
|
message: string | null;
|
||||||
|
show: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ErrorPopup: Component<ErrorPopupProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<Show when={props.show && props.message}>
|
||||||
|
<div class="absolute bottom-full left-2 right-2 mb-1 bg-red-50 border border-red-200 rounded shadow-lg z-50">
|
||||||
|
<div class="flex items-start justify-between px-2 pt-2 pb-1">
|
||||||
|
<p class="text-red-700 text-xs break-words">{props.message}</p>
|
||||||
|
<button
|
||||||
|
onClick={props.onClose}
|
||||||
|
class="text-red-400 hover:text-red-600 text-xs leading-none ml-2 shrink-0"
|
||||||
|
title="关闭"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* InviteDialog — input player name, copy invite link
|
||||||
|
*/
|
||||||
|
import { Component, createSignal } from "solid-js";
|
||||||
|
import { useJournalStream } from "../stores/journalStream";
|
||||||
|
|
||||||
|
interface InviteDialogProps {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InviteDialog: Component<InviteDialogProps> = (props) => {
|
||||||
|
const stream = useJournalStream();
|
||||||
|
const [playerName, setPlayerName] = createSignal("");
|
||||||
|
const [copied, setCopied] = createSignal(false);
|
||||||
|
|
||||||
|
const inviteLink = () => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set("session", stream.sessionId || "default");
|
||||||
|
if (playerName().trim()) {
|
||||||
|
url.searchParams.set("player", playerName().trim());
|
||||||
|
}
|
||||||
|
url.searchParams.set("autojoin", "1");
|
||||||
|
return url.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(inviteLink());
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
const input = document.getElementById(
|
||||||
|
"invite-link-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
if (input) input.select();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 z-50 flex items-center justify-center bg-black/20"
|
||||||
|
onClick={props.onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-white rounded-lg border border-gray-200 shadow-xl w-[280px] p-4 space-y-3"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-800">邀请玩家</h3>
|
||||||
|
<button
|
||||||
|
onClick={props.onClose}
|
||||||
|
class="text-gray-400 hover:text-gray-600 text-sm"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
输入玩家名字并分享链接,对方将以玩家身份加入。
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={playerName()}
|
||||||
|
onInput={(e) => setPlayerName(e.currentTarget.value)}
|
||||||
|
placeholder="玩家名字"
|
||||||
|
class="w-full border border-gray-300 rounded px-2 py-1 text-xs"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<input
|
||||||
|
id="invite-link-input"
|
||||||
|
type="text"
|
||||||
|
value={inviteLink()}
|
||||||
|
readonly
|
||||||
|
class="flex-1 border border-gray-200 rounded px-2 py-1 text-xs bg-gray-50 text-gray-600 font-mono truncate"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
class={`shrink-0 rounded px-2 py-1 text-xs font-medium transition-colors ${
|
||||||
|
copied()
|
||||||
|
? "bg-green-100 text-green-700"
|
||||||
|
: "bg-blue-600 text-white hover:bg-blue-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{copied() ? "已复制!" : "复制"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Journal context — provides `onClose` to message renderers so that
|
||||||
|
* clicking a link message can close the panel on mobile.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createContext, useContext } from "solid-js";
|
||||||
|
|
||||||
|
export interface JournalContextValue {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const JournalContext = createContext<JournalContextValue>();
|
||||||
|
|
||||||
|
export function useJournalContext(): JournalContextValue | undefined {
|
||||||
|
return useContext(JournalContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { JournalContext };
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* JournalHeader — status dot, player name, session dropdown, disconnect & close
|
||||||
|
*/
|
||||||
|
import { Component, Show } from "solid-js";
|
||||||
|
import { useJournalStream, disconnectStream } from "../stores/journalStream";
|
||||||
|
import { SessionDropdown } from "./SessionDropdown";
|
||||||
|
|
||||||
|
interface JournalHeaderProps {
|
||||||
|
onClose: () => void;
|
||||||
|
onCreateSession: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const JournalHeader: Component<JournalHeaderProps> = (props) => {
|
||||||
|
const stream = useJournalStream();
|
||||||
|
|
||||||
|
const handleDisconnect = () => disconnectStream();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 shrink-0">
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<span
|
||||||
|
class="w-2 h-2 rounded-full shrink-0"
|
||||||
|
classList={{
|
||||||
|
"bg-gray-400": stream.connectionStatus === "disconnected",
|
||||||
|
"bg-yellow-400 animate-pulse":
|
||||||
|
stream.connectionStatus === "connecting",
|
||||||
|
"bg-green-500": stream.connectionStatus === "connected",
|
||||||
|
"bg-red-500": stream.connectionStatus === "error",
|
||||||
|
}}
|
||||||
|
title={stream.connectionStatus}
|
||||||
|
/>
|
||||||
|
<Show
|
||||||
|
when={stream.connected && stream.sessionId}
|
||||||
|
fallback={
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 truncate">未连接</h2>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-semibold text-gray-700 truncate leading-tight flex items-center gap-1">
|
||||||
|
{stream.myName}
|
||||||
|
<Show when={stream.myRole !== "gm"}>
|
||||||
|
<span
|
||||||
|
class={`text-[10px] px-1 rounded ${
|
||||||
|
stream.myRole === "observer"
|
||||||
|
? "bg-gray-200 text-gray-500"
|
||||||
|
: "bg-blue-100 text-blue-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{stream.myRole}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</p>
|
||||||
|
{/* Clickable subtitle — session dropdown (GM only) */}
|
||||||
|
<Show when={stream.myRole === "gm"}>
|
||||||
|
<SessionDropdown onCreate={props.onCreateSession} />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
|
<Show when={stream.connected}>
|
||||||
|
<button
|
||||||
|
onClick={handleDisconnect}
|
||||||
|
class="text-xs text-gray-400 hover:text-red-500 px-1"
|
||||||
|
title="断开连接"
|
||||||
|
>
|
||||||
|
⏻
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
<button
|
||||||
|
onClick={props.onClose}
|
||||||
|
class="text-gray-400 hover:text-gray-600 text-sm px-1"
|
||||||
|
title="隐藏面板"
|
||||||
|
>
|
||||||
|
▸
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
/**
|
||||||
|
* JournalInput — chat-style textarea with command dispatch and autocomplete.
|
||||||
|
*
|
||||||
|
* - Enter sends, Shift+Enter inserts newline
|
||||||
|
* - Plain text → "chat" type
|
||||||
|
* - `/roll 3d6kh1` → "roll" type (result resolved client-side by GM)
|
||||||
|
* - `/link path#section` → "link" type
|
||||||
|
* - `/stat set key=value` → "stat" type
|
||||||
|
* - `/` alone opens completions dropdown
|
||||||
|
*
|
||||||
|
* Delegates command dispatch to the shared command-dispatcher module.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, createSignal, createEffect, onMount, Show } from "solid-js";
|
||||||
|
import { sendMessage, useJournalStream } from "../stores/journalStream";
|
||||||
|
import { actionPrefill, setActionPrefill } from "../stores/reveal";
|
||||||
|
import { useJournalCompletions, ensureCompletions } from "./completions";
|
||||||
|
import { parseInput } from "./command-parser";
|
||||||
|
import type { CompletionItem } from "./command-parser";
|
||||||
|
import { buildCompletions } from "./command-completions";
|
||||||
|
import { CompletionsDropdown } from "./CompletionsDropdown";
|
||||||
|
import { ErrorPopup } from "./ErrorPopup";
|
||||||
|
import { dispatchCommand, dispatchError, setDispatchError } from "./command-dispatcher";
|
||||||
|
|
||||||
|
// ---- Component ----
|
||||||
|
|
||||||
|
export const JournalInput: Component = () => {
|
||||||
|
const stream = useJournalStream();
|
||||||
|
const comp = useJournalCompletions();
|
||||||
|
|
||||||
|
const isObserver = () => stream.myRole === "observer";
|
||||||
|
const isPlayer = () => stream.myRole === "player";
|
||||||
|
const isGm = () => stream.myRole === "gm";
|
||||||
|
|
||||||
|
const [text, setText] = createSignal("");
|
||||||
|
const [showErrorPopup, setShowErrorPopup] = createSignal(false);
|
||||||
|
const [sending, setSending] = createSignal(false);
|
||||||
|
const [showCompletions, setShowCompletions] = createSignal(false);
|
||||||
|
const [selectedIdx, setSelectedIdx] = createSignal(0);
|
||||||
|
|
||||||
|
let textareaRef!: HTMLTextAreaElement;
|
||||||
|
|
||||||
|
// Ensure completions are loading on mount
|
||||||
|
onMount(() => {
|
||||||
|
void ensureCompletions();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for action prefill requests from article buttons
|
||||||
|
createEffect(() => {
|
||||||
|
const action = actionPrefill();
|
||||||
|
if (action) {
|
||||||
|
setText(`${action.command} ${action.text}`);
|
||||||
|
setActionPrefill(null);
|
||||||
|
textareaRef?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Derived ----
|
||||||
|
|
||||||
|
const completions = () =>
|
||||||
|
buildCompletions(text().trim(), {
|
||||||
|
data: comp.data,
|
||||||
|
isGm: isGm(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Send ----
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
const raw = text().trim();
|
||||||
|
if (!raw) return;
|
||||||
|
|
||||||
|
// Observers: everything is plain chat
|
||||||
|
if (isObserver()) {
|
||||||
|
const result = sendMessage("chat", { text: raw });
|
||||||
|
const r = unwrapSendResult(result);
|
||||||
|
finish(r.ok, r.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseInput(raw);
|
||||||
|
if (parsed.error) {
|
||||||
|
setDispatchError(parsed.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDispatchError(null);
|
||||||
|
|
||||||
|
// Players: chat + set commands only
|
||||||
|
if (isPlayer()) {
|
||||||
|
if (parsed.type === "chat") {
|
||||||
|
const result = sendMessage("chat", { text: raw });
|
||||||
|
const r = unwrapSendResult(result);
|
||||||
|
finish(r.ok, r.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.type !== "set" && parsed.type !== "rolltag") {
|
||||||
|
setDispatchError("玩家只能发送聊天消息或使用 /set 命令");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GM: all commands, Player: set commands
|
||||||
|
setSending(true);
|
||||||
|
|
||||||
|
await dispatchCommand({
|
||||||
|
role: stream.myRole as "gm" | "player" | "observer",
|
||||||
|
myName: stream.myName,
|
||||||
|
command: raw,
|
||||||
|
sparkTables: comp.data.sparkTables,
|
||||||
|
variables: stream.variables,
|
||||||
|
declarations: comp.data.declarations,
|
||||||
|
tagModifiers: comp.data.tagModifiers,
|
||||||
|
});
|
||||||
|
|
||||||
|
// dispatchCommand already set the shared error if it failed.
|
||||||
|
// Only clear text on success (no error is shown).
|
||||||
|
if (!dispatchError()) {
|
||||||
|
setText("");
|
||||||
|
}
|
||||||
|
setSending(false);
|
||||||
|
textareaRef?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear text or set shared dispatch error on failure. */
|
||||||
|
function finish(success: boolean, err?: string) {
|
||||||
|
if (success) {
|
||||||
|
setText("");
|
||||||
|
setDispatchError(null);
|
||||||
|
} else if (err) {
|
||||||
|
setDispatchError(err);
|
||||||
|
}
|
||||||
|
setSending(false);
|
||||||
|
textareaRef?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unwrap a sendMessage result into (success, error?) for finish(). */
|
||||||
|
function unwrapSendResult<R>(
|
||||||
|
r: { success: true; msg: R } | { success: false; error: string },
|
||||||
|
) {
|
||||||
|
return r.success
|
||||||
|
? ({ ok: true, error: undefined } as const)
|
||||||
|
: ({ ok: false, error: r.error } as const);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Completions ----
|
||||||
|
|
||||||
|
function acceptCompletion(item: CompletionItem) {
|
||||||
|
if (item.kind === "no-results") return;
|
||||||
|
setText(item.insertText);
|
||||||
|
setShowCompletions(false);
|
||||||
|
textareaRef?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCompletion(dir: "up" | "down") {
|
||||||
|
const comps = completions();
|
||||||
|
if (comps.length === 0) return;
|
||||||
|
setSelectedIdx((prev) => {
|
||||||
|
if (dir === "down") return (prev + 1) % comps.length;
|
||||||
|
return (prev - 1 + comps.length) % comps.length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCompletions() {
|
||||||
|
setShowCompletions(true);
|
||||||
|
setSelectedIdx(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Keyboard ----
|
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
const comps = completions();
|
||||||
|
const open = showCompletions();
|
||||||
|
|
||||||
|
if (e.key === "Tab") {
|
||||||
|
if (open) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (comps.length > 0 && comps[selectedIdx()].kind !== "no-results") {
|
||||||
|
acceptCompletion(comps[selectedIdx()]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const raw = text();
|
||||||
|
if ((isGm() || raw.startsWith("/set")) && raw.startsWith("/")) {
|
||||||
|
e.preventDefault();
|
||||||
|
openCompletions();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (open && comps.length > 0) {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
selectCompletion("down");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
selectCompletion("up");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowCompletions(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setShowCompletions(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput(e: InputEvent) {
|
||||||
|
const input = e.currentTarget as HTMLTextAreaElement;
|
||||||
|
const raw = input.value;
|
||||||
|
setText(raw);
|
||||||
|
|
||||||
|
if ((isGm() || raw.startsWith("/set")) && raw.startsWith("/")) {
|
||||||
|
setShowCompletions(true);
|
||||||
|
setSelectedIdx(0);
|
||||||
|
} else {
|
||||||
|
setShowCompletions(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-resize
|
||||||
|
textareaRef.style.height = "auto";
|
||||||
|
textareaRef.style.height = Math.min(textareaRef.scrollHeight, 150) + "px";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Render ----
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="border-t border-gray-200 bg-white relative">
|
||||||
|
<CompletionsDropdown
|
||||||
|
show={showCompletions()}
|
||||||
|
items={completions()}
|
||||||
|
selectedIdx={selectedIdx()}
|
||||||
|
onSelect={acceptCompletion}
|
||||||
|
onHover={(idx) => setSelectedIdx(idx)}
|
||||||
|
onClose={() => setShowCompletions(false)}
|
||||||
|
state={comp.state}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<Show
|
||||||
|
when={!isObserver()}
|
||||||
|
fallback={
|
||||||
|
<div class="px-3 py-4 text-xs text-gray-400 italic text-center">
|
||||||
|
你正在观察模式。在另一台设备上打开此面板以主持人或玩家身份加入。
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
id="journal-input-textarea"
|
||||||
|
value={text()}
|
||||||
|
onInput={handleInput}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={
|
||||||
|
isGm()
|
||||||
|
? "输入消息,或使用 /roll、/link、/set 命令..."
|
||||||
|
: "输入消息,或使用 /set 命令..."
|
||||||
|
}
|
||||||
|
rows={2}
|
||||||
|
class="w-full resize-none border-0 px-3 pt-2.5 pb-1 text-sm
|
||||||
|
text-gray-800 placeholder-gray-400 focus:outline-none
|
||||||
|
bg-transparent min-h-15"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between px-2 pb-2">
|
||||||
|
<Show when={dispatchError()}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowErrorPopup((v) => !v)}
|
||||||
|
class="text-red-500 text-xs truncate max-w-[60%] text-left hover:underline"
|
||||||
|
title={dispatchError() ?? undefined}
|
||||||
|
>
|
||||||
|
{dispatchError()}
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
<div class="flex items-center gap-1 ml-auto">
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={sending() || !text().trim()}
|
||||||
|
class="bg-blue-600 text-white rounded px-3 py-1 text-xs
|
||||||
|
hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed
|
||||||
|
transition-colors"
|
||||||
|
>
|
||||||
|
发送
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error popup */}
|
||||||
|
<ErrorPopup
|
||||||
|
message={dispatchError()}
|
||||||
|
show={showErrorPopup()}
|
||||||
|
onClose={() => setShowErrorPopup(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* JournalPanel — right panel container for the journal stream
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, Show, createSignal, For } from "solid-js";
|
||||||
|
import { useJournalStream } from "../stores/journalStream";
|
||||||
|
import { StreamView } from "./StreamView";
|
||||||
|
import { JournalInput } from "./JournalInput";
|
||||||
|
import { JournalContext } from "./JournalContext";
|
||||||
|
import { JournalHeader } from "./JournalHeader";
|
||||||
|
import { ConnectDialog } from "./ConnectDialog";
|
||||||
|
import { InviteDialog } from "./InviteDialog";
|
||||||
|
import { CreateSessionDialog } from "./CreateSessionDialog";
|
||||||
|
import { VariableView } from "./VariableView";
|
||||||
|
|
||||||
|
export interface JournalPanelProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
||||||
|
const stream = useJournalStream();
|
||||||
|
const [showInvite, setShowInvite] = createSignal(false);
|
||||||
|
const [showCreateSession, setShowCreateSession] = createSignal(false);
|
||||||
|
const [viewMode, setViewMode] = createSignal<"stream" | "variables">("stream");
|
||||||
|
|
||||||
|
// Player list (exclude gm and observer)
|
||||||
|
const playerEntries = () =>
|
||||||
|
Object.entries(stream.players).filter(
|
||||||
|
([_, p]) => p.role !== "gm" && p.role !== "observer",
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mobile backdrop — overlay that appears below the header */}
|
||||||
|
<div
|
||||||
|
class="fixed top-16 inset-x-0 bottom-0 bg-black/30 z-40 md:hidden"
|
||||||
|
classList={{ hidden: !props.open }}
|
||||||
|
onClick={props.onClose}
|
||||||
|
/>
|
||||||
|
{/* Panel — on mobile: fixed overlay; on desktop: fills the parent wrapper */}
|
||||||
|
<aside
|
||||||
|
class={`fixed top-16 right-0 bottom-0 z-50 bg-white
|
||||||
|
flex flex-col w-full h-full max-w-md shadow-lg
|
||||||
|
transition-transform duration-300 ease-in-out
|
||||||
|
md:static md:inset-auto md:w-105 md:max-w-none md:shadow-none md:border-l md:border-gray-200
|
||||||
|
${props.open ? "translate-x-0" : "translate-x-full"}`}
|
||||||
|
inert={!props.open}
|
||||||
|
>
|
||||||
|
<JournalContext.Provider value={{ onClose: props.onClose }}>
|
||||||
|
{/* Header */}
|
||||||
|
<JournalHeader
|
||||||
|
onClose={props.onClose}
|
||||||
|
onCreateSession={() => setShowCreateSession(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Player list tabs */}
|
||||||
|
<Show when={stream.connected}>
|
||||||
|
<div class="flex items-center gap-1 px-3 py-1.5 border-b border-gray-100 bg-gray-50 overflow-x-auto shrink-0">
|
||||||
|
<For each={playerEntries()}>
|
||||||
|
{([name]) => (
|
||||||
|
<span class="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full whitespace-nowrap">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowInvite(true)}
|
||||||
|
class="text-xs text-gray-400 hover:text-blue-600 px-1.5 py-0.5 rounded border border-dashed border-gray-300 hover:border-blue-400 whitespace-nowrap shrink-0"
|
||||||
|
title="邀请玩家"
|
||||||
|
>
|
||||||
|
+ 邀请
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show
|
||||||
|
when={stream.connected}
|
||||||
|
fallback={
|
||||||
|
<div class="flex-1 flex items-center justify-center p-4 overflow-y-auto">
|
||||||
|
<ConnectDialog />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* View toggle */}
|
||||||
|
<div class="flex items-center border-b border-gray-200 bg-gray-50 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode("stream")}
|
||||||
|
class={`flex-1 text-xs py-1.5 transition-colors ${
|
||||||
|
viewMode() === "stream"
|
||||||
|
? "bg-white text-blue-600 font-medium border-b-2 border-blue-600"
|
||||||
|
: "text-gray-500 hover:text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
消息流
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode("variables")}
|
||||||
|
class={`flex-1 text-xs py-1.5 transition-colors ${
|
||||||
|
viewMode() === "variables"
|
||||||
|
? "bg-white text-blue-600 font-medium border-b-2 border-blue-600"
|
||||||
|
: "text-gray-500 hover:text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
变量
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-h-0">
|
||||||
|
<Show when={viewMode() === "stream"} fallback={<VariableView />}>
|
||||||
|
<StreamView />
|
||||||
|
</Show>
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* ReactiveVariableManager — mounts as a child of <Article> and reactively
|
||||||
|
* replaces {{$key}} template patterns in the rendered markdown DOM with
|
||||||
|
* live variable values from the journal stream.
|
||||||
|
*
|
||||||
|
* On mount, walks all text nodes in the article content looking for
|
||||||
|
* {{$key}} placeholders. Each match is wrapped in a <span> with a
|
||||||
|
* data-reactive-var attribute so that subsequent updates (driven by
|
||||||
|
* a Solid effect) only touch those spans.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, createEffect, onCleanup } from "solid-js";
|
||||||
|
import { useArticleDom } from "../Article";
|
||||||
|
import { useJournalStream } from "../stores/journalStream";
|
||||||
|
|
||||||
|
const TEMPLATE_RE = /\{\{(\$[a-zA-Z_][a-zA-Z0-9_]*)\}\}/g;
|
||||||
|
|
||||||
|
/** Attribute storing the original template text on marker spans. */
|
||||||
|
const ATTR_TEMPLATE = "data-reactive-var";
|
||||||
|
|
||||||
|
/** Attribute storing pre-computed key list (comma-separated) for fast updates. */
|
||||||
|
const ATTR_KEYS = "data-reactive-keys";
|
||||||
|
|
||||||
|
export const ReactiveVariableManager: Component = () => {
|
||||||
|
const contentDom = useArticleDom();
|
||||||
|
const stream = useJournalStream();
|
||||||
|
|
||||||
|
const values = () => stream.variables;
|
||||||
|
|
||||||
|
// ---- Initial scan: wrap {{$key}} text in marker spans ----
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const dom = contentDom();
|
||||||
|
if (!dom) return;
|
||||||
|
|
||||||
|
// Only scan once — after the first scan, the spans exist and we
|
||||||
|
// switch to the reactive update effect below.
|
||||||
|
if (dom.querySelector(`[${ATTR_TEMPLATE}]`)) return;
|
||||||
|
|
||||||
|
scanAndWrap(dom);
|
||||||
|
});
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
const dom = contentDom();
|
||||||
|
if (dom) unwrapAll(dom);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Reactive updates: whenever variable values change, update all spans ----
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const dom = contentDom();
|
||||||
|
if (!dom) return;
|
||||||
|
|
||||||
|
const v = values();
|
||||||
|
const spans = dom.querySelectorAll<HTMLElement>(`[${ATTR_TEMPLATE}]`);
|
||||||
|
|
||||||
|
for (const span of spans) {
|
||||||
|
const template = span.getAttribute(ATTR_TEMPLATE) ?? "";
|
||||||
|
const keys = span.getAttribute(ATTR_KEYS)?.split(",") ?? [];
|
||||||
|
let result = template;
|
||||||
|
for (const key of keys) {
|
||||||
|
result = result.replaceAll(`{{${key}}}`, v[key] ?? "");
|
||||||
|
}
|
||||||
|
if (span.textContent !== result) {
|
||||||
|
span.textContent = result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return null; // renders nothing — works purely via DOM manipulation
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DOM helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk all text nodes in a subtree and wrap `{{$key}}` patterns in
|
||||||
|
* `<span data-reactive-var="originalText">` elements.
|
||||||
|
*/
|
||||||
|
function scanAndWrap(root: Element): void {
|
||||||
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||||
|
const replacements: { node: Text; html: string }[] = [];
|
||||||
|
|
||||||
|
let textNode: Text | null;
|
||||||
|
while ((textNode = walker.nextNode() as Text | null)) {
|
||||||
|
const text = textNode.textContent;
|
||||||
|
if (!text) continue;
|
||||||
|
|
||||||
|
TEMPLATE_RE.lastIndex = 0;
|
||||||
|
if (!TEMPLATE_RE.test(text)) continue;
|
||||||
|
|
||||||
|
// Build replacement HTML: split on {{$key}}, wrap each match in a span
|
||||||
|
// with pre-computed key for fast updates.
|
||||||
|
TEMPLATE_RE.lastIndex = 0;
|
||||||
|
const parts: string[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = TEMPLATE_RE.exec(text)) !== null) {
|
||||||
|
if (m.index > lastIndex) {
|
||||||
|
parts.push(escapeHtml(text.slice(lastIndex, m.index)));
|
||||||
|
}
|
||||||
|
const key = m[1]; // includes $ prefix
|
||||||
|
parts.push(
|
||||||
|
`<span ${ATTR_TEMPLATE}="${escapeAttr(m[0])}" ${ATTR_KEYS}="${escapeAttr(key)}">${escapeHtml(m[0])}</span>`,
|
||||||
|
);
|
||||||
|
lastIndex = TEMPLATE_RE.lastIndex;
|
||||||
|
}
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
parts.push(escapeHtml(text.slice(lastIndex)));
|
||||||
|
}
|
||||||
|
|
||||||
|
replacements.push({ node: textNode, html: parts.join("") });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { node, html } of replacements) {
|
||||||
|
const wrapper = document.createElement("span");
|
||||||
|
wrapper.innerHTML = html;
|
||||||
|
node.replaceWith(wrapper);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse scanAndWrap — remove marker spans, restoring plain text.
|
||||||
|
* Called on cleanup so subsequent remounts get a clean slate.
|
||||||
|
*/
|
||||||
|
function unwrapAll(root: Element): void {
|
||||||
|
const spans = root.querySelectorAll<HTMLElement>(`[${ATTR_TEMPLATE}]`);
|
||||||
|
// Process in reverse to avoid DOM mutation issues
|
||||||
|
for (let i = spans.length - 1; i >= 0; i--) {
|
||||||
|
const span = spans[i];
|
||||||
|
const parent = span.parentNode;
|
||||||
|
if (!parent) continue;
|
||||||
|
const text = span.getAttribute(ATTR_TEMPLATE) ?? span.textContent ?? "";
|
||||||
|
span.replaceWith(document.createTextNode(text));
|
||||||
|
}
|
||||||
|
// Normalize to merge adjacent text nodes
|
||||||
|
root.normalize();
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttr(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReactiveVariableManager;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* StreamMessage — single message card in the stream
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, Show, createMemo } from "solid-js";
|
||||||
|
import type { StreamMessage as StreamMessageType } from "./registry";
|
||||||
|
import { getMessageType } from "./registry";
|
||||||
|
import { revertLatest, useJournalStream } from "../stores/journalStream";
|
||||||
|
|
||||||
|
export const StreamMessageCard: Component<{
|
||||||
|
message: StreamMessageType;
|
||||||
|
}> = (props) => {
|
||||||
|
const stream = useJournalStream();
|
||||||
|
const def = createMemo(() => getMessageType(props.message.type));
|
||||||
|
const rendered = createMemo(() =>
|
||||||
|
def()?.render?.(props.message.payload, props.message as any),
|
||||||
|
);
|
||||||
|
|
||||||
|
const time = createMemo(() => {
|
||||||
|
const d = new Date(props.message.timestamp);
|
||||||
|
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
|
});
|
||||||
|
|
||||||
|
const isMine = () => props.message.sender === stream.myName;
|
||||||
|
const latestSeq = () => stream.senderSeq[stream.myName] ?? 0;
|
||||||
|
const canRevertThis = () =>
|
||||||
|
isMine() && props.message.seq === latestSeq() && !props.message.reverted;
|
||||||
|
|
||||||
|
const senderClass = () =>
|
||||||
|
props.message.sender === "gm"
|
||||||
|
? "text-purple-600 font-semibold"
|
||||||
|
: "text-blue-600 font-semibold";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show
|
||||||
|
when={!props.message.reverted}
|
||||||
|
fallback={<RevealedCard msg={props.message} />}
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div class="flex items-center gap-1.5 px-2.5 py-1 bg-gray-50 border-b border-gray-100">
|
||||||
|
<span class={`text-xs ${senderClass()}`}>{props.message.sender}</span>
|
||||||
|
<span class="text-xs text-gray-400">{time()}</span>
|
||||||
|
<Show when={canRevertThis()}>
|
||||||
|
<button
|
||||||
|
onClick={() => revertLatest()}
|
||||||
|
class="text-xs text-gray-300 hover:text-red-500 ml-auto px-0.5 leading-none"
|
||||||
|
title="撤回此消息"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
<span
|
||||||
|
class="text-xs text-gray-300 bg-gray-100 px-1 rounded"
|
||||||
|
classList={{ "ml-auto": !canRevertThis() }}
|
||||||
|
>
|
||||||
|
{props.message.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div class="px-2.5 py-1.5 text-sm text-gray-800">
|
||||||
|
<Show
|
||||||
|
when={rendered()}
|
||||||
|
fallback={<JsonBody payload={props.message.payload} />}
|
||||||
|
>
|
||||||
|
{rendered()}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Fallback: render payload as formatted JSON */
|
||||||
|
const JsonBody: Component<{ payload: unknown }> = (props) => {
|
||||||
|
const text = () => JSON.stringify(props.payload, null, 2);
|
||||||
|
return (
|
||||||
|
<pre class="text-xs text-gray-600 whitespace-pre-wrap font-mono">
|
||||||
|
{text()}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Reverted card: struck-through, grey */
|
||||||
|
const RevealedCard: Component<{ msg: StreamMessageType }> = (props) => {
|
||||||
|
return (
|
||||||
|
<div class="bg-gray-100 rounded-lg border border-gray-200 opacity-50 px-2.5 py-1.5">
|
||||||
|
<span class="text-xs text-gray-400 line-through">
|
||||||
|
{props.msg.sender} — 已撤回
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* StreamView — scrollable message log
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, For, createEffect, onMount } from "solid-js";
|
||||||
|
import { visibleMessages, useJournalStream } from "../stores/journalStream";
|
||||||
|
import { StreamMessageCard } from "./StreamMessage";
|
||||||
|
|
||||||
|
export const StreamView: Component = () => {
|
||||||
|
const stream = useJournalStream();
|
||||||
|
let containerRef: HTMLDivElement | undefined;
|
||||||
|
|
||||||
|
// Auto-scroll to bottom on new messages
|
||||||
|
const messages = () => visibleMessages();
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
void messages().length; // track
|
||||||
|
if (!containerRef) return;
|
||||||
|
containerRef.scrollTop = containerRef.scrollHeight;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
class="h-full overflow-y-auto px-3 py-2 space-y-2 bg-gray-50"
|
||||||
|
>
|
||||||
|
<For each={messages()}>
|
||||||
|
{(msg) => <StreamMessageCard message={msg} />}
|
||||||
|
</For>
|
||||||
|
<Show when={messages().length === 0}>
|
||||||
|
<p class="text-center text-gray-400 text-xs py-8">
|
||||||
|
{stream.connected
|
||||||
|
? "暂无消息,发送点什么开始吧。"
|
||||||
|
: "连接会话以查看消息。"}
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
import { Show } from "solid-js";
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* VariableView — flat list of all variable key-value pairs.
|
||||||
|
*
|
||||||
|
* Hovering a value shows a popup with:
|
||||||
|
* - Declaration expression (if any), styled distinctly
|
||||||
|
* - Active tag modifiers: #tag +N (from $source)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, For, createMemo, Show, createSignal } from "solid-js";
|
||||||
|
import { useJournalStream } from "../stores/journalStream";
|
||||||
|
import { useJournalCompletions } from "./completions";
|
||||||
|
import { getMods, getDeclExpr } from "./var-reactivity";
|
||||||
|
|
||||||
|
export const VariableView: Component = () => {
|
||||||
|
const stream = useJournalStream();
|
||||||
|
const comp = useJournalCompletions();
|
||||||
|
|
||||||
|
const values = createMemo(() => stream.variables);
|
||||||
|
|
||||||
|
/** Flat list of all variables */
|
||||||
|
const items = createMemo(() => {
|
||||||
|
const vars = values();
|
||||||
|
return Object.entries(vars).map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
isTag: value.startsWith("#"),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasAnyData = () =>
|
||||||
|
comp.data.declarations.length > 0 || Object.keys(values()).length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="h-full overflow-y-auto bg-gray-50">
|
||||||
|
<Show
|
||||||
|
when={hasAnyData()}
|
||||||
|
fallback={
|
||||||
|
<p class="text-center text-gray-400 text-xs py-8">
|
||||||
|
暂无变量定义。在文档中使用 ```csv role=declare 代码块定义变量。
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="divide-y divide-gray-100">
|
||||||
|
<For each={items()}>
|
||||||
|
{(item) => (
|
||||||
|
<VariableRow
|
||||||
|
key={item.key}
|
||||||
|
value={item.value}
|
||||||
|
isTag={item.isTag}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// VariableRow — single row with hover popup
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const VariableRow: Component<{
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
isTag: boolean;
|
||||||
|
}> = (props) => {
|
||||||
|
const [hovered, setHovered] = createSignal(false);
|
||||||
|
|
||||||
|
const declExpr = createMemo(() => getDeclExpr(props.key));
|
||||||
|
const mods = createMemo(() => getMods(props.key));
|
||||||
|
|
||||||
|
const hasPopup = () => !!declExpr() || mods().length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="relative flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors"
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
>
|
||||||
|
<span class="flex-1 text-sm font-mono text-gray-800">{props.key}</span>
|
||||||
|
<span
|
||||||
|
class={props.isTag
|
||||||
|
? "text-sm font-mono text-purple-700 font-semibold"
|
||||||
|
: "text-sm font-mono text-gray-700 font-semibold"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{props.value}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Hover popup */}
|
||||||
|
<Show when={hasPopup() && hovered()}>
|
||||||
|
<div class="absolute right-0 top-full mt-1 z-50 bg-white border border-gray-200 rounded-md shadow-lg p-2 min-w-45 text-xs">
|
||||||
|
{/* Declaration expression */}
|
||||||
|
<Show when={declExpr()}>
|
||||||
|
<div class="mb-1">
|
||||||
|
<span class="text-gray-400">expr: </span>
|
||||||
|
<span class="font-mono text-gray-500 bg-gray-50 px-1 rounded">
|
||||||
|
{declExpr()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Tag modifiers */}
|
||||||
|
<Show when={mods().length > 0}>
|
||||||
|
<div class={declExpr() ? "border-t border-gray-100 pt-1 mt-1" : ""}>
|
||||||
|
<For each={mods()}>
|
||||||
|
{(mod) => (
|
||||||
|
<div class="flex items-center gap-1 text-gray-500">
|
||||||
|
<span class="font-mono text-purple-600">{mod.tag}</span>
|
||||||
|
<span>+{mod.value}</span>
|
||||||
|
<span class="text-gray-400">(from {mod.source})</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VariableView;
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* Command completions — build the dropdown items for journal input
|
||||||
|
* autocomplete based on the current raw text and completions data.
|
||||||
|
*
|
||||||
|
* Pure function with no component dependencies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CompletionItem } from "./command-parser";
|
||||||
|
import type { JournalCompletions } from "./completions";
|
||||||
|
|
||||||
|
export interface CompletionsContext {
|
||||||
|
data: JournalCompletions;
|
||||||
|
isGm: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCompletions(
|
||||||
|
raw: string,
|
||||||
|
ctx: CompletionsContext,
|
||||||
|
): CompletionItem[] {
|
||||||
|
const { data, isGm } = ctx;
|
||||||
|
|
||||||
|
const commands = [
|
||||||
|
{ label: "/roll", kind: "command" as const, insertText: "/roll " },
|
||||||
|
{ label: "/link", kind: "command" as const, insertText: "/link " },
|
||||||
|
{ label: "/set", kind: "command" as const, insertText: "/set " },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Show commands when user types / or starts typing a command name
|
||||||
|
if (raw.startsWith("/") && !raw.includes(" ")) {
|
||||||
|
const prefix = raw.toLowerCase();
|
||||||
|
let matches = commands.filter((c) =>
|
||||||
|
c.label.toLowerCase().startsWith(prefix),
|
||||||
|
);
|
||||||
|
if (!isGm) {
|
||||||
|
matches = matches.filter((c) => c.label === "/set");
|
||||||
|
}
|
||||||
|
return matches.length > 0
|
||||||
|
? matches
|
||||||
|
: [{ label: "未知命令", kind: "no-results", insertText: "" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
// After /roll — show dice AND spark table suggestions
|
||||||
|
if (raw.startsWith("/roll ")) {
|
||||||
|
const prefix = raw.slice("/roll ".length).toLowerCase();
|
||||||
|
|
||||||
|
const diceMatches = data.dice
|
||||||
|
.filter(
|
||||||
|
(d) =>
|
||||||
|
d.notation.toLowerCase().includes(prefix) ||
|
||||||
|
d.label.toLowerCase().includes(prefix),
|
||||||
|
)
|
||||||
|
.slice(0, 4);
|
||||||
|
|
||||||
|
const sparkMatches = data.sparkTables
|
||||||
|
.filter(
|
||||||
|
(s) =>
|
||||||
|
s.slug.toLowerCase().includes(prefix) ||
|
||||||
|
s.label.toLowerCase().includes(prefix),
|
||||||
|
)
|
||||||
|
.slice(0, 4);
|
||||||
|
|
||||||
|
const items: CompletionItem[] = [];
|
||||||
|
|
||||||
|
for (const d of diceMatches) {
|
||||||
|
items.push({
|
||||||
|
label: `🎲 ${d.notation}`,
|
||||||
|
kind: "value",
|
||||||
|
insertText: "/roll " + d.notation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const s of sparkMatches) {
|
||||||
|
items.push({
|
||||||
|
label: `✨ ${s.slug} (${s.notation})`,
|
||||||
|
kind: "value",
|
||||||
|
insertText: `/roll ${s.slug}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return [{ label: "输入骰子表达式", kind: "no-results", insertText: "" }];
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// After /link — show article and heading suggestions
|
||||||
|
if (raw.startsWith("/link ")) {
|
||||||
|
const prefix = raw.slice("/link ".length).toLowerCase();
|
||||||
|
const matches = data.links
|
||||||
|
.filter(
|
||||||
|
(l) =>
|
||||||
|
l.path.toLowerCase().includes(prefix) ||
|
||||||
|
l.label.toLowerCase().includes(prefix),
|
||||||
|
)
|
||||||
|
.slice(0, 8);
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return [{ label: "未找到链接", kind: "no-results", insertText: "" }];
|
||||||
|
}
|
||||||
|
return matches.map((l) => {
|
||||||
|
const insert = l.section
|
||||||
|
? `/link ${l.path}#${l.section}`
|
||||||
|
: `/link ${l.path}`;
|
||||||
|
return { label: l.label, kind: "value" as const, insertText: insert };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// After /set — show known variable and tag names
|
||||||
|
if (raw.startsWith("/set ")) {
|
||||||
|
const rest = raw.slice("/set ".length).trim();
|
||||||
|
|
||||||
|
// Already has a key? Show tag suggestions
|
||||||
|
if (rest.includes(" ")) {
|
||||||
|
const afterSpace = rest.slice(rest.indexOf(" ") + 1).toLowerCase();
|
||||||
|
const tags = data.tagModifiers.map((tm) => tm.tag);
|
||||||
|
// Also gather unique tags from declarations
|
||||||
|
const uniqueTags = [...new Set(tags)];
|
||||||
|
const matches = uniqueTags
|
||||||
|
.filter((t) => t.toLowerCase().startsWith(afterSpace))
|
||||||
|
.slice(0, 8);
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return [{ label: "输入表达式", kind: "no-results", insertText: "" }];
|
||||||
|
}
|
||||||
|
return matches.map((t) => ({
|
||||||
|
label: t,
|
||||||
|
kind: "value" as const,
|
||||||
|
insertText: raw + t,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show variable name suggestions
|
||||||
|
const prefix = rest.toLowerCase();
|
||||||
|
const varNames = data.declarations.map((d) => d.key);
|
||||||
|
const matches = varNames
|
||||||
|
.filter((v) => v.toLowerCase().startsWith(prefix))
|
||||||
|
.slice(0, 8);
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return [{ label: "$variable", kind: "no-results", insertText: "" }];
|
||||||
|
}
|
||||||
|
return matches.map((v) => ({
|
||||||
|
label: v,
|
||||||
|
kind: "value" as const,
|
||||||
|
insertText: `/set ${v} `,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
/**
|
||||||
|
* Command dispatcher — shared logic for parsing & dispatching commands
|
||||||
|
* from text input or :cmd[] directive spans.
|
||||||
|
*
|
||||||
|
* Used by both JournalInput (manual typing) and CommandLinkManager (click).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { sendMessage } from "../stores/journalStream";
|
||||||
|
import { createSignal } from "solid-js";
|
||||||
|
import { parseInput } from "./command-parser";
|
||||||
|
import { resolveRollPayload } from "./types/roll";
|
||||||
|
import { resolveSparkPayload } from "./types/spark";
|
||||||
|
import { evaluateExpression, exprValueToString } from "./variable-expression";
|
||||||
|
import { computeCascade, getCombined, setBase } from "./var-reactivity";
|
||||||
|
import type { VarDeclaration, TagModifier } from "./declare-parser";
|
||||||
|
|
||||||
|
// Tagmap pattern: "#warrior:1;#druid:2". Also accepts bare "#tag" as shorthand.
|
||||||
|
const BARE_TAG_PATTERN = /^#[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||||
|
|
||||||
|
function normalizeTagMap(expr: string): string {
|
||||||
|
const t = expr.trim();
|
||||||
|
if (BARE_TAG_PATTERN.test(t)) return t + ":1";
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Result
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type DispatchResult = { ok: true } | { ok: false; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared signal for dispatch errors from any source (typed or cmd-link clicks).
|
||||||
|
* Components that show errors (JournalInput) read from here; callers that
|
||||||
|
* want errors surfaced (CommandLinkManager) write to it.
|
||||||
|
*/
|
||||||
|
export const [dispatchError, setDispatchError] = createSignal<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main dispatch
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface DispatchContext {
|
||||||
|
/** The role of the current user */
|
||||||
|
role: "gm" | "player" | "observer";
|
||||||
|
/** The user's name */
|
||||||
|
myName: string;
|
||||||
|
/** The raw text to dispatch (with or without leading `/`) */
|
||||||
|
command: string;
|
||||||
|
/** Spark table lookup data (from completions) */
|
||||||
|
sparkTables: {
|
||||||
|
slug: string;
|
||||||
|
csvPath?: string;
|
||||||
|
docPath?: string;
|
||||||
|
remix?: boolean;
|
||||||
|
}[];
|
||||||
|
/** Current runtime variable values */
|
||||||
|
variables: Record<string, string>;
|
||||||
|
/** Variable declarations (from role=declare blocks) */
|
||||||
|
declarations: VarDeclaration[];
|
||||||
|
/** Tag modifiers (from role=declare blocks) */
|
||||||
|
tagModifiers: TagModifier[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch a command string. Works for both typed input and
|
||||||
|
* :cmd[] directive clicks.
|
||||||
|
*/
|
||||||
|
export async function dispatchCommand(
|
||||||
|
ctx: DispatchContext,
|
||||||
|
): Promise<DispatchResult> {
|
||||||
|
const raw = ctx.command.trim();
|
||||||
|
if (!raw) return { ok: false, error: "Empty command" };
|
||||||
|
|
||||||
|
// Observers can only send chat
|
||||||
|
if (ctx.role === "observer") {
|
||||||
|
const result = sendMessage("chat", { text: raw });
|
||||||
|
return finish(unwrap(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefixed = raw.startsWith("/") ? raw : "/" + raw;
|
||||||
|
const parsed = parseInput(prefixed);
|
||||||
|
if (parsed.error) return finish({ ok: false, error: parsed.error });
|
||||||
|
|
||||||
|
// Players: chat + set commands only
|
||||||
|
if (ctx.role === "player") {
|
||||||
|
if (parsed.type === "chat") {
|
||||||
|
const result = sendMessage("chat", { text: raw });
|
||||||
|
return finish(unwrap(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.type === "set" || parsed.type === "rolltag") {
|
||||||
|
return finish(
|
||||||
|
dispatchSet(parsed.payload as Record<string, unknown>, ctx),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return finish({ ok: false, error: "玩家只能发送聊天消息或使用 /set 命令" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// GM: all commands
|
||||||
|
if (parsed.type === "roll") {
|
||||||
|
const arg = (parsed.payload as { arg: string }).arg;
|
||||||
|
|
||||||
|
// Try spark table first: if arg matches a known spark table slug, roll it
|
||||||
|
const match = ctx.sparkTables.find((s) => s.slug === arg);
|
||||||
|
if (match) {
|
||||||
|
try {
|
||||||
|
const csvPath = match.csvPath ?? "";
|
||||||
|
const docPath = match.docPath;
|
||||||
|
const remix = match.remix ?? false;
|
||||||
|
const p = await resolveSparkPayload({
|
||||||
|
key: arg,
|
||||||
|
csvPath,
|
||||||
|
docPath,
|
||||||
|
remix,
|
||||||
|
});
|
||||||
|
const result = sendMessage("spark", p);
|
||||||
|
return finish(unwrap(result));
|
||||||
|
} catch (e) {
|
||||||
|
return finish({
|
||||||
|
ok: false,
|
||||||
|
error: e instanceof Error ? e.message : "种子表掷骰失败",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variable expression (e.g. "2d6 + $mod" or "$hp / 2")
|
||||||
|
if (arg.includes("$")) {
|
||||||
|
try {
|
||||||
|
const ev = evaluateExpression(arg, {
|
||||||
|
lookup: (name: string) => getCombined("$" + name, ctx.variables),
|
||||||
|
});
|
||||||
|
if (ev.value.kind !== "number") {
|
||||||
|
return finish({ ok: false, error: "Roll expression must evaluate to a number" });
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
notation: arg,
|
||||||
|
label: arg,
|
||||||
|
result: {
|
||||||
|
total: ev.value.value,
|
||||||
|
detail: "",
|
||||||
|
plainDetail: "",
|
||||||
|
pools: [] as { rolls: number[]; subtotal: number }[],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = sendMessage("roll", payload);
|
||||||
|
return finish(unwrap(result));
|
||||||
|
} catch (e) {
|
||||||
|
return finish({
|
||||||
|
ok: false,
|
||||||
|
error: e instanceof Error ? e.message : "表达式求值失败",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, treat as a dice expression
|
||||||
|
const p = resolveRollPayload({ notation: arg, label: arg });
|
||||||
|
const result = sendMessage("roll", p);
|
||||||
|
return finish(unwrap(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.type === "set" || parsed.type === "rolltag") {
|
||||||
|
return finish(dispatchSet(parsed.payload as Record<string, unknown>, ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = sendMessage(parsed.type, parsed.payload);
|
||||||
|
return finish(unwrap(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the shared error signal and return the result (pass-through). */
|
||||||
|
function finish(r: DispatchResult): DispatchResult {
|
||||||
|
setDispatchError(r.ok ? null : r.error);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Set dispatch
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function dispatchSet(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
ctx: DispatchContext,
|
||||||
|
): DispatchResult {
|
||||||
|
const p = payload as { key?: string; expr?: string; tags?: string[] };
|
||||||
|
|
||||||
|
if (!p.key) {
|
||||||
|
return { ok: false, error: "缺少变量名" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = p.key;
|
||||||
|
// Use getCombined with stream fallback for old value so tag transitions
|
||||||
|
// are detected correctly even when the variable has active mods.
|
||||||
|
const oldValue = getCombined(key, ctx.variables);
|
||||||
|
let newValue: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (p.tags && p.tags.length > 0) {
|
||||||
|
// Rolltag: pick random tag, format as tagmap entry
|
||||||
|
const idx = Math.floor(Math.random() * p.tags.length);
|
||||||
|
newValue = normalizeTagMap(p.tags[idx]);
|
||||||
|
} else if (p.expr) {
|
||||||
|
// Evaluate expression — handles both numeric and tagmap values
|
||||||
|
const result = evaluateExpression(p.expr, {
|
||||||
|
lookup: (name: string) => {
|
||||||
|
const k = "$" + name;
|
||||||
|
// Use getCombined with stream fallback for accurate values
|
||||||
|
return k === key ? undefined : getCombined(k, ctx.variables);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
newValue = exprValueToString(result.value);
|
||||||
|
} else {
|
||||||
|
return { ok: false, error: "缺少表达式" };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: e instanceof Error ? e.message : "表达式求值失败",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update base value so getCombined returns the correct new value
|
||||||
|
// during cascade computation
|
||||||
|
setBase(key, newValue);
|
||||||
|
|
||||||
|
// Build working variable store for cascade (includes the new value)
|
||||||
|
const workingVars = { ...ctx.variables, [key]: newValue };
|
||||||
|
|
||||||
|
// Compute cascade (tag activation/deactivation + declaration re-eval)
|
||||||
|
try {
|
||||||
|
const cascade = computeCascade(key, oldValue, workingVars);
|
||||||
|
for (const change of cascade) {
|
||||||
|
sendMessage("var", {
|
||||||
|
action: "set",
|
||||||
|
key: change.key,
|
||||||
|
value: change.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Cascade errors are non-fatal — the direct set already succeeded
|
||||||
|
console.warn("[dispatch] cascade error:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the direct set last so cascade messages arrive first.
|
||||||
|
// Use getCombined with stream fallback so the stream store receives the
|
||||||
|
// correct value (base + active mods) rather than just the raw base.
|
||||||
|
const combinedValue = getCombined(key, ctx.variables);
|
||||||
|
const r1 = sendMessage("var", { action: "set", key, value: combinedValue });
|
||||||
|
const u1 = unwrap(r1);
|
||||||
|
if (!u1.ok) return u1;
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Unwrap a sendMessage result into DispatchResult. */
|
||||||
|
function unwrap<R>(
|
||||||
|
r: { success: true; msg: R } | { success: false; error: string },
|
||||||
|
): DispatchResult {
|
||||||
|
return r.success ? { ok: true } : { ok: false, error: r.error };
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Command parser — parse a raw input string into a typed ParsedInput.
|
||||||
|
*
|
||||||
|
* Pure function with no component dependencies, so it can live in its own
|
||||||
|
* module and keep JournalInput small.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CompletionItem {
|
||||||
|
label: string;
|
||||||
|
kind: "command" | "value" | "no-results";
|
||||||
|
insertText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedInput {
|
||||||
|
type: "chat" | "roll" | "link" | "set" | "rolltag";
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseInput(raw: string): ParsedInput {
|
||||||
|
if (raw.startsWith("/roll ")) {
|
||||||
|
const arg = raw.slice("/roll ".length).trim();
|
||||||
|
if (!arg)
|
||||||
|
return { type: "roll", payload: {}, error: "需要骰子表达式或种子表键名" };
|
||||||
|
return { type: "roll", payload: { arg } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.startsWith("/link ")) {
|
||||||
|
const arg = raw.slice("/link ".length).trim();
|
||||||
|
if (!arg) return { type: "link", payload: {}, error: "需要路径" };
|
||||||
|
const hashIdx = arg.indexOf("#");
|
||||||
|
const path = hashIdx === -1 ? arg : arg.slice(0, hashIdx);
|
||||||
|
const section =
|
||||||
|
hashIdx === -1 ? undefined : arg.slice(hashIdx + 1) || undefined;
|
||||||
|
return { type: "link", payload: { path, section } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.startsWith("/set ")) {
|
||||||
|
const rest = raw.slice("/set ".length).trim();
|
||||||
|
if (!rest)
|
||||||
|
return { type: "set", payload: {}, error: "格式: /set $key expression" };
|
||||||
|
|
||||||
|
// Split on first space to get key and expression
|
||||||
|
const spaceIdx = rest.indexOf(" ");
|
||||||
|
if (spaceIdx === -1)
|
||||||
|
return { type: "set", payload: {}, error: "格式: /set $key expression" };
|
||||||
|
|
||||||
|
const key = rest.slice(0, spaceIdx).trim();
|
||||||
|
const expr = rest.slice(spaceIdx + 1).trim();
|
||||||
|
|
||||||
|
if (!key || !expr)
|
||||||
|
return { type: "set", payload: {}, error: "key 和 expression 不能为空" };
|
||||||
|
|
||||||
|
if (!key.startsWith("$"))
|
||||||
|
return { type: "set", payload: {}, error: "key 必须以 $ 开头" };
|
||||||
|
|
||||||
|
// Check if expr is a rolltag (e.g. #w|#d|#s)
|
||||||
|
if (expr.includes("|") && expr.split("|").every((t) => t.trim().startsWith("#"))) {
|
||||||
|
const tags = expr.split("|").map((t) => t.trim());
|
||||||
|
return { type: "rolltag", payload: { key, tags } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: "set", payload: { key, expr } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw === "/roll" || raw === "/link" || raw === "/set") {
|
||||||
|
return { type: "chat", payload: {}, error: "请补全命令" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: "chat", payload: { text: raw } };
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
/**
|
||||||
|
* Journal completions — client-side loader for /__COMPLETIONS.json
|
||||||
|
*
|
||||||
|
* In CLI mode, fetches the pre-computed index. In dev/browser mode, falls
|
||||||
|
* back to scanning the in-memory file index for dice expressions, headings,
|
||||||
|
* and declare blocks.
|
||||||
|
*
|
||||||
|
* The fetch runs eagerly on module import. Call `useJournalCompletions()`
|
||||||
|
* from any Solid component to reactively read the state.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal } from "solid-js";
|
||||||
|
import {
|
||||||
|
getPathsByExtension,
|
||||||
|
getIndexedData,
|
||||||
|
setIndexedData,
|
||||||
|
setInlineResolver,
|
||||||
|
} from "../../data-loader/file-index";
|
||||||
|
import {
|
||||||
|
buildRegistryFromIndex,
|
||||||
|
deriveCompletions,
|
||||||
|
resolveInlineByPath,
|
||||||
|
type ContentRegistry,
|
||||||
|
} from "../../cli/content-registry";
|
||||||
|
import type {
|
||||||
|
CompletionsPayload,
|
||||||
|
DiceCompletion,
|
||||||
|
LinkCompletion,
|
||||||
|
SparkTableCompletion,
|
||||||
|
VarDeclaration,
|
||||||
|
TagModifier,
|
||||||
|
} from "../../cli/completions/types";
|
||||||
|
import { initReactivity, computeInitialValues } from "./var-reactivity";
|
||||||
|
import { sendMessage, journalStreamState } from "../stores/journalStream";
|
||||||
|
|
||||||
|
export type { VarDeclaration, TagModifier };
|
||||||
|
|
||||||
|
// Re-export CLI types so consumers don't need to know about the CLI path
|
||||||
|
export type { DiceCompletion, LinkCompletion, SparkTableCompletion };
|
||||||
|
|
||||||
|
/** Convenience alias — same shape as the CLI's CompletionsPayload. */
|
||||||
|
export type JournalCompletions = CompletionsPayload;
|
||||||
|
|
||||||
|
export type CompletionsState =
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "loaded"; data: JournalCompletions }
|
||||||
|
| { status: "empty" }
|
||||||
|
| { status: "error"; message: string };
|
||||||
|
|
||||||
|
// ------------------- Reactive state (module-level signal) -------------------
|
||||||
|
|
||||||
|
const [completionsState, setCompletionsState] = createSignal<CompletionsState>({
|
||||||
|
status: "loading",
|
||||||
|
});
|
||||||
|
|
||||||
|
// The registry backing the completions. Populated in both CLI and client
|
||||||
|
// modes so inline content ids can be resolved at runtime (e.g. spark rolls).
|
||||||
|
let activeRegistry: ContentRegistry = { pathIndex: {}, docContent: {} };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The registry backing the current completions.
|
||||||
|
* In CLI mode this is fetched from the server; in browser mode it is built
|
||||||
|
* client-side. Used to resolve inline content ids (e.g. spark table CSVs).
|
||||||
|
*/
|
||||||
|
export function getRegistry(): ContentRegistry {
|
||||||
|
return activeRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the inline-content resolver so `getIndexedData` can resolve
|
||||||
|
// directive refs (e.g. `./csv_abc123`) that aren't real files.
|
||||||
|
setInlineResolver((path) => resolveInlineByPath(activeRegistry, path));
|
||||||
|
|
||||||
|
// ------------------- Fetch (CLI mode) -------------------
|
||||||
|
|
||||||
|
async function tryServer(): Promise<JournalCompletions | null> {
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/__COMPLETIONS.json");
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
const data = await resp.json();
|
||||||
|
return {
|
||||||
|
dice: Array.isArray(data.dice) ? data.dice : [],
|
||||||
|
links: Array.isArray(data.links) ? data.links : [],
|
||||||
|
sparkTables: Array.isArray(data.sparkTables) ? data.sparkTables : [],
|
||||||
|
declarations: Array.isArray(data.declarations) ? data.declarations : [],
|
||||||
|
tagModifiers: Array.isArray(data.tagModifiers) ? data.tagModifiers : [],
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load the content registry from the server (CLI mode). */
|
||||||
|
async function tryServerRegistry(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/__CONTENT_REGISTRY.json");
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const data = await resp.json();
|
||||||
|
activeRegistry = {
|
||||||
|
pathIndex: data.pathIndex ?? {},
|
||||||
|
docContent: data.docContent ?? {},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Registry unavailable — leave empty; client scan will populate it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------- Client-side fallback scan -------------------
|
||||||
|
|
||||||
|
async function scanClientSide(): Promise<JournalCompletions> {
|
||||||
|
const paths = await getPathsByExtension("md");
|
||||||
|
|
||||||
|
// Load all .md content into a raw index, then build the registry through
|
||||||
|
// the same shared pipeline as the CLI (scanDoc).
|
||||||
|
const index: Record<string, string> = {};
|
||||||
|
for (const filePath of paths) {
|
||||||
|
const content = await getIndexedData(filePath);
|
||||||
|
if (!content) continue;
|
||||||
|
index[filePath] = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry = buildRegistryFromIndex(index);
|
||||||
|
activeRegistry = registry;
|
||||||
|
|
||||||
|
// Write the processed (stripped) content back into the file index so
|
||||||
|
// Article/md-embed render the same content as CLI mode (attributed blocks
|
||||||
|
// processed by role).
|
||||||
|
for (const [path, content] of Object.entries(registry.pathIndex)) {
|
||||||
|
setIndexedData(path, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return deriveCompletions(registry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------- Init (runs eagerly at import time) -------------------
|
||||||
|
|
||||||
|
// Using a top-level IIFE so the promise starts immediately
|
||||||
|
const _initPromise: Promise<void> = (async () => {
|
||||||
|
// 1. Try server first (CLI mode)
|
||||||
|
const serverData = await tryServer();
|
||||||
|
if (serverData) {
|
||||||
|
setCompletionsState({ status: "loaded", data: serverData });
|
||||||
|
await tryServerRegistry();
|
||||||
|
try {
|
||||||
|
initReactivity({
|
||||||
|
declarations: serverData.declarations,
|
||||||
|
tagModifiers: serverData.tagModifiers,
|
||||||
|
});
|
||||||
|
seedDeclaredVariables();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[completions] reactivity init error:", e);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fall back to client-side scan (dev/browser mode)
|
||||||
|
try {
|
||||||
|
const data = await scanClientSide();
|
||||||
|
if (data.dice.length > 0 || data.links.length > 0) {
|
||||||
|
setCompletionsState({ status: "loaded", data });
|
||||||
|
try {
|
||||||
|
initReactivity({
|
||||||
|
declarations: data.declarations,
|
||||||
|
tagModifiers: data.tagModifiers,
|
||||||
|
});
|
||||||
|
seedDeclaredVariables();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[completions] reactivity init error:", e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setCompletionsState({ status: "empty" });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setCompletionsState({
|
||||||
|
status: "error",
|
||||||
|
message: e instanceof Error ? e.message : "Failed to scan",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ------------------- Public API -------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a Promise that resolves once completions are loaded (or failed).
|
||||||
|
* Useful for waiting before showing the completions dropdown.
|
||||||
|
*/
|
||||||
|
export function ensureCompletions(): Promise<void> {
|
||||||
|
return _initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive hooks for the journal input.
|
||||||
|
* Returns the current completions state + a convenience `data` extractor.
|
||||||
|
*/
|
||||||
|
export function useJournalCompletions(): {
|
||||||
|
state: CompletionsState;
|
||||||
|
data: JournalCompletions;
|
||||||
|
} {
|
||||||
|
const s = completionsState();
|
||||||
|
if (s.status === "loaded") {
|
||||||
|
return { state: s, data: s.data };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
state: s,
|
||||||
|
data: {
|
||||||
|
dice: [],
|
||||||
|
links: [],
|
||||||
|
sparkTables: [],
|
||||||
|
declarations: [],
|
||||||
|
tagModifiers: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Seed declared variables into the store on load
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function seedDeclaredVariables(): void {
|
||||||
|
const initial = computeInitialValues(journalStreamState.variables);
|
||||||
|
for (const { key, value } of initial) {
|
||||||
|
sendMessage("var", { action: "set", key, value });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Declare parser re-export — delegates to the shared parser in cli/completions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
parseDeclareCsv,
|
||||||
|
type VarDeclaration,
|
||||||
|
type TagModifier,
|
||||||
|
type DeclareResult,
|
||||||
|
} from "../../cli/completions/declare-parser";
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Journal module — barrel export
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Trigger message type registration
|
||||||
|
import "./types";
|
||||||
|
|
||||||
|
export {
|
||||||
|
registerMessageType,
|
||||||
|
getMessageType,
|
||||||
|
registeredTypes,
|
||||||
|
validatePayload,
|
||||||
|
canEmit,
|
||||||
|
} from "./registry";
|
||||||
|
export type { MessageTypeDef, StreamMessage } from "./registry";
|
||||||
|
export {
|
||||||
|
connectStream,
|
||||||
|
disconnectStream,
|
||||||
|
hydrateFromServer,
|
||||||
|
sendMessage,
|
||||||
|
revertLatest,
|
||||||
|
canRevert,
|
||||||
|
visibleMessages,
|
||||||
|
useJournalStream,
|
||||||
|
journalStreamState,
|
||||||
|
createSession,
|
||||||
|
deleteSession,
|
||||||
|
sessions,
|
||||||
|
setMyName,
|
||||||
|
} from "../stores/journalStream";
|
||||||
|
export type {
|
||||||
|
JournalStreamState,
|
||||||
|
SessionMeta,
|
||||||
|
SessionManifest,
|
||||||
|
} from "../stores/journalStream";
|
||||||
|
|
||||||
|
// UI components
|
||||||
|
export { JournalPanel } from "./JournalPanel";
|
||||||
|
export type { JournalPanelProps } from "./JournalPanel";
|
||||||
|
export { JournalHeader } from "./JournalHeader";
|
||||||
|
export { ConnectDialog } from "./ConnectDialog";
|
||||||
|
export { InviteDialog } from "./InviteDialog";
|
||||||
|
export { SessionDropdown } from "./SessionDropdown";
|
||||||
|
export { CreateSessionDialog } from "./CreateSessionDialog";
|
||||||
|
export { ConnectionBar } from "./ConnectionBar";
|
||||||
|
export { StreamView } from "./StreamView";
|
||||||
|
export { StreamMessageCard } from "./StreamMessage";
|
||||||
|
export { ComposePanel } from "./ComposePanel";
|
||||||
|
export { JournalInput } from "./JournalInput";
|
||||||
|
export { DynamicForm } from "./DynamicForm";
|
||||||
|
export { useJournalCompletions } from "./completions";
|
||||||
|
export { dispatchCommand } from "./command-dispatcher";
|
||||||
|
export type { DispatchContext, DispatchResult } from "./command-dispatcher";
|
||||||
|
export { parseInput } from "./command-parser";
|
||||||
|
export type { ParsedInput, CompletionItem } from "./command-parser";
|
||||||
|
export { buildCompletions } from "./command-completions";
|
||||||
|
export type { CompletionsContext } from "./command-completions";
|
||||||
|
export { VariableView } from "./VariableView";
|
||||||
|
export { parseDeclareCsv } from "./declare-parser";
|
||||||
|
export type { VarDeclaration, TagModifier } from "./declare-parser";
|
||||||
|
export { evaluateExpression, expressionIsTag, exprValueToString } from "./variable-expression";
|
||||||
|
export type { EvalContext, EvalResult, ExprValue } from "./variable-expression";
|
||||||
|
export { initReactivity, computeCascade, getCombined, getMods, getDeclExpr, setBase, extractDependencies, computeInitialValues } from "./var-reactivity";
|
||||||
|
export type { VarReactivityState, VariableStore } from "./var-reactivity";
|
||||||
|
export { JournalContext, useJournalContext } from "./JournalContext";
|
||||||
|
export type { JournalContextValue } from "./JournalContext";
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* Journal Stream — Message Type Registry
|
||||||
|
*
|
||||||
|
* An open, add-only registry of message types. Game-specific widgets
|
||||||
|
* call `registerMessageType()` at module import time. The stream core
|
||||||
|
* uses this to validate, render, and reduce messages.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ZodType } from "zod";
|
||||||
|
import type { JSX } from "solid-js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// StreamMessage
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The typed envelope carried on the stream topic.
|
||||||
|
* `P` is narrowed by the `type` key at the receiving side.
|
||||||
|
*/
|
||||||
|
export interface StreamMessage<P = unknown> {
|
||||||
|
/** Deterministic id: `{sender}-{seq}` */
|
||||||
|
id: string;
|
||||||
|
/** Sender identifier — "gm" or a player name */
|
||||||
|
sender: string;
|
||||||
|
/** Monotonic sequence number, per sender */
|
||||||
|
seq: number;
|
||||||
|
/** Registered type key — determines which payload schema & widget are used */
|
||||||
|
type: string;
|
||||||
|
/** Type-specific data, validated against the registry's Zod schema */
|
||||||
|
payload: P;
|
||||||
|
/** Epoch millis, set by the sender at publish time */
|
||||||
|
timestamp: number;
|
||||||
|
/**
|
||||||
|
* Soft-delete. Only the sender's latest (highest seq) message can be
|
||||||
|
* reverted. Sending a new message locks the previous one permanently.
|
||||||
|
*/
|
||||||
|
reverted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MessageTypeDef
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface MessageTypeDef<P = unknown> {
|
||||||
|
/** Unique type key, e.g. "roll", "link" */
|
||||||
|
type: string;
|
||||||
|
/** Human-readable label shown in the compose panel dropdown */
|
||||||
|
label: string;
|
||||||
|
/** Which roles can emit this type. Empty array = all roles (including observer) allowed. */
|
||||||
|
emitters?: ("gm" | "player" | "observer")[];
|
||||||
|
/** Zod schema used to validate payload before publish */
|
||||||
|
schema: ZodType<P>;
|
||||||
|
/** Returns a default payload for compose panel initialization */
|
||||||
|
defaultPayload?: () => P;
|
||||||
|
/**
|
||||||
|
* Render the message body in the stream.
|
||||||
|
* Receives the full message for context (sender, timestamp, reverted).
|
||||||
|
* Return undefined to suppress inline rendering (e.g. pure state mutations).
|
||||||
|
*/
|
||||||
|
render?: (payload: P, msg: StreamMessage<P>) => JSX.Element | undefined;
|
||||||
|
/**
|
||||||
|
* Apply side-effects to local state when this message is received.
|
||||||
|
* Runs once per message, at initial hydration and on live receipt.
|
||||||
|
*/
|
||||||
|
reducer?: (payload: P, msg: StreamMessage<P>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Registry
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const registry = new Map<string, MessageTypeDef<any>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a message type. Calling module should import and call this at
|
||||||
|
* top-level so the registry is populated before any stream operations.
|
||||||
|
*/
|
||||||
|
export function registerMessageType<P>(def: MessageTypeDef<P>): void {
|
||||||
|
if (registry.has(def.type)) {
|
||||||
|
throw new Error(`Message type "${def.type}" is already registered.`);
|
||||||
|
}
|
||||||
|
registry.set(def.type, def);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retrieve a registered type definition. */
|
||||||
|
export function getMessageType(type: string): MessageTypeDef | undefined {
|
||||||
|
return registry.get(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Iterate over all registered type keys. */
|
||||||
|
export function registeredTypes(): IterableIterator<string> {
|
||||||
|
return registry.keys();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a payload against the registered schema for `type`.
|
||||||
|
* Returns the parsed (narrowed) payload on success, or a ZodError.
|
||||||
|
*/
|
||||||
|
export function validatePayload<P>(
|
||||||
|
type: string,
|
||||||
|
payload: P,
|
||||||
|
): { success: true; data: P } | { success: false; error: string } {
|
||||||
|
const def = registry.get(type);
|
||||||
|
if (!def) {
|
||||||
|
return { success: false, error: `Unknown message type: "${type}"` };
|
||||||
|
}
|
||||||
|
const result = def.schema.safeParse(payload);
|
||||||
|
if (result.success) {
|
||||||
|
return { success: true, data: result.data as P };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: result.error.issues
|
||||||
|
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
||||||
|
.join("; "),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a role (sender) is allowed to emit a given message type.
|
||||||
|
*/
|
||||||
|
export function canEmit(type: string, role: string): boolean {
|
||||||
|
const def = registry.get(type);
|
||||||
|
if (!def) return false;
|
||||||
|
const emitters = def.emitters;
|
||||||
|
if (!emitters || emitters.length === 0) return true;
|
||||||
|
return emitters.includes(role as "gm" | "player" | "observer");
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Built-in message type: chat
|
||||||
|
*
|
||||||
|
* Emitters: gm, player
|
||||||
|
* Command: plain text (or press Enter)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
import { registerMessageType } from "../registry";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
text: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Message cannot be empty")
|
||||||
|
.max(2000, "Message too long"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ChatPayload = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
registerMessageType<ChatPayload>({
|
||||||
|
type: "chat",
|
||||||
|
label: "聊天",
|
||||||
|
emitters: ["gm", "player"],
|
||||||
|
schema,
|
||||||
|
defaultPayload: () => ({ text: "" }),
|
||||||
|
render: (p) => <p class="text-sm whitespace-pre-wrap">{p.text}</p>,
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Journal message types — barrel export
|
||||||
|
*
|
||||||
|
* Importing this module registers all built-in message types.
|
||||||
|
* Game-specific widgets should create their own barrel and import it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import "./chat";
|
||||||
|
import "./roll";
|
||||||
|
import "./spark";
|
||||||
|
import "./link";
|
||||||
|
import "./intent";
|
||||||
|
import "./var";
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Built-in message types: intent / resolution
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
import { registerMessageType } from "../registry";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// intent
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const intentSchema = z.object({
|
||||||
|
description: z.string().min(1),
|
||||||
|
context: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type IntentPayload = z.infer<typeof intentSchema>;
|
||||||
|
|
||||||
|
registerMessageType<IntentPayload>({
|
||||||
|
type: "intent",
|
||||||
|
label: "声明意图",
|
||||||
|
emitters: ["player"],
|
||||||
|
schema: intentSchema,
|
||||||
|
defaultPayload: () => ({ description: "" }),
|
||||||
|
render: (p) => (
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-lg">🙋</span>
|
||||||
|
<span class="text-sm font-medium text-yellow-700">意图</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm">{p.description}</p>
|
||||||
|
{p.context && Object.keys(p.context).length > 0 && (
|
||||||
|
<pre class="text-xs bg-gray-100 rounded px-2 py-1 text-gray-600">
|
||||||
|
{JSON.stringify(p.context, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// resolution
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const resolutionSchema = z.object({
|
||||||
|
intentId: z.string().min(1),
|
||||||
|
outcome: z.string().min(1),
|
||||||
|
statChanges: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ResolutionPayload = z.infer<typeof resolutionSchema>;
|
||||||
|
|
||||||
|
registerMessageType<ResolutionPayload>({
|
||||||
|
type: "resolution",
|
||||||
|
label: "裁决意图",
|
||||||
|
emitters: ["gm"],
|
||||||
|
schema: resolutionSchema,
|
||||||
|
render: (p) => (
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-lg">✅</span>
|
||||||
|
<span class="text-sm font-medium text-green-700">裁决</span>
|
||||||
|
<span class="text-xs text-gray-400">re: {p.intentId}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm">{p.outcome}</p>
|
||||||
|
{p.statChanges && Object.keys(p.statChanges).length > 0 && (
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{Object.entries(p.statChanges).map(([k, v]) => (
|
||||||
|
<span class="text-xs bg-green-50 text-green-800 px-1.5 py-0.5 rounded border border-green-200">
|
||||||
|
{k}: {String(v)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Built-in message type: link (article reveal)
|
||||||
|
*
|
||||||
|
* Emitters: gm
|
||||||
|
* Command: /link path#section
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component } from "solid-js";
|
||||||
|
import { useNavigateWithParams } from "../../useNavigateWithParams";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { registerMessageType } from "../registry";
|
||||||
|
import { journalSetState } from "../../stores/journalStream";
|
||||||
|
import { produce } from "solid-js/store";
|
||||||
|
import { useJournalContext } from "../JournalContext";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
path: z.string().min(1),
|
||||||
|
label: z.string().optional(),
|
||||||
|
section: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type LinkPayload = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
function fileNameFromPath(path: string): string {
|
||||||
|
const parts = path.split("/").filter(Boolean);
|
||||||
|
return decodeURIComponent(parts[parts.length - 1] || path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encode a path, preserving / separators but encoding each segment */
|
||||||
|
function encodePath(path: string): string {
|
||||||
|
// Decode first in case the path is already URL-encoded (defense-in-depth)
|
||||||
|
return decodeURIComponent(path)
|
||||||
|
.split("/")
|
||||||
|
.map((seg) => encodeURIComponent(seg))
|
||||||
|
.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Section slug → human-readable title */
|
||||||
|
function slugToTitle(slug: string): string {
|
||||||
|
return slug
|
||||||
|
.split("-")
|
||||||
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const RevealLink: Component<LinkPayload> = (p) => {
|
||||||
|
const navigate = useNavigateWithParams();
|
||||||
|
const ctx = useJournalContext();
|
||||||
|
|
||||||
|
const target = () => {
|
||||||
|
let t = encodePath(p.path);
|
||||||
|
if (p.section) t += `#${encodeURIComponent(p.section)}`;
|
||||||
|
return t;
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayLabel = () => {
|
||||||
|
if (p.label) return p.label;
|
||||||
|
return fileNameFromPath(p.path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(target());
|
||||||
|
// On mobile, close the journal panel to reveal the article
|
||||||
|
if (window.innerWidth < 768) {
|
||||||
|
ctx?.onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-lg">📄</span>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href={target()}
|
||||||
|
onClick={handleClick}
|
||||||
|
class="text-blue-600 underline text-sm"
|
||||||
|
>
|
||||||
|
{displayLabel()}
|
||||||
|
</a>
|
||||||
|
{p.section && (
|
||||||
|
<span class="text-xs text-gray-400 ml-1">
|
||||||
|
§ {slugToTitle(p.section)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
registerMessageType<LinkPayload>({
|
||||||
|
type: "link",
|
||||||
|
label: "链接文章",
|
||||||
|
emitters: ["gm"],
|
||||||
|
schema,
|
||||||
|
defaultPayload: () => ({ path: "/" }),
|
||||||
|
render: (p) => <RevealLink {...p} />,
|
||||||
|
reducer: (p) => {
|
||||||
|
journalSetState(
|
||||||
|
produce((s) => {
|
||||||
|
const key = p.path.replace(/^\.?\//, "").replace(/\.md$/, "");
|
||||||
|
if (!s.revealedPaths[key]) {
|
||||||
|
s.revealedPaths[key] = new Set();
|
||||||
|
}
|
||||||
|
if (p.section) {
|
||||||
|
s.revealedPaths[key].add(p.section);
|
||||||
|
} else {
|
||||||
|
// No section — reveal the whole article
|
||||||
|
s.revealedPaths[key].clear();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Built-in message type: roll
|
||||||
|
*
|
||||||
|
* Emitters: gm, player
|
||||||
|
* Command: /roll formula
|
||||||
|
* The sender's browser rolls the formula and publishes the resolved result.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
import { registerMessageType } from "../registry";
|
||||||
|
import { rollFormula } from "../../md-commander/hooks";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
notation: z.string().min(1),
|
||||||
|
label: z.string().optional(),
|
||||||
|
result: z.object({
|
||||||
|
total: z.number(),
|
||||||
|
detail: z.string(),
|
||||||
|
plainDetail: z.string(),
|
||||||
|
pools: z.array(
|
||||||
|
z.object({
|
||||||
|
rolls: z.array(z.number()),
|
||||||
|
subtotal: z.number(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RollPayload = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
/** Compute the dice result on the GM's machine before send */
|
||||||
|
export function resolveRollPayload(raw: {
|
||||||
|
notation: string;
|
||||||
|
label?: string;
|
||||||
|
}): RollPayload {
|
||||||
|
const roll = rollFormula(raw.notation);
|
||||||
|
return {
|
||||||
|
notation: raw.notation,
|
||||||
|
label: raw.label,
|
||||||
|
result: roll.result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
registerMessageType<RollPayload>({
|
||||||
|
type: "roll",
|
||||||
|
label: "掷骰",
|
||||||
|
emitters: ["gm", "player"],
|
||||||
|
schema,
|
||||||
|
defaultPayload: () => ({
|
||||||
|
notation: "1d20",
|
||||||
|
result: { total: 0, detail: "", plainDetail: "", pools: [] },
|
||||||
|
}),
|
||||||
|
render: (p) => {
|
||||||
|
const { result } = p;
|
||||||
|
return (
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="text-lg">🎲</span>
|
||||||
|
<span class="font-mono text-sm">
|
||||||
|
{p.notation} →{" "}
|
||||||
|
<span class="font-bold text-base">{result.total}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{result.detail && (
|
||||||
|
<p class="text-xs text-gray-400" innerHTML={result.detail} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* Built-in message type: spark
|
||||||
|
*
|
||||||
|
* Emitters: gm
|
||||||
|
* Command: /spark pageName-columnSlug
|
||||||
|
*
|
||||||
|
* "spark tables" are markdown tables whose first column header is a dice
|
||||||
|
* formula (d6, d20, d100, etc.). The command rolls the dice once per data
|
||||||
|
* column, looks up the row matching each roll, and publishes the results.
|
||||||
|
*
|
||||||
|
* The completion slug is "pageName-columnSlug" where columnSlug is the
|
||||||
|
* concatenated slugs of every data-column header.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
import { For } from "solid-js";
|
||||||
|
import { registerMessageType } from "../registry";
|
||||||
|
import { rollFormula } from "../../md-commander/hooks";
|
||||||
|
import { parseSparkTableCsv, rollSparkTable } from "../../utils/spark-table";
|
||||||
|
import { getIndexedData } from "../../../data-loader/file-index";
|
||||||
|
import { getRegistry } from "../../journal/completions";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Schema
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const sparkColumnSchema = z.object({
|
||||||
|
header: z.string(),
|
||||||
|
slug: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sparkResultSchema = z.object({
|
||||||
|
notation: z.string(),
|
||||||
|
columns: z.array(sparkColumnSchema),
|
||||||
|
source: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
/** User-visible label, e.g. "adventure body-label" */
|
||||||
|
label: z.string(),
|
||||||
|
/** The dice notation from the spark table header */
|
||||||
|
notation: z.string().min(1),
|
||||||
|
/** Resolved spark table columns */
|
||||||
|
sparkTable: sparkResultSchema,
|
||||||
|
/** First column's raw dice roll — informational only */
|
||||||
|
rollResult: z.object({
|
||||||
|
total: z.number(),
|
||||||
|
detail: z.string(),
|
||||||
|
plainDetail: z.string(),
|
||||||
|
pools: z.array(
|
||||||
|
z.object({
|
||||||
|
rolls: z.array(z.number()),
|
||||||
|
subtotal: z.number(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SparkPayload = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Resolve
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a spark table roll.
|
||||||
|
*
|
||||||
|
* `key` is the combined slug (pageName-columnSlug).
|
||||||
|
* `csvPath` is the resolved .csv file path from completions.
|
||||||
|
* `remix` controls whether each column gets an independent roll.
|
||||||
|
*/
|
||||||
|
export async function resolveSparkPayload(raw: {
|
||||||
|
key: string;
|
||||||
|
csvPath: string;
|
||||||
|
docPath?: string;
|
||||||
|
remix: boolean;
|
||||||
|
}): Promise<SparkPayload> {
|
||||||
|
let csv: string | null;
|
||||||
|
|
||||||
|
// Inline content ids resolve through the registry (docPath + content id);
|
||||||
|
// real file paths fall back to the file index.
|
||||||
|
const registry = getRegistry();
|
||||||
|
const docStore = raw.docPath ? registry.docContent[raw.docPath] : undefined;
|
||||||
|
const inline = docStore?.[raw.csvPath];
|
||||||
|
if (inline) {
|
||||||
|
csv = inline.body;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
csv = await getIndexedData(raw.csvPath);
|
||||||
|
} catch {
|
||||||
|
csv = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (csv === null) {
|
||||||
|
throw new Error(`Failed to load CSV: "${raw.csvPath}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = parseSparkTableCsv(csv);
|
||||||
|
if (!meta) {
|
||||||
|
throw new Error(
|
||||||
|
`Spark table "${raw.key}" not found in CSV "${raw.csvPath}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sparkResult = rollSparkTable(meta, { remix: raw.remix });
|
||||||
|
const firstRoll = rollFormula(meta.notation);
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: raw.key,
|
||||||
|
notation: meta.notation,
|
||||||
|
sparkTable: sparkResult,
|
||||||
|
rollResult: firstRoll.result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Render
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
registerMessageType<SparkPayload>({
|
||||||
|
type: "spark",
|
||||||
|
label: "种子表",
|
||||||
|
emitters: ["gm"],
|
||||||
|
schema,
|
||||||
|
defaultPayload: () => ({
|
||||||
|
label: "",
|
||||||
|
notation: "d6",
|
||||||
|
sparkTable: { notation: "d6", columns: [], source: "" },
|
||||||
|
rollResult: { total: 0, detail: "", plainDetail: "", pools: [] },
|
||||||
|
}),
|
||||||
|
render: (p) => {
|
||||||
|
const st = p.sparkTable;
|
||||||
|
return (
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="text-lg">✨</span>
|
||||||
|
<span class="font-mono text-xs text-purple-600">
|
||||||
|
{st.notation} · spark
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-purple-50 rounded border border-purple-200 overflow-hidden">
|
||||||
|
<table class="w-full text-xs">
|
||||||
|
<tbody>
|
||||||
|
<For each={st.columns}>
|
||||||
|
{(col) => (
|
||||||
|
<tr class="border-t border-purple-100 first:border-t-0">
|
||||||
|
<td class="px-2 py-1 text-purple-600 font-medium w-1/3">
|
||||||
|
{col.header}
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-1 text-gray-800">{col.value}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Built-in message type: var
|
||||||
|
*
|
||||||
|
* Emitters: gm, player
|
||||||
|
* Command: /set $key expression
|
||||||
|
*
|
||||||
|
* Variable declarations and tag modifiers are authored in ```csv role=declare
|
||||||
|
* code blocks in markdown documents. The stream carries set/del mutations
|
||||||
|
* that build up the runtime variable store additively.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
import { registerMessageType } from "../registry";
|
||||||
|
import { journalSetState } from "../../stores/journalStream";
|
||||||
|
import { produce } from "solid-js/store";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
action: z.enum(["set", "del"]),
|
||||||
|
key: z.string().min(1),
|
||||||
|
value: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type VarPayload = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
registerMessageType<VarPayload>({
|
||||||
|
type: "var",
|
||||||
|
label: "变量",
|
||||||
|
emitters: ["gm", "player"],
|
||||||
|
schema,
|
||||||
|
defaultPayload: () => ({ action: "set", key: "", value: "" }),
|
||||||
|
render: (p) => {
|
||||||
|
if (p.action === "del") {
|
||||||
|
return (
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="text-lg">🔢</span>
|
||||||
|
<span class="font-mono text-sm text-gray-500 line-through">
|
||||||
|
{p.key}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="text-lg">🔢</span>
|
||||||
|
<span class="font-mono text-sm">
|
||||||
|
{p.key} = <span class="font-bold">{p.value}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
reducer: (p) => {
|
||||||
|
journalSetState(
|
||||||
|
produce((s) => {
|
||||||
|
if (p.action === "del") {
|
||||||
|
delete s.variables[p.key];
|
||||||
|
} else if (p.value !== undefined) {
|
||||||
|
s.variables[p.key] = p.value;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,692 @@
|
|||||||
|
/**
|
||||||
|
* Variable reactivity engine — tracks variable declarations and tag
|
||||||
|
* modifiers, then cascades changes when variables are set.
|
||||||
|
*
|
||||||
|
* Runs client-side (in the sender's tab, via command-dispatcher).
|
||||||
|
* Uses a base/mod separation:
|
||||||
|
* - baseValues: what /set writes (or declaration evaluation produces)
|
||||||
|
* - numericMods: per-target list of {tag, value, source} from tag activations
|
||||||
|
* - tagMapMods: per-target list of {tag, value, source} for tagmap targets
|
||||||
|
* - sourceActivations: per-source list of {tag, target, value, threshold, kind}
|
||||||
|
* for deactivation
|
||||||
|
*
|
||||||
|
* Variables have one of two types:
|
||||||
|
* - numeric: base + sum(numericMods)
|
||||||
|
* - tagmap (#warrior:1;#druid:2): the tagmap is used for threshold-gated
|
||||||
|
* modifier activation; tagmap variables do not receive numeric mods but
|
||||||
|
* can receive tagmap mods (adding/subtracting to specific tag counts).
|
||||||
|
*
|
||||||
|
* All functions that resolve variable values accept an explicit `fallback`
|
||||||
|
* (the stream VariableStore) rather than relying on mutable module state.
|
||||||
|
* This ensures correctness regardless of call order.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { VarDeclaration, TagModifier } from "./declare-parser";
|
||||||
|
import { evaluateExpression, exprValueToString } from "./variable-expression";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface VarReactivityState {
|
||||||
|
declarations: VarDeclaration[];
|
||||||
|
tagModifiers: TagModifier[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VariableStore = Record<string, string>;
|
||||||
|
|
||||||
|
/** A single modifier applied to a target variable. */
|
||||||
|
export interface ActiveMod {
|
||||||
|
tag: string; // "#warrior"
|
||||||
|
value: number; // evaluated modifier expression result
|
||||||
|
source: string; // "$class" — which variable activated this tag
|
||||||
|
threshold: number; // the threshold that triggered this activation
|
||||||
|
kind: "numeric" | "tagmap";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tagmap helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Tagmap serialization format: "#warrior:1;#druid:2" */
|
||||||
|
const TAGMAP_RE = /^#([a-zA-Z_][a-zA-Z0-9_]*):(\d+)$/;
|
||||||
|
|
||||||
|
/** Parse a tagmap string. Returns null if the value is not a valid tagmap. */
|
||||||
|
function parseTagMap(value: string | undefined): Record<string, number> | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed.startsWith("#")) return null;
|
||||||
|
|
||||||
|
const parts = trimmed.split(";");
|
||||||
|
const map: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const m = TAGMAP_RE.exec(part.trim());
|
||||||
|
if (!m) return null; // invalid entry
|
||||||
|
const tag = "#" + m[1];
|
||||||
|
const count = parseInt(m[2], 10);
|
||||||
|
if (count <= 0) continue; // skip zero/negative counts on parse
|
||||||
|
map[tag] = (map[tag] ?? 0) + count;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(map).length > 0 ? map : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialize a tagmap back to string. Returns empty string if map is empty. */
|
||||||
|
function tagMapToString(map: Record<string, number>): string {
|
||||||
|
const entries = Object.entries(map).filter(([, c]) => c > 0);
|
||||||
|
if (entries.length === 0) return "";
|
||||||
|
return entries.map(([tag, count]) => `${tag}:${count}`).join(";");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a value is a tagmap string. */
|
||||||
|
function isTagMapValue(value: string | undefined): boolean {
|
||||||
|
if (!value) return false;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed.startsWith("#")) return false;
|
||||||
|
return parseTagMap(trimmed) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Dependency graph: $dep → Set<$declaredVar> */
|
||||||
|
let depGraph: Map<string, Set<string>> | null = null;
|
||||||
|
|
||||||
|
/** Reverse: $declaredVar → its expression */
|
||||||
|
let declExprs: Map<string, string> | null = null;
|
||||||
|
|
||||||
|
/** Tag modifiers from declare blocks: #tag → [{target, expression, threshold}] */
|
||||||
|
let tagModMap: Map<string, Array<{ target: string; expression: string; threshold: number }>> | null = null;
|
||||||
|
|
||||||
|
/** Base values set by /set or declaration evaluation */
|
||||||
|
const baseValues = new Map<string, string>();
|
||||||
|
|
||||||
|
/** Numeric mods per target: $target → [{tag, value, source, ...}] */
|
||||||
|
const numericMods = new Map<string, ActiveMod[]>();
|
||||||
|
|
||||||
|
/** Tagmap mods per target: $target → [{tag, value, source, ...}] */
|
||||||
|
const tagMapMods = new Map<string, ActiveMod[]>();
|
||||||
|
|
||||||
|
/** Activations per source: $source → [{tag, target, value, threshold, kind}] */
|
||||||
|
const sourceActivations = new Map<string, Array<{
|
||||||
|
tag: string;
|
||||||
|
target: string;
|
||||||
|
value: number;
|
||||||
|
threshold: number;
|
||||||
|
kind: "numeric" | "tagmap";
|
||||||
|
}>>();
|
||||||
|
|
||||||
|
/** Set of $vars currently being re-evaluated (cycle guard) */
|
||||||
|
const inFlight = new Set<string>();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize (or re-initialize) the reactivity engine from declarations
|
||||||
|
* and tag modifiers parsed from role=declare blocks.
|
||||||
|
*
|
||||||
|
* Throws if a circular dependency is detected.
|
||||||
|
*/
|
||||||
|
export function initReactivity(state: VarReactivityState): void {
|
||||||
|
depGraph = new Map();
|
||||||
|
declExprs = new Map();
|
||||||
|
tagModMap = new Map();
|
||||||
|
baseValues.clear();
|
||||||
|
numericMods.clear();
|
||||||
|
tagMapMods.clear();
|
||||||
|
sourceActivations.clear();
|
||||||
|
|
||||||
|
// Index tag modifiers
|
||||||
|
for (const tm of state.tagModifiers) {
|
||||||
|
let list = tagModMap.get(tm.tag);
|
||||||
|
if (!list) {
|
||||||
|
list = [];
|
||||||
|
tagModMap.set(tm.tag, list);
|
||||||
|
}
|
||||||
|
list.push({ target: tm.target, expression: tm.expression, threshold: tm.threshold });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index declarations and build dependency graph
|
||||||
|
for (const decl of state.declarations) {
|
||||||
|
declExprs.set(decl.key, decl.expression);
|
||||||
|
const deps = extractDependencies(decl.expression);
|
||||||
|
for (const dep of deps) {
|
||||||
|
let dependents = depGraph.get(dep);
|
||||||
|
if (!dependents) {
|
||||||
|
dependents = new Set();
|
||||||
|
depGraph.set(dep, dependents);
|
||||||
|
}
|
||||||
|
dependents.add(decl.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkCircular();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract $var names from an expression string. */
|
||||||
|
export function extractDependencies(expr: string): string[] {
|
||||||
|
const vars: string[] = [];
|
||||||
|
const re = /\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(expr)) !== null) {
|
||||||
|
const name = "$" + m[1];
|
||||||
|
if (!vars.includes(name)) vars.push(name);
|
||||||
|
}
|
||||||
|
return vars;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the combined value of a variable.
|
||||||
|
* - For tagmap variables: returns the serialized tagmap (base + tagmap mods).
|
||||||
|
* - For numeric variables: returns base + sum(numericMods).
|
||||||
|
* Falls back to the provided stream store for variables not tracked locally.
|
||||||
|
*/
|
||||||
|
export function getCombined(key: string, fallback?: VariableStore): string {
|
||||||
|
const base = baseValues.get(key);
|
||||||
|
|
||||||
|
if (base !== undefined && isTagMapValue(base)) {
|
||||||
|
// Tagmap variable: apply tagmap mods, then serialize
|
||||||
|
const baseMap = parseTagMap(base);
|
||||||
|
const mods = tagMapMods.get(key) ?? [];
|
||||||
|
if (baseMap && mods.length === 0) return base;
|
||||||
|
const resultMap: Record<string, number> = { ...(baseMap ?? {}) };
|
||||||
|
for (const m of mods) {
|
||||||
|
resultMap[m.tag] = (resultMap[m.tag] ?? 0) + m.value;
|
||||||
|
if (resultMap[m.tag] <= 0) delete resultMap[m.tag];
|
||||||
|
}
|
||||||
|
const serialized = tagMapToString(resultMap);
|
||||||
|
return serialized || "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseNum = base !== undefined ? parseFloat(base) : NaN;
|
||||||
|
const mods = numericMods.get(key) ?? [];
|
||||||
|
const modSum = mods.reduce((sum, m) => sum + m.value, 0);
|
||||||
|
|
||||||
|
if (!isNaN(baseNum)) {
|
||||||
|
return String(baseNum + modSum);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to stream store. The stream value is authoritative for
|
||||||
|
// variables not tracked locally — it already includes any mods from
|
||||||
|
// the sender's engine, so we return it as-is without adding local mods.
|
||||||
|
const fb = fallback?.[key];
|
||||||
|
if (fb !== undefined) return fb;
|
||||||
|
|
||||||
|
// No base, but has numeric mods
|
||||||
|
if (mods.length > 0) return String(modSum);
|
||||||
|
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the effective tagmap for a variable (base + tagmap mods).
|
||||||
|
* Returns null if the variable is not a tagmap variable.
|
||||||
|
*/
|
||||||
|
export function getTagMap(key: string): Record<string, number> | null {
|
||||||
|
const base = baseValues.get(key);
|
||||||
|
if (base === undefined || !isTagMapValue(base)) return null;
|
||||||
|
const baseMap = parseTagMap(base);
|
||||||
|
if (!baseMap) return null;
|
||||||
|
const mods = tagMapMods.get(key) ?? [];
|
||||||
|
const result: Record<string, number> = { ...baseMap };
|
||||||
|
for (const m of mods) {
|
||||||
|
result[m.tag] = (result[m.tag] ?? 0) + m.value;
|
||||||
|
if (result[m.tag] <= 0) delete result[m.tag];
|
||||||
|
}
|
||||||
|
return Object.keys(result).length > 0 ? result : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the active mods for a variable (for UI hover display). */
|
||||||
|
export function getMods(key: string): ActiveMod[] {
|
||||||
|
const nums = numericMods.get(key) ?? [];
|
||||||
|
const tags = tagMapMods.get(key) ?? [];
|
||||||
|
return [...nums, ...tags];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the declaration expression for a variable, if any. */
|
||||||
|
export function getDeclExpr(key: string): string | undefined {
|
||||||
|
return declExprs?.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the base value for a variable. Called by dispatchSet before
|
||||||
|
* computing the cascade, so getCombined returns the correct new value.
|
||||||
|
*/
|
||||||
|
export function setBase(key: string, value: string): void {
|
||||||
|
baseValues.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuild local reactivity state (baseValues, mods, sourceActivations)
|
||||||
|
* from the hydrated stream variable store. Must be called after replayReducers
|
||||||
|
* so that tag activations are correctly tracked for subsequent cascade
|
||||||
|
* computations.
|
||||||
|
*/
|
||||||
|
export function rebuildReactivityFromStore(variables: VariableStore): void {
|
||||||
|
if (!tagModMap) return;
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(variables)) {
|
||||||
|
if (!baseValues.has(key)) {
|
||||||
|
baseValues.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagMap = isTagMapValue(value) ? parseTagMap(value) : null;
|
||||||
|
if (tagMap && !sourceActivations.has(key)) {
|
||||||
|
// Activate all tags in the tagmap
|
||||||
|
const added = applyTagMapActivations(key, {}, tagMap, variables);
|
||||||
|
// We don't return the added entries here — they'll be applied
|
||||||
|
// to the store separately
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute cascade effects after a variable change.
|
||||||
|
*
|
||||||
|
* @param changedVar - the variable that was just set (e.g. "$con")
|
||||||
|
* @param oldValue - its previous combined value (for tag transition detection)
|
||||||
|
* @param currentVars - the full stream variable store (as fallback)
|
||||||
|
* @returns list of {key, value} pairs (combined values) to publish as var messages
|
||||||
|
*/
|
||||||
|
export function computeCascade(
|
||||||
|
changedVar: string,
|
||||||
|
oldValue: string | undefined,
|
||||||
|
currentVars: VariableStore,
|
||||||
|
): Array<{ key: string; value: string }> {
|
||||||
|
if (!depGraph || !declExprs || !tagModMap) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: Array<{ key: string; value: string }> = [];
|
||||||
|
|
||||||
|
// ---- Tag activation/deactivation ----
|
||||||
|
const newValue = getCombined(changedVar, currentVars);
|
||||||
|
const oldTagMap = isTagMapValue(oldValue) ? parseTagMap(oldValue) : {};
|
||||||
|
const newTagMap = isTagMapValue(newValue) ? parseTagMap(newValue) : {};
|
||||||
|
|
||||||
|
// Diff tagmaps and apply changes
|
||||||
|
const tagResults = applyTagMapActivations(changedVar, oldTagMap ?? {}, newTagMap ?? {}, currentVars);
|
||||||
|
results.push(...tagResults);
|
||||||
|
|
||||||
|
// ---- Declaration re-evaluation ----
|
||||||
|
const reevaluated = reevaluateDependents(changedVar, currentVars);
|
||||||
|
for (const r of reevaluated) {
|
||||||
|
if (!results.some((x) => x.key === r.key)) {
|
||||||
|
results.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute initial values for all declared variables.
|
||||||
|
* Called once after initReactivity() to seed the store.
|
||||||
|
*/
|
||||||
|
export function computeInitialValues(
|
||||||
|
currentVars: VariableStore,
|
||||||
|
): Array<{ key: string; value: string }> {
|
||||||
|
if (!declExprs) return [];
|
||||||
|
|
||||||
|
const allKeys = [...declExprs.keys()];
|
||||||
|
const sorted = topoSortAffected(new Set(allKeys));
|
||||||
|
|
||||||
|
const results: Array<{ key: string; value: string }> = [];
|
||||||
|
|
||||||
|
for (const key of sorted) {
|
||||||
|
const expr = declExprs.get(key);
|
||||||
|
if (!expr) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = evaluateExpression(expr, {
|
||||||
|
lookup: (name: string) => {
|
||||||
|
const k = "$" + name;
|
||||||
|
return getCombined(k, currentVars);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawValue = exprValueToString(result.value);
|
||||||
|
baseValues.set(key, rawValue);
|
||||||
|
|
||||||
|
// Check if this is a tagmap value — if so, activate matching modifiers
|
||||||
|
const tagMap = isTagMapValue(rawValue) ? parseTagMap(rawValue) : null;
|
||||||
|
if (tagMap) {
|
||||||
|
const added = applyTagMapActivations(key, {}, tagMap, currentVars);
|
||||||
|
for (const r of added) {
|
||||||
|
if (!results.some((x) => x.key === r.key)) {
|
||||||
|
results.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always emit the combined value for this key
|
||||||
|
const combined = getCombined(key, currentVars);
|
||||||
|
if (!results.some((x) => x.key === key)) {
|
||||||
|
results.push({ key, value: combined });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// skip failed evaluations at init time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tag activation / deactivation (threshold-based)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply tagmap changes for a source variable. Compares old and new tagmaps,
|
||||||
|
* activating/deactivating modifiers whose threshold crossing state changed.
|
||||||
|
*/
|
||||||
|
function applyTagMapActivations(
|
||||||
|
source: string,
|
||||||
|
oldTagMap: Record<string, number>,
|
||||||
|
newTagMap: Record<string, number>,
|
||||||
|
fallback: VariableStore,
|
||||||
|
): Array<{ key: string; value: string }> {
|
||||||
|
if (!tagModMap) return [];
|
||||||
|
|
||||||
|
const results: Array<{ key: string; value: string }> = [];
|
||||||
|
const allTags = new Set([...Object.keys(oldTagMap), ...Object.keys(newTagMap)]);
|
||||||
|
|
||||||
|
for (const tag of allTags) {
|
||||||
|
const mods = tagModMap.get(tag);
|
||||||
|
if (!mods || mods.length === 0) continue;
|
||||||
|
|
||||||
|
const oldCount = oldTagMap[tag] ?? 0;
|
||||||
|
const newCount = newTagMap[tag] ?? 0;
|
||||||
|
|
||||||
|
for (let modIdx = 0; modIdx < mods.length; modIdx++) {
|
||||||
|
const mod = mods[modIdx];
|
||||||
|
const wasActive = oldCount >= mod.threshold;
|
||||||
|
const isActive = newCount >= mod.threshold;
|
||||||
|
|
||||||
|
if (!wasActive && isActive) {
|
||||||
|
// Activate this modifier
|
||||||
|
try {
|
||||||
|
// Snapshot target's current value as base if needed
|
||||||
|
if (!baseValues.has(mod.target)) {
|
||||||
|
baseValues.set(mod.target, getCombined(mod.target, fallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
const evalResult = evaluateExpression(mod.expression, {
|
||||||
|
lookup: (name: string) => {
|
||||||
|
const k = "$" + name;
|
||||||
|
return getCombined(k, fallback);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modifier expressions must evaluate to a number
|
||||||
|
if (evalResult.value.kind !== "number") {
|
||||||
|
throw new Error(
|
||||||
|
`Modifier expression "${mod.expression}" must evaluate to a number`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const value = evalResult.value.value;
|
||||||
|
const targetIsTagMap = isTagMapValue(baseValues.get(mod.target));
|
||||||
|
|
||||||
|
if (targetIsTagMap) {
|
||||||
|
// Apply as tagmap mod to the target's tag count
|
||||||
|
const entry: ActiveMod = { tag, value, source, threshold: mod.threshold, kind: "tagmap" };
|
||||||
|
let targetMods = tagMapMods.get(mod.target);
|
||||||
|
if (!targetMods) {
|
||||||
|
targetMods = [];
|
||||||
|
tagMapMods.set(mod.target, targetMods);
|
||||||
|
}
|
||||||
|
targetMods.push(entry);
|
||||||
|
} else {
|
||||||
|
// Apply as numeric mod
|
||||||
|
const entry: ActiveMod = { tag, value, source, threshold: mod.threshold, kind: "numeric" };
|
||||||
|
let targetMods = numericMods.get(mod.target);
|
||||||
|
if (!targetMods) {
|
||||||
|
targetMods = [];
|
||||||
|
numericMods.set(mod.target, targetMods);
|
||||||
|
}
|
||||||
|
targetMods.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track in source activations for deactivation
|
||||||
|
let sourceEntries = sourceActivations.get(source);
|
||||||
|
if (!sourceEntries) {
|
||||||
|
sourceEntries = [];
|
||||||
|
sourceActivations.set(source, sourceEntries);
|
||||||
|
}
|
||||||
|
sourceEntries.push({
|
||||||
|
tag,
|
||||||
|
target: mod.target,
|
||||||
|
value,
|
||||||
|
threshold: mod.threshold,
|
||||||
|
kind: targetIsTagMap ? "tagmap" : "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit new combined value
|
||||||
|
const combined = getCombined(mod.target, fallback);
|
||||||
|
const existing = results.findIndex((r) => r.key === mod.target);
|
||||||
|
if (existing >= 0) {
|
||||||
|
results[existing] = { key: mod.target, value: combined };
|
||||||
|
} else {
|
||||||
|
results.push({ key: mod.target, value: combined });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// skip failed modifier
|
||||||
|
}
|
||||||
|
} else if (wasActive && !isActive) {
|
||||||
|
// Deactivate this modifier
|
||||||
|
const sourceEntries = sourceActivations.get(source);
|
||||||
|
if (!sourceEntries) continue;
|
||||||
|
|
||||||
|
const idx = sourceEntries.findIndex(
|
||||||
|
(e) => e.tag === tag && e.target === mod.target && e.threshold === mod.threshold,
|
||||||
|
);
|
||||||
|
if (idx < 0) continue;
|
||||||
|
|
||||||
|
const entry = sourceEntries[idx];
|
||||||
|
sourceEntries.splice(idx, 1);
|
||||||
|
|
||||||
|
// Remove from the appropriate mod list
|
||||||
|
if (entry.kind === "tagmap") {
|
||||||
|
const targetMods = tagMapMods.get(entry.target);
|
||||||
|
if (targetMods) {
|
||||||
|
const modIdx = targetMods.findIndex(
|
||||||
|
(m) => m.tag === tag && m.source === source && m.threshold === mod.threshold,
|
||||||
|
);
|
||||||
|
if (modIdx >= 0) targetMods.splice(modIdx, 1);
|
||||||
|
if (targetMods.length === 0) tagMapMods.delete(entry.target);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const targetMods = numericMods.get(entry.target);
|
||||||
|
if (targetMods) {
|
||||||
|
const modIdx = targetMods.findIndex(
|
||||||
|
(m) => m.tag === tag && m.source === source && m.threshold === mod.threshold,
|
||||||
|
);
|
||||||
|
if (modIdx >= 0) targetMods.splice(modIdx, 1);
|
||||||
|
if (targetMods.length === 0) numericMods.delete(entry.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit updated combined value
|
||||||
|
const combined = getCombined(entry.target, fallback);
|
||||||
|
const existing = results.findIndex((r) => r.key === entry.target);
|
||||||
|
if (existing >= 0) {
|
||||||
|
results[existing] = { key: entry.target, value: combined };
|
||||||
|
} else {
|
||||||
|
results.push({ key: entry.target, value: combined });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up empty sourceActivations
|
||||||
|
if (sourceActivations.get(source)?.length === 0) {
|
||||||
|
sourceActivations.delete(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Declaration re-evaluation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function reevaluateDependents(
|
||||||
|
changedVar: string,
|
||||||
|
fallback: VariableStore,
|
||||||
|
): Array<{ key: string; value: string }> {
|
||||||
|
if (!depGraph || !declExprs) return [];
|
||||||
|
|
||||||
|
// Collect all dependents reachable from changedVar (BFS)
|
||||||
|
const affected = new Set<string>();
|
||||||
|
const queue = [changedVar];
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const dep = queue.shift()!;
|
||||||
|
const dependents = depGraph.get(dep);
|
||||||
|
if (!dependents) continue;
|
||||||
|
for (const d of dependents) {
|
||||||
|
if (!affected.has(d)) {
|
||||||
|
affected.add(d);
|
||||||
|
queue.push(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = topoSortAffected(affected);
|
||||||
|
const results: Array<{ key: string; value: string }> = [];
|
||||||
|
|
||||||
|
for (const key of sorted) {
|
||||||
|
if (inFlight.has(key)) {
|
||||||
|
throw new Error(
|
||||||
|
`Circular dependency detected during evaluation of ${key}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
inFlight.add(key);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const expr = declExprs.get(key);
|
||||||
|
if (!expr) continue;
|
||||||
|
|
||||||
|
const result = evaluateExpression(expr, {
|
||||||
|
lookup: (name: string) => {
|
||||||
|
const k = "$" + name;
|
||||||
|
return getCombined(k, fallback);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawValue = exprValueToString(result.value);
|
||||||
|
|
||||||
|
// Check for tagmap transition on this declared variable
|
||||||
|
const oldCombined = getCombined(key, fallback);
|
||||||
|
const oldTagMap = isTagMapValue(oldCombined) ? parseTagMap(oldCombined) : {};
|
||||||
|
const newTagMap = isTagMapValue(rawValue) ? parseTagMap(rawValue) : {};
|
||||||
|
|
||||||
|
if (JSON.stringify(oldTagMap) !== JSON.stringify(newTagMap)) {
|
||||||
|
const tagResults = applyTagMapActivations(key, oldTagMap ?? {}, newTagMap ?? {}, fallback);
|
||||||
|
for (const r of tagResults) {
|
||||||
|
if (!results.some((x) => x.key === r.key)) {
|
||||||
|
results.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update base value
|
||||||
|
baseValues.set(key, rawValue);
|
||||||
|
|
||||||
|
// Emit combined value
|
||||||
|
const combined = getCombined(key, fallback);
|
||||||
|
if (!results.some((x) => x.key === key)) {
|
||||||
|
results.push({ key, value: combined });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
inFlight.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Topological sort & circular check
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function topoSortAffected(affected: Set<string>): string[] {
|
||||||
|
if (!depGraph || !declExprs) return [...affected];
|
||||||
|
|
||||||
|
const result: string[] = [];
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const temp = new Set<string>();
|
||||||
|
|
||||||
|
function visit(key: string): void {
|
||||||
|
if (visited.has(key)) return;
|
||||||
|
if (temp.has(key)) {
|
||||||
|
throw new Error(`Circular dependency involving ${key}`);
|
||||||
|
}
|
||||||
|
temp.add(key);
|
||||||
|
|
||||||
|
const expr = declExprs!.get(key);
|
||||||
|
if (expr) {
|
||||||
|
const deps = extractDependencies(expr);
|
||||||
|
for (const dep of deps) {
|
||||||
|
if (affected.has(dep) || declExprs!.has(dep)) {
|
||||||
|
visit(dep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
temp.delete(key);
|
||||||
|
visited.add(key);
|
||||||
|
result.push(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of affected) {
|
||||||
|
visit(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkCircular(): void {
|
||||||
|
if (!declExprs) return;
|
||||||
|
|
||||||
|
const allKeys = [...declExprs.keys()];
|
||||||
|
const state = new Map<string, "unvisited" | "visiting" | "visited">();
|
||||||
|
for (const k of allKeys) state.set(k, "unvisited");
|
||||||
|
|
||||||
|
const path: string[] = [];
|
||||||
|
|
||||||
|
function dfs(key: string): void {
|
||||||
|
const s = state.get(key);
|
||||||
|
if (s === "visited") return;
|
||||||
|
if (s === "visiting") {
|
||||||
|
const cycleStart = path.indexOf(key);
|
||||||
|
const cycle = path.slice(cycleStart).concat(key);
|
||||||
|
throw new Error(
|
||||||
|
`Circular dependency detected: ${cycle.join(" → ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.set(key, "visiting");
|
||||||
|
path.push(key);
|
||||||
|
|
||||||
|
const expr = declExprs!.get(key);
|
||||||
|
if (expr) {
|
||||||
|
const deps = extractDependencies(expr);
|
||||||
|
for (const dep of deps) {
|
||||||
|
if (declExprs!.has(dep)) {
|
||||||
|
dfs(dep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path.pop();
|
||||||
|
state.set(key, "visited");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of allKeys) {
|
||||||
|
dfs(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
/**
|
||||||
|
* Variable expression evaluator — parses and evaluates expressions used
|
||||||
|
* in `/set` commands and `role=declare` code blocks.
|
||||||
|
*
|
||||||
|
* Supports:
|
||||||
|
* - Number literals (integer or decimal)
|
||||||
|
* - Tagmap literals: #warrior:1;#druid:2 (bare #warrior → #warrior:1)
|
||||||
|
* - $var references (resolved via lookup; auto-detects number vs tagmap)
|
||||||
|
* - Dice patterns: 3d6, 2d8kh1, etc. (delegates to rollFormula)
|
||||||
|
* - Arithmetic: + - * / (type-checked via registry)
|
||||||
|
* - Functions: floor(x), ceil(x), round(x) (numbers only)
|
||||||
|
* - Parentheses for grouping
|
||||||
|
*
|
||||||
|
* Throws on:
|
||||||
|
* - Type mismatch (e.g. number + tagmap, tagmap * number)
|
||||||
|
* - Circular variable references (detected by caller)
|
||||||
|
* - Division by zero
|
||||||
|
* - Unknown functions
|
||||||
|
* - Malformed expressions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { rollFormula } from "../md-commander/hooks";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A value produced by the expression evaluator. */
|
||||||
|
export type ExprValue =
|
||||||
|
| { kind: "number"; value: number }
|
||||||
|
| { kind: "tagmap"; value: Record<string, number> };
|
||||||
|
|
||||||
|
export interface EvalContext {
|
||||||
|
/** Resolve $var → string (numeric or tagmap serialized form).
|
||||||
|
* Return undefined if the variable doesn't exist. */
|
||||||
|
lookup: (varName: string) => string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvalResult {
|
||||||
|
value: ExprValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate an expression string.
|
||||||
|
* Throws if the expression is malformed or contains a type mismatch.
|
||||||
|
*/
|
||||||
|
export function evaluateExpression(
|
||||||
|
expr: string,
|
||||||
|
ctx: EvalContext,
|
||||||
|
): EvalResult {
|
||||||
|
const tokens = tokenize(expr);
|
||||||
|
const result = parseExpression(tokens, 0, ctx);
|
||||||
|
if (result.next < tokens.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Unexpected token at position ${result.next}: "${tokens[result.next].raw}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { value: result.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Quick check: does this expression produce a tag value? */
|
||||||
|
export function expressionIsTag(expr: string): boolean {
|
||||||
|
const trimmed = expr.trim();
|
||||||
|
return trimmed.startsWith("#");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialize an ExprValue back to a string (for var-reactivity integration). */
|
||||||
|
export function exprValueToString(v: ExprValue): string {
|
||||||
|
if (v.kind === "number") return String(v.value);
|
||||||
|
// tagmap
|
||||||
|
const entries = Object.entries(v.value).filter(([, c]) => c > 0);
|
||||||
|
if (entries.length === 0) return "0";
|
||||||
|
return entries.map(([tag, count]) => `${tag}:${count}`).join(";");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Binary operation registry
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type BinaryOp = (a: ExprValue, b: ExprValue) => ExprValue;
|
||||||
|
|
||||||
|
/* Helpers that narrow ExprValue to specific kinds for use in registry callbacks. */
|
||||||
|
const num = (a: ExprValue, b: ExprValue): [number, number] =>
|
||||||
|
[a.value as number, b.value as number];
|
||||||
|
const tmap = (a: ExprValue, b: ExprValue): [Record<string, number>, Record<string, number>] =>
|
||||||
|
[a.value as Record<string, number>, b.value as Record<string, number>];
|
||||||
|
|
||||||
|
/** Registry: binaryOps[leftKind][rightKind][operator] → implementation.
|
||||||
|
* Any undefined combination throws a type-mismatch error. */
|
||||||
|
const binaryOps: Record<
|
||||||
|
string,
|
||||||
|
Record<string, Record<string, BinaryOp>>
|
||||||
|
> = {
|
||||||
|
number: {
|
||||||
|
number: {
|
||||||
|
"+": (a, b) => {
|
||||||
|
const [l, r] = num(a, b);
|
||||||
|
return { kind: "number", value: l + r };
|
||||||
|
},
|
||||||
|
"-": (a, b) => {
|
||||||
|
const [l, r] = num(a, b);
|
||||||
|
return { kind: "number", value: l - r };
|
||||||
|
},
|
||||||
|
"*": (a, b) => {
|
||||||
|
const [l, r] = num(a, b);
|
||||||
|
return { kind: "number", value: l * r };
|
||||||
|
},
|
||||||
|
"/": (a, b) => {
|
||||||
|
const [l, r] = num(a, b);
|
||||||
|
if (r === 0) throw new Error("Division by zero");
|
||||||
|
return { kind: "number", value: l / r };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tagmap: {
|
||||||
|
tagmap: {
|
||||||
|
"+": (a, b) => {
|
||||||
|
const [leftMap, rightMap] = tmap(a, b);
|
||||||
|
const result: Record<string, number> = { ...leftMap };
|
||||||
|
for (const [tag, count] of Object.entries(rightMap)) {
|
||||||
|
result[tag] = (result[tag] ?? 0) + count;
|
||||||
|
}
|
||||||
|
return { kind: "tagmap", value: result };
|
||||||
|
},
|
||||||
|
"-": (a, b) => {
|
||||||
|
const [leftMap, rightMap] = tmap(a, b);
|
||||||
|
const result: Record<string, number> = { ...leftMap };
|
||||||
|
for (const [tag, count] of Object.entries(rightMap)) {
|
||||||
|
result[tag] = (result[tag] ?? 0) - count;
|
||||||
|
if (result[tag] <= 0) delete result[tag];
|
||||||
|
}
|
||||||
|
return { kind: "tagmap", value: result };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function getBinaryOp(
|
||||||
|
left: ExprValue,
|
||||||
|
right: ExprValue,
|
||||||
|
op: string,
|
||||||
|
): BinaryOp {
|
||||||
|
const opFn = binaryOps[left.kind]?.[right.kind]?.[op];
|
||||||
|
if (!opFn) {
|
||||||
|
throw new Error(
|
||||||
|
`Type mismatch: cannot ${op} ${left.kind} with ${right.kind}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return opFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tokenizer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Token {
|
||||||
|
kind: "number" | "tagmap" | "var" | "ident" | "op" | "lparen" | "rparen" | "comma";
|
||||||
|
value: string;
|
||||||
|
raw: string;
|
||||||
|
/** Pre-parsed tagmap data (only set when kind === "tagmap") */
|
||||||
|
tagmap?: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dice pattern: e.g. "3d6", "2d8kh1", "d20" */
|
||||||
|
const DICE_RE = /^\d*d\d+(?:[kdh]\d+)*$/i;
|
||||||
|
|
||||||
|
/** Single tagmap entry: "#warrior" or "#warrior:1" */
|
||||||
|
const TAGMAP_ENTRY_RE = /^#([a-zA-Z_][a-zA-Z0-9_]*)(?::(\d+))?/;
|
||||||
|
|
||||||
|
function tokenize(input: string): Token[] {
|
||||||
|
const tokens: Token[] = [];
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < input.length) {
|
||||||
|
const ch = input[i];
|
||||||
|
|
||||||
|
// Whitespace
|
||||||
|
if (/\s/.test(ch)) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Number (integer or decimal, but NOT followed by 'd' which makes it a dice pattern)
|
||||||
|
if (/[0-9]/.test(ch)) {
|
||||||
|
let num = "";
|
||||||
|
while (i < input.length && /[0-9.]/.test(input[i])) {
|
||||||
|
num += input[i];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
// Peek ahead: if next char is 'd' (case-insensitive), this is a dice pattern
|
||||||
|
if (i < input.length && /[dD]/.test(input[i])) {
|
||||||
|
// Dice pattern
|
||||||
|
let dice = num;
|
||||||
|
while (i < input.length && /[a-zA-Z0-9]/.test(input[i])) {
|
||||||
|
dice += input[i];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (!DICE_RE.test(dice)) {
|
||||||
|
throw new Error(`Invalid dice notation: "${dice}"`);
|
||||||
|
}
|
||||||
|
tokens.push({ kind: "number", value: String(rollDice(dice)), raw: dice });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
tokens.push({ kind: "number", value: num, raw: num });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tagmap literal: #warrior:1;#druid:2 or bare #warrior
|
||||||
|
if (ch === "#") {
|
||||||
|
let raw = "";
|
||||||
|
const map: Record<string, number> = {};
|
||||||
|
|
||||||
|
while (i < input.length && input[i] === "#") {
|
||||||
|
// Match one entry from the current position
|
||||||
|
const remaining = input.slice(i);
|
||||||
|
const m = TAGMAP_ENTRY_RE.exec(remaining);
|
||||||
|
if (!m) {
|
||||||
|
throw new Error(`Invalid tagmap entry at position ${i}: "${remaining.slice(0, 20)}..."`);
|
||||||
|
}
|
||||||
|
const matched = m[0];
|
||||||
|
raw += (raw ? ";" : "") + matched;
|
||||||
|
const tag = "#" + m[1];
|
||||||
|
const count = m[2] !== undefined ? parseInt(m[2], 10) : 1;
|
||||||
|
if (count > 0) {
|
||||||
|
map[tag] = (map[tag] ?? 0) + count;
|
||||||
|
}
|
||||||
|
i += matched.length;
|
||||||
|
|
||||||
|
// Skip whitespace after the entry
|
||||||
|
while (i < input.length && input[i] === " ") i++;
|
||||||
|
|
||||||
|
// Check for semicolon separator (continue to next entry)
|
||||||
|
if (i < input.length && input[i] === ";") {
|
||||||
|
raw += ";";
|
||||||
|
i++;
|
||||||
|
// Skip whitespace after semicolon
|
||||||
|
while (i < input.length && input[i] === " ") i++;
|
||||||
|
// If the next char is not '#', we're done with the tagmap
|
||||||
|
if (i >= input.length || input[i] !== "#") break;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(map).length === 0) {
|
||||||
|
throw new Error(`Empty tagmap: "${raw}"`);
|
||||||
|
}
|
||||||
|
tokens.push({ kind: "tagmap", value: raw, raw, tagmap: map });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variable reference: $var
|
||||||
|
if (ch === "$") {
|
||||||
|
let ident = "$";
|
||||||
|
i++;
|
||||||
|
if (i >= input.length || !/[a-zA-Z_]/.test(input[i])) {
|
||||||
|
throw new Error(`Invalid variable reference at position ${i - 1}: expected identifier after $`);
|
||||||
|
}
|
||||||
|
while (i < input.length && /[a-zA-Z0-9_]/.test(input[i])) {
|
||||||
|
ident += input[i];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
tokens.push({ kind: "var", value: ident, raw: ident });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identifier or function name
|
||||||
|
if (/[a-zA-Z_]/.test(ch)) {
|
||||||
|
let ident = "";
|
||||||
|
while (i < input.length && /[a-zA-Z0-9_]/.test(input[i])) {
|
||||||
|
ident += input[i];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
tokens.push({ kind: "ident", value: ident, raw: ident });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operators and punctuation
|
||||||
|
switch (ch) {
|
||||||
|
case "+":
|
||||||
|
case "-":
|
||||||
|
case "*":
|
||||||
|
case "/":
|
||||||
|
tokens.push({ kind: "op", value: ch, raw: ch });
|
||||||
|
i++;
|
||||||
|
break;
|
||||||
|
case "(":
|
||||||
|
tokens.push({ kind: "lparen", value: "(", raw: "(" });
|
||||||
|
i++;
|
||||||
|
break;
|
||||||
|
case ")":
|
||||||
|
tokens.push({ kind: "rparen", value: ")", raw: ")" });
|
||||||
|
i++;
|
||||||
|
break;
|
||||||
|
case ",":
|
||||||
|
tokens.push({ kind: "comma", value: ",", raw: "," });
|
||||||
|
i++;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unexpected character: "${ch}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dice helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function rollDice(notation: string): number {
|
||||||
|
const result = rollFormula(notation);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(`Dice roll failed: ${result.error ?? notation}`);
|
||||||
|
}
|
||||||
|
return result.result.total;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Recursive descent parser
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface ParseResult {
|
||||||
|
value: ExprValue;
|
||||||
|
next: number; // index of next unconsumed token
|
||||||
|
}
|
||||||
|
|
||||||
|
/** expression := term (("+" | "-") term)* */
|
||||||
|
function parseExpression(
|
||||||
|
tokens: Token[],
|
||||||
|
pos: number,
|
||||||
|
ctx: EvalContext,
|
||||||
|
): ParseResult {
|
||||||
|
let result = parseTerm(tokens, pos, ctx);
|
||||||
|
pos = result.next;
|
||||||
|
|
||||||
|
while (pos < tokens.length) {
|
||||||
|
const tok = tokens[pos];
|
||||||
|
if (tok.kind === "op" && (tok.value === "+" || tok.value === "-")) {
|
||||||
|
const right = parseTerm(tokens, pos + 1, ctx);
|
||||||
|
const opFn = getBinaryOp(result.value, right.value, tok.value);
|
||||||
|
result = { value: opFn(result.value, right.value), next: right.next };
|
||||||
|
pos = result.next;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** term := factor (("*" | "/") factor)* */
|
||||||
|
function parseTerm(
|
||||||
|
tokens: Token[],
|
||||||
|
pos: number,
|
||||||
|
ctx: EvalContext,
|
||||||
|
): ParseResult {
|
||||||
|
let result = parseFactor(tokens, pos, ctx);
|
||||||
|
pos = result.next;
|
||||||
|
|
||||||
|
while (pos < tokens.length) {
|
||||||
|
const tok = tokens[pos];
|
||||||
|
if (tok.kind === "op" && (tok.value === "*" || tok.value === "/")) {
|
||||||
|
const right = parseFactor(tokens, pos + 1, ctx);
|
||||||
|
const opFn = getBinaryOp(result.value, right.value, tok.value);
|
||||||
|
result = { value: opFn(result.value, right.value), next: right.next };
|
||||||
|
pos = result.next;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** factor := number | tagmap | "$" ident | ident "(" expr ")" | "(" expr ")" | "-" factor */
|
||||||
|
function parseFactor(
|
||||||
|
tokens: Token[],
|
||||||
|
pos: number,
|
||||||
|
ctx: EvalContext,
|
||||||
|
): ParseResult {
|
||||||
|
if (pos >= tokens.length) {
|
||||||
|
throw new Error("Unexpected end of expression");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tok = tokens[pos];
|
||||||
|
|
||||||
|
// Unary minus (numbers only)
|
||||||
|
if (tok.kind === "op" && tok.value === "-") {
|
||||||
|
const inner = parseFactor(tokens, pos + 1, ctx);
|
||||||
|
if (inner.value.kind !== "number") {
|
||||||
|
throw new Error(`Type mismatch: cannot negate ${inner.value.kind}`);
|
||||||
|
}
|
||||||
|
return { value: { kind: "number", value: -inner.value.value }, next: inner.next };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Number literal (including already-rolled dice patterns)
|
||||||
|
if (tok.kind === "number") {
|
||||||
|
return { value: { kind: "number", value: parseFloat(tok.value) }, next: pos + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tagmap literal
|
||||||
|
if (tok.kind === "tagmap") {
|
||||||
|
return { value: { kind: "tagmap", value: { ...tok.tagmap! } }, next: pos + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variable reference: $var
|
||||||
|
if (tok.kind === "var") {
|
||||||
|
const varName = tok.value; // includes $ prefix
|
||||||
|
const resolved = ctx.lookup(varName.slice(1)); // strip $ for lookup
|
||||||
|
if (resolved === undefined) {
|
||||||
|
return { value: { kind: "number", value: 0 }, next: pos + 1 };
|
||||||
|
}
|
||||||
|
// Auto-detect: tagmap or numeric
|
||||||
|
if (resolved.startsWith("#")) {
|
||||||
|
const map = parseTagMapValue(resolved);
|
||||||
|
if (!map) {
|
||||||
|
throw new Error(
|
||||||
|
`Type mismatch: ${varName} is not a valid tagmap ("${resolved}")`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { value: { kind: "tagmap", value: map }, next: pos + 1 };
|
||||||
|
}
|
||||||
|
const num = parseFloat(resolved);
|
||||||
|
if (isNaN(num)) {
|
||||||
|
throw new Error(
|
||||||
|
`Type mismatch: ${varName} is not numeric ("${resolved}")`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { value: { kind: "number", value: num }, next: pos + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parenthesized expression
|
||||||
|
if (tok.kind === "lparen") {
|
||||||
|
const inner = parseExpression(tokens, pos + 1, ctx);
|
||||||
|
if (inner.next >= tokens.length || tokens[inner.next].kind !== "rparen") {
|
||||||
|
throw new Error("Missing closing parenthesis");
|
||||||
|
}
|
||||||
|
return { value: inner.value, next: inner.next + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function call: ident "(" expression ")"
|
||||||
|
if (tok.kind === "ident") {
|
||||||
|
const name = tok.value;
|
||||||
|
|
||||||
|
// Check for function call: ident "(" ...
|
||||||
|
if (pos + 1 < tokens.length && tokens[pos + 1].kind === "lparen") {
|
||||||
|
const arg = parseExpression(tokens, pos + 2, ctx);
|
||||||
|
if (arg.next >= tokens.length || tokens[arg.next].kind !== "rparen") {
|
||||||
|
throw new Error(`Missing closing parenthesis after ${name}(...)`);
|
||||||
|
}
|
||||||
|
const value = applyFunction(name, arg.value);
|
||||||
|
return { value, next: arg.next + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unknown identifier: "${name}" (use $ for variables)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected token: "${tok.raw}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFunction(name: string, arg: ExprValue): ExprValue {
|
||||||
|
if (arg.kind !== "number") {
|
||||||
|
throw new Error(`Type mismatch: ${name}() requires a number, got ${arg.kind}`);
|
||||||
|
}
|
||||||
|
switch (name.toLowerCase()) {
|
||||||
|
case "floor":
|
||||||
|
return { kind: "number", value: Math.floor(arg.value) };
|
||||||
|
case "ceil":
|
||||||
|
return { kind: "number", value: Math.ceil(arg.value) };
|
||||||
|
case "round":
|
||||||
|
return { kind: "number", value: Math.round(arg.value) };
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown function: ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tagmap parsing (shared with var-reactivity, duplicated to avoid circular deps)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const TAGMAP_RE = /^#([a-zA-Z_][a-zA-Z0-9_]*):(\d+)$/;
|
||||||
|
|
||||||
|
function parseTagMapValue(value: string): Record<string, number> | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed.startsWith("#")) return null;
|
||||||
|
|
||||||
|
const parts = trimmed.split(";");
|
||||||
|
const map: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const m = TAGMAP_RE.exec(part.trim());
|
||||||
|
if (!m) return null;
|
||||||
|
const tag = "#" + m[1];
|
||||||
|
const count = parseInt(m[2], 10);
|
||||||
|
if (count <= 0) continue;
|
||||||
|
map[tag] = (map[tag] ?? 0) + count;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(map).length > 0 ? map : null;
|
||||||
|
}
|
||||||
@@ -38,7 +38,10 @@ export function rollDicePool(pool: DicePool): DicePoolResult {
|
|||||||
const { keptRolls, droppedRolls } = applyModifier(rolls, pool.modifier);
|
const { keptRolls, droppedRolls } = applyModifier(rolls, pool.modifier);
|
||||||
|
|
||||||
// 固定数字总是保留
|
// 固定数字总是保留
|
||||||
const finalKeptRolls = [...keptRolls, ...allRolls.filter((r) => r.sides === 0)];
|
const finalKeptRolls = [
|
||||||
|
...keptRolls,
|
||||||
|
...allRolls.filter((r) => r.sides === 0),
|
||||||
|
];
|
||||||
|
|
||||||
// 计算小计
|
// 计算小计
|
||||||
const subtotal = finalKeptRolls.reduce((sum, roll) => {
|
const subtotal = finalKeptRolls.reduce((sum, roll) => {
|
||||||
@@ -59,7 +62,7 @@ export function rollDicePool(pool: DicePool): DicePoolResult {
|
|||||||
*/
|
*/
|
||||||
export function applyModifier(
|
export function applyModifier(
|
||||||
rolls: DieResult[],
|
rolls: DieResult[],
|
||||||
modifier: DicePool["modifier"]
|
modifier: DicePool["modifier"],
|
||||||
): { keptRolls: DieResult[]; droppedRolls: DieResult[] } {
|
): { keptRolls: DieResult[]; droppedRolls: DieResult[] } {
|
||||||
if (!modifier) {
|
if (!modifier) {
|
||||||
return { keptRolls: rolls, droppedRolls: [] };
|
return { keptRolls: rolls, droppedRolls: [] };
|
||||||
@@ -70,7 +73,8 @@ export function applyModifier(
|
|||||||
|
|
||||||
switch (modifier.type) {
|
switch (modifier.type) {
|
||||||
case "kh": // Keep Highest - 保留最大的 N 个
|
case "kh": // Keep Highest - 保留最大的 N 个
|
||||||
case "dh": { // Drop Highest - 丢弃最大的 N 个
|
case "dh": {
|
||||||
|
// Drop Highest - 丢弃最大的 N 个
|
||||||
const count = modifier.count;
|
const count = modifier.count;
|
||||||
const higher = sorted.slice(-count);
|
const higher = sorted.slice(-count);
|
||||||
const rest = sorted.slice(0, -count);
|
const rest = sorted.slice(0, -count);
|
||||||
@@ -81,7 +85,8 @@ export function applyModifier(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "kl": // Keep Lowest - 保留最小的 N 个
|
case "kl": // Keep Lowest - 保留最小的 N 个
|
||||||
case "dl": { // Drop Lowest - 丢弃最小的 N 个
|
case "dl": {
|
||||||
|
// Drop Lowest - 丢弃最小的 N 个
|
||||||
const count = modifier.count;
|
const count = modifier.count;
|
||||||
const lower = sorted.slice(0, count);
|
const lower = sorted.slice(0, count);
|
||||||
const rest = sorted.slice(count);
|
const rest = sorted.slice(count);
|
||||||
@@ -105,29 +110,35 @@ export function roll(formula: string): RollResult {
|
|||||||
|
|
||||||
const total = poolResults.reduce((sum, result) => sum + result.subtotal, 0);
|
const total = poolResults.reduce((sum, result) => sum + result.subtotal, 0);
|
||||||
|
|
||||||
// 生成详细表达式 - 使用 HTML 格式
|
// Generate detail expressions (both HTML and plain text)
|
||||||
const rollSpans: string[] = [];
|
const htmlSpans: string[] = [];
|
||||||
|
const textSpans: string[] = [];
|
||||||
for (const result of poolResults) {
|
for (const result of poolResults) {
|
||||||
// 构建骰子结果 HTML
|
|
||||||
for (const roll of result.rolls) {
|
for (const roll of result.rolls) {
|
||||||
const isKept = result.keptRolls.some((kept) => kept === roll);
|
const isKept = result.keptRolls.some((kept) => kept === roll);
|
||||||
if (roll.sides === 0) {
|
if (roll.sides === 0) {
|
||||||
// 固定数字,不显示括号
|
const sign = roll.isNegative ? "-" : "";
|
||||||
rollSpans.push(`<strong>${roll.isNegative ? '-' : ''}${roll.value}</strong>`);
|
htmlSpans.push(`<strong>${sign}${roll.value}</strong>`);
|
||||||
|
textSpans.push(`${sign}${roll.value}`);
|
||||||
} else if (isKept) {
|
} else if (isKept) {
|
||||||
rollSpans.push(`<strong>[${roll.value}]</strong>`);
|
htmlSpans.push(`<strong>[${roll.value}]</strong>`);
|
||||||
|
textSpans.push(`[${roll.value}]`);
|
||||||
} else {
|
} else {
|
||||||
rollSpans.push(`<span class="text-gray-400">[${roll.value}]</span>`);
|
htmlSpans.push(`<span class="text-gray-400">[${roll.value}]</span>`);
|
||||||
|
textSpans.push(`[${roll.value}]`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rollsStr = rollSpans.join(" + ");
|
const rollHtml = htmlSpans.join(" + ");
|
||||||
const detail = `<strong>${total}</strong> = ${rollsStr}`;
|
const rollText = textSpans.join(" + ");
|
||||||
|
const detail = `<strong>${total}</strong> = ${rollHtml}`;
|
||||||
|
const plainDetail = `${total} = ${rollText}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pools: poolResults,
|
pools: poolResults,
|
||||||
total,
|
total,
|
||||||
detail,
|
detail,
|
||||||
|
plainDetail,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export interface DieResult {
|
|||||||
export interface DicePool {
|
export interface DicePool {
|
||||||
dice: DieResult[]; // 骰子列表(未掷骰前为模板)
|
dice: DieResult[]; // 骰子列表(未掷骰前为模板)
|
||||||
modifier?: {
|
modifier?: {
|
||||||
type: 'kh' | 'kl' | 'dh' | 'dl';
|
type: "kh" | "kl" | "dh" | "dl";
|
||||||
count: number;
|
count: number;
|
||||||
};
|
};
|
||||||
isNegative: boolean; // 整个骰池是否为负
|
isNegative: boolean; // 整个骰池是否为负
|
||||||
@@ -37,7 +37,8 @@ export interface DicePool {
|
|||||||
export interface RollResult {
|
export interface RollResult {
|
||||||
pools: DicePoolResult[]; // 各骰池结果
|
pools: DicePoolResult[]; // 各骰池结果
|
||||||
total: number; // 总和
|
total: number; // 总和
|
||||||
detail: string; // 详细表达式
|
detail: string; // 详细表达式 (HTML)
|
||||||
|
plainDetail: string; // 详细表达式 (plain text)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,11 +56,11 @@ export interface DicePoolResult {
|
|||||||
* 解析后的令牌
|
* 解析后的令牌
|
||||||
*/
|
*/
|
||||||
export type Token =
|
export type Token =
|
||||||
| { type: 'number'; value: number }
|
| { type: "number"; value: number }
|
||||||
| { type: 'dice'; count: number; sides: number }
|
| { type: "dice"; count: number; sides: number }
|
||||||
| { type: 'pool_start' }
|
| { type: "pool_start" }
|
||||||
| { type: 'pool_end' }
|
| { type: "pool_end" }
|
||||||
| { type: 'comma' }
|
| { type: "comma" }
|
||||||
| { type: 'plus' }
|
| { type: "plus" }
|
||||||
| { type: 'minus' }
|
| { type: "minus" }
|
||||||
| { type: 'modifier'; modType: 'kh' | 'kl' | 'dh' | 'dl'; count: number };
|
| { type: "modifier"; modType: "kh" | "kl" | "dh" | "dl"; count: number };
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface DiceRollerResult {
|
|||||||
result: {
|
result: {
|
||||||
total: number;
|
total: number;
|
||||||
detail: string;
|
detail: string;
|
||||||
|
plainDetail: string;
|
||||||
pools: Array<{
|
pools: Array<{
|
||||||
rolls: number[];
|
rolls: number[];
|
||||||
keptRolls: number[];
|
keptRolls: number[];
|
||||||
@@ -38,6 +39,7 @@ export function rollFormula(formula: string): DiceRollerResult {
|
|||||||
result: {
|
result: {
|
||||||
total: result.total,
|
total: result.total,
|
||||||
detail: result.detail,
|
detail: result.detail,
|
||||||
|
plainDetail: result.plainDetail,
|
||||||
pools: result.pools.map((pool) => ({
|
pools: result.pools.map((pool) => ({
|
||||||
rolls: pool.rolls.map((r) => r.value),
|
rolls: pool.rolls.map((r) => r.value),
|
||||||
keptRolls: pool.keptRolls.map((r) => r.value),
|
keptRolls: pool.keptRolls.map((r) => r.value),
|
||||||
@@ -52,6 +54,7 @@ export function rollFormula(formula: string): DiceRollerResult {
|
|||||||
result: {
|
result: {
|
||||||
total: 0,
|
total: 0,
|
||||||
detail: "",
|
detail: "",
|
||||||
|
plainDetail: "",
|
||||||
pools: [],
|
pools: [],
|
||||||
},
|
},
|
||||||
success: false,
|
success: false,
|
||||||
@@ -64,7 +67,10 @@ export function rollFormula(formula: string): DiceRollerResult {
|
|||||||
* 简化版掷骰,返回 HTML 格式结果
|
* 简化版掷骰,返回 HTML 格式结果
|
||||||
* 格式:[1] [2] [3] = <strong>6</strong>
|
* 格式:[1] [2] [3] = <strong>6</strong>
|
||||||
*/
|
*/
|
||||||
export function rollSimple(formula: string): { text: string; isHtml?: boolean } {
|
export function rollSimple(formula: string): {
|
||||||
|
text: string;
|
||||||
|
isHtml?: boolean;
|
||||||
|
} {
|
||||||
try {
|
try {
|
||||||
const result = rollDice(formula);
|
const result = rollDice(formula);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createStore } from "solid-js/store";
|
|||||||
import type { MdCommanderCommand, MdCommanderCommandMap } from "../types";
|
import type { MdCommanderCommand, MdCommanderCommandMap } from "../types";
|
||||||
import { setupHelpCommand, clearCommand, rollCommand, trackCommand, untrackCommand, listTrackCommand } from "../commands";
|
import { setupHelpCommand, clearCommand, rollCommand, trackCommand, untrackCommand, listTrackCommand } from "../commands";
|
||||||
import {resolvePath} from "../../utils/path";
|
import {resolvePath} from "../../utils/path";
|
||||||
import {loadCSV} from "../../utils/csv-loader";
|
import {loadCSVFromPath} from "../../utils/csv-loader";
|
||||||
|
|
||||||
const defaultCommands: MdCommanderCommandMap = {
|
const defaultCommands: MdCommanderCommandMap = {
|
||||||
help: setupHelpCommand({}),
|
help: setupHelpCommand({}),
|
||||||
@@ -111,7 +111,7 @@ export async function loadCommandTemplatesFromCSV(
|
|||||||
setCommandsError(undefined);
|
setCommandsError(undefined);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const csv = await loadCSV<CommandTemplateRow>(resolvePath(articlePath, path));
|
const csv = await loadCSVFromPath<CommandTemplateRow>(resolvePath(articlePath, path));
|
||||||
|
|
||||||
// 按命令分组模板
|
// 按命令分组模板
|
||||||
const templatesByCommand = new Map<string, CommandTemplateRow[]>();
|
const templatesByCommand = new Map<string, CommandTemplateRow[]>();
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { createMemo, For, Show } from "solid-js";
|
import { createMemo, For, Show, type JSX } from "solid-js";
|
||||||
import { parseMarkdown } from "../../markdown";
|
import { parseMarkdown } from "../../markdown";
|
||||||
import { getLayerStyle } from "./hooks/dimensions";
|
import { getLayerStyle } from "./hooks/dimensions";
|
||||||
import type { CardData, CardSide, LayerConfig } from "./types";
|
import type { Align, 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,25 +25,35 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAlignStyle = (align?: "l" | "c" | "r") => {
|
const getAlignStyle = (align?: Align) => {
|
||||||
if (align === "l") return "left";
|
const horizontal = align?.includes("l")
|
||||||
if (align === "r") return "right";
|
? "left"
|
||||||
return "center";
|
: align?.includes("r")
|
||||||
|
? "right"
|
||||||
|
: "center";
|
||||||
|
const vertical = align?.includes("t")
|
||||||
|
? "flex-start"
|
||||||
|
: align?.includes("b")
|
||||||
|
? "flex-end"
|
||||||
|
: "center";
|
||||||
|
return {
|
||||||
|
"text-align": horizontal,
|
||||||
|
"justify-content": vertical,
|
||||||
|
} as JSX.CSSProperties;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isLayerSelected = (layerIndex: number) =>
|
const isLayerSelected = (layerIndex: number) =>
|
||||||
selectedLayer() === layerIndex;
|
selectedLayer() === layerIndex;
|
||||||
|
|
||||||
|
const isEditing = () =>
|
||||||
|
props.store.state.isEditing && !props.store.state.fixed;
|
||||||
|
|
||||||
const getFrameBounds = (layer: LayerConfig) => {
|
const getFrameBounds = (layer: LayerConfig) => {
|
||||||
const dims = dimensions();
|
const dims = dimensions();
|
||||||
const left = (layer.x1 - 1) * dims.cellWidth;
|
const left = (layer.x1 - 1) * dims.cellWidth;
|
||||||
@@ -80,20 +89,23 @@ export function CardLayer(props: CardLayerProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<article
|
<article
|
||||||
class="absolute flex flex-col items-stretch justify-center prose text-black prose-sm cursor-pointer"
|
class="absolute flex flex-col items-stretch justify-center prose text-black prose-sm"
|
||||||
classList={{
|
classList={{
|
||||||
|
"cursor-pointer": isEditing(),
|
||||||
"ring-2 ring-blue-500 ring-offset-1":
|
"ring-2 ring-blue-500 ring-offset-1":
|
||||||
isSelected() && !draggingState(),
|
isSelected() && !draggingState() && isEditing(),
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
...getLayerStyle(layer, dimensions()),
|
...getLayerStyle(layer, dimensions()),
|
||||||
"font-size": `${layer.fontSize || 3}mm`,
|
"font-size": `${layer.fontSize || 3}mm`,
|
||||||
"text-align": getAlignStyle(layer.align),
|
...getAlignStyle(layer.align),
|
||||||
}}
|
}}
|
||||||
innerHTML={renderLayerContent(props.cardData[layer.prop])}
|
innerHTML={renderLayerContent(
|
||||||
|
layer.template ?? props.cardData[layer.prop ?? ""],
|
||||||
|
)}
|
||||||
onClick={(e) => handleLayerClick(index(), e)}
|
onClick={(e) => handleLayerClick(index(), e)}
|
||||||
/>
|
/>
|
||||||
<Show when={isSelected()}>
|
<Show when={isSelected() && isEditing()}>
|
||||||
<div
|
<div
|
||||||
class="absolute border-2 border-blue-500 pointer-events-none z-10"
|
class="absolute border-2 border-blue-500 pointer-events-none z-10"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { For, Show, createEffect, createSignal, on } from "solid-js";
|
||||||
|
import type { DeckStore } from "./hooks/deckStore";
|
||||||
|
|
||||||
|
export interface CardListProps {
|
||||||
|
store: DeckStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卡牌列表:左侧垂直导航,每行高度一致,按页分页
|
||||||
|
*/
|
||||||
|
export function CardList(props: CardListProps) {
|
||||||
|
const { store } = props;
|
||||||
|
const [page, setPage] = createSignal(0);
|
||||||
|
|
||||||
|
const totalPages = () =>
|
||||||
|
Math.max(1, Math.ceil(store.state.cards.length / PAGE_SIZE));
|
||||||
|
|
||||||
|
const pageCards = () => {
|
||||||
|
const start = page() * PAGE_SIZE;
|
||||||
|
return store.state.cards.slice(start, start + PAGE_SIZE);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 当活动卡牌变化时,自动跳转到其所在页(只响应 activeTab,避免与手动翻页互相干扰)
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => store.state.activeTab,
|
||||||
|
(tab) => {
|
||||||
|
setPage(Math.floor(tab / PAGE_SIZE));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav class="w-44 shrink-0 border-r border-gray-200 pr-3 flex flex-col">
|
||||||
|
<div class="flex flex-col gap-1 flex-1">
|
||||||
|
<For each={pageCards()}>
|
||||||
|
{(card, index) => {
|
||||||
|
const globalIndex = () => page() * PAGE_SIZE + index();
|
||||||
|
const active = () => store.state.activeTab === globalIndex();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => store.actions.setActiveTab(globalIndex())}
|
||||||
|
class={`flex items-center gap-2 w-full text-left px-3 py-2 rounded text-sm font-medium transition-colors cursor-pointer ${
|
||||||
|
active()
|
||||||
|
? "bg-blue-100 text-blue-600"
|
||||||
|
: "text-gray-600 hover:bg-gray-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span class="shrink-0 text-xs text-gray-400 tabular-nums w-5">
|
||||||
|
{globalIndex() + 1}
|
||||||
|
</span>
|
||||||
|
<span class="truncate">
|
||||||
|
{card.label || card.name || `Card ${globalIndex() + 1}`}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={totalPages() > 1}>
|
||||||
|
<div class="flex items-center justify-between mt-2 pt-2 border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(Math.max(0, page() - 1))}
|
||||||
|
disabled={page() === 0}
|
||||||
|
class="px-2 py-1 rounded text-sm text-gray-600 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-gray-500 tabular-nums">
|
||||||
|
{page() + 1} / {totalPages()}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(Math.min(totalPages() - 1, page() + 1))}
|
||||||
|
disabled={page() >= totalPages() - 1}
|
||||||
|
class="px-2 py-1 rounded text-sm text-gray-600 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { For } from "solid-js";
|
import { Show, createSignal, onMount } from "solid-js";
|
||||||
|
import { Portal } from "solid-js/web";
|
||||||
import type { DeckStore } from "./hooks/deckStore";
|
import type { DeckStore } from "./hooks/deckStore";
|
||||||
|
|
||||||
export interface DeckHeaderProps {
|
export interface DeckHeaderProps {
|
||||||
@@ -10,8 +11,28 @@ export interface DeckHeaderProps {
|
|||||||
*/
|
*/
|
||||||
export function DeckHeader(props: DeckHeaderProps) {
|
export function DeckHeader(props: DeckHeaderProps) {
|
||||||
const { store } = props;
|
const { store } = props;
|
||||||
|
const [showCopyFallback, setShowCopyFallback] = createSignal(false);
|
||||||
|
const [fallbackCode, setFallbackCode] = createSignal("");
|
||||||
|
let textareaRef: HTMLTextAreaElement | undefined;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (showCopyFallback() && textareaRef) {
|
||||||
|
textareaRef.focus();
|
||||||
|
textareaRef.select();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
store.actions.copyCode((code) => {
|
||||||
|
setFallbackCode(code);
|
||||||
|
setShowCopyFallback(true);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeFallback = () => setShowCopyFallback(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div class="flex items-center gap-2 border-b border-gray-200 pb-2 mb-4">
|
<div class="flex items-center gap-2 border-b border-gray-200 pb-2 mb-4">
|
||||||
{/* 编辑按钮 */}
|
{/* 编辑按钮 */}
|
||||||
<button
|
<button
|
||||||
@@ -33,23 +54,50 @@ export function DeckHeader(props: DeckHeaderProps) {
|
|||||||
📥 导出 PDF
|
📥 导出 PDF
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Tab 选择器 */}
|
{/* 复制代码按钮 */}
|
||||||
<div class="flex gap-1 overflow-x-auto flex-1 min-w-0 flex-wrap">
|
|
||||||
<For each={store.state.cards}>
|
|
||||||
{(card, index) => (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => store.actions.setActiveTab(index())}
|
onClick={handleCopy}
|
||||||
class={`font-medium transition-colors shrink-0 min-w-[1.6em] cursor-pointer px-2 py-1 rounded ${
|
class="px-2 py-1 rounded text-xs font-medium transition-colors cursor-pointer bg-purple-100 text-purple-600 hover:bg-purple-200"
|
||||||
store.state.activeTab === index()
|
|
||||||
? "bg-blue-100 text-blue-600 border-b-2 border-blue-600"
|
|
||||||
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{card.label || card.name || `Card ${index() + 1}`}
|
📋 复制代码
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 剪贴板不可用时的手动复制弹窗 */}
|
||||||
|
<Show when={showCopyFallback()}>
|
||||||
|
<Portal>
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-black/50 z-60 flex items-center justify-center p-4"
|
||||||
|
onClick={closeFallback}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-white rounded-lg shadow-xl w-full max-w-lg p-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 class="font-bold mb-2">复制代码</h3>
|
||||||
|
<p class="text-sm text-gray-600 mb-3">
|
||||||
|
自动复制失败(当前站点可能不是 HTTPS),请手动复制下面的代码:
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
readonly
|
||||||
|
value={fallbackCode()}
|
||||||
|
class="w-full h-40 border border-gray-300 rounded px-3 py-2 text-xs font-mono bg-gray-50 resize-none"
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
onClick={(e) => e.currentTarget.select()}
|
||||||
|
/>
|
||||||
|
<div class="flex justify-end gap-2 mt-3">
|
||||||
|
<button
|
||||||
|
onClick={closeFallback}
|
||||||
|
class="px-3 py-1.5 rounded text-sm font-medium cursor-pointer bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
|
>
|
||||||
|
关闭
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</Portal>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { createSignal } from "solid-js";
|
||||||
|
import type { DeckStore } from "./hooks/deckStore";
|
||||||
|
import { LayerEditorPanel, PropertiesEditorPanel } from "./editor-panel";
|
||||||
|
|
||||||
|
export interface EditorTabsProps {
|
||||||
|
store: DeckStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabKey = "sizing" | "front" | "back";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑面板:尺寸 / 正面图层 / 背面图层 用标签页切换,节省横向空间
|
||||||
|
*/
|
||||||
|
export function EditorTabs(props: EditorTabsProps) {
|
||||||
|
const { store } = props;
|
||||||
|
const [tab, setTab] = createSignal<TabKey>("sizing");
|
||||||
|
|
||||||
|
const tabs: { key: TabKey; label: string }[] = [
|
||||||
|
{ key: "sizing", label: "尺寸" },
|
||||||
|
{ key: "front", label: "正面图层" },
|
||||||
|
{ key: "back", label: "背面图层" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const selectTab = (key: TabKey) => {
|
||||||
|
setTab(key);
|
||||||
|
// 切换正/背面标签时,同步预览的显示面
|
||||||
|
if (key === "front") store.actions.setActiveSide("front");
|
||||||
|
if (key === "back") store.actions.setActiveSide("back");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="w-64 shrink-0">
|
||||||
|
{/* 标签页切换 */}
|
||||||
|
<div class="flex gap-1 mb-3">
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
onClick={() => selectTab(t.key)}
|
||||||
|
class={`flex-1 px-3 py-1.5 rounded text-sm font-medium cursor-pointer ${
|
||||||
|
tab() === t.key
|
||||||
|
? "bg-blue-600 text-white"
|
||||||
|
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab() === "sizing" ? (
|
||||||
|
<PropertiesEditorPanel store={store} />
|
||||||
|
) : (
|
||||||
|
<LayerEditorPanel
|
||||||
|
store={store}
|
||||||
|
side={tab() === "front" ? "front" : "back"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -115,7 +115,7 @@ export function PrintPreview(props: PrintPreviewProps) {
|
|||||||
<PltPreview pltCode={pltCode()} onClose={handleClosePltPreview} />
|
<PltPreview pltCode={pltCode()} onClose={handleClosePltPreview} />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="fixed inset-0 bg-black/50 z-[60] overflow-auto">
|
<div class="fixed inset-0 bg-black/50 z-60 overflow-auto">
|
||||||
<div class="min-h-screen py-20 px-4">
|
<div class="min-h-screen py-20 px-4">
|
||||||
<PrintPreviewHeader
|
<PrintPreviewHeader
|
||||||
store={store}
|
store={store}
|
||||||
|
|||||||
@@ -179,6 +179,20 @@ export function PrintPreviewHeader(props: PrintPreviewHeaderProps) {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={store.state.pltDoubleCut}
|
||||||
|
onChange={(e) =>
|
||||||
|
store.actions.setPltDoubleCut(e.target.checked)
|
||||||
|
}
|
||||||
|
class="cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-600">PLT 双切</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<label class="text-sm text-gray-600">正面奇数页偏移:</label>
|
<label class="text-sm text-gray-600">正面奇数页偏移:</label>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { layersToConfigs, normalizeDeckConfig } from "./config";
|
||||||
|
|
||||||
|
describe("layersToConfigs", () => {
|
||||||
|
test("parses compact string format (legacy)", () => {
|
||||||
|
const layers = layersToConfigs("title:1,1-5,1f8 body:1,5-8,8f3");
|
||||||
|
expect(layers).toHaveLength(2);
|
||||||
|
expect(layers[0]).toMatchObject({
|
||||||
|
prop: "title",
|
||||||
|
visible: true,
|
||||||
|
x1: 1,
|
||||||
|
y1: 1,
|
||||||
|
x2: 5,
|
||||||
|
y2: 1,
|
||||||
|
fontSize: 8,
|
||||||
|
});
|
||||||
|
expect(layers[1].prop).toBe("body");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses structured list with prop layers", () => {
|
||||||
|
const layers = layersToConfigs([
|
||||||
|
{ prop: "name", pos: "1,1-5,2", font: 12 },
|
||||||
|
]);
|
||||||
|
expect(layers).toHaveLength(1);
|
||||||
|
expect(layers[0]).toMatchObject({
|
||||||
|
prop: "name",
|
||||||
|
template: undefined,
|
||||||
|
visible: true,
|
||||||
|
x1: 1,
|
||||||
|
y1: 1,
|
||||||
|
x2: 5,
|
||||||
|
y2: 2,
|
||||||
|
fontSize: 12,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses structured list with template layers", () => {
|
||||||
|
const layers = layersToConfigs([
|
||||||
|
{ template: "**{{name}}**", pos: "1,3-5,8", align: "l" },
|
||||||
|
]);
|
||||||
|
expect(layers).toHaveLength(1);
|
||||||
|
expect(layers[0]).toMatchObject({
|
||||||
|
prop: undefined,
|
||||||
|
template: "**{{name}}**",
|
||||||
|
visible: true,
|
||||||
|
x1: 1,
|
||||||
|
y1: 3,
|
||||||
|
x2: 5,
|
||||||
|
y2: 8,
|
||||||
|
align: "l",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses combined vertical+horizontal align", () => {
|
||||||
|
const layers = layersToConfigs([
|
||||||
|
{ prop: "a", pos: "1,1-2,2", align: "tl" },
|
||||||
|
{ prop: "b", pos: "1,1-2,2", align: "br" },
|
||||||
|
{ prop: "c", pos: "1,1-2,2", align: "tc" },
|
||||||
|
{ prop: "d", pos: "1,1-2,2", align: "bc" },
|
||||||
|
]);
|
||||||
|
expect(layers[0].align).toBe("tl");
|
||||||
|
expect(layers[1].align).toBe("br");
|
||||||
|
expect(layers[2].align).toBe("tc");
|
||||||
|
expect(layers[3].align).toBe("bc");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses vertical+horizontal align in compact layers string", () => {
|
||||||
|
const layers = layersToConfigs("title:1,1-4,1tl body:1,2-5,8bc");
|
||||||
|
expect(layers[0].align).toBe("tl");
|
||||||
|
expect(layers[1].align).toBe("bc");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("defaults placement when pos is missing or invalid", () => {
|
||||||
|
const layers = layersToConfigs([{ prop: "x" }, { prop: "y", pos: "bogus" }]);
|
||||||
|
expect(layers[0]).toMatchObject({ x1: 1, y1: 1, x2: 2, y2: 2 });
|
||||||
|
expect(layers[1]).toMatchObject({ x1: 1, y1: 1, x2: 2, y2: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns [] for absent/empty input", () => {
|
||||||
|
expect(layersToConfigs()).toEqual([]);
|
||||||
|
expect(layersToConfigs("")).toEqual([]);
|
||||||
|
expect(layersToConfigs([])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeDeckConfig", () => {
|
||||||
|
test("parses string dimensions", () => {
|
||||||
|
const cfg = normalizeDeckConfig({ size: "54x86", grid: "5x8" });
|
||||||
|
expect(cfg.sizeW).toBe(54);
|
||||||
|
expect(cfg.sizeH).toBe(86);
|
||||||
|
expect(cfg.gridW).toBe(5);
|
||||||
|
expect(cfg.gridH).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses array dimensions", () => {
|
||||||
|
const cfg = normalizeDeckConfig({ size: [63, 88] });
|
||||||
|
expect(cfg.sizeW).toBe(63);
|
||||||
|
expect(cfg.sizeH).toBe(88);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("coerces numeric strings for bleed/padding", () => {
|
||||||
|
const cfg = normalizeDeckConfig({ bleed: "2", padding: "3" });
|
||||||
|
expect(cfg.bleed).toBe(2);
|
||||||
|
expect(cfg.padding).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes layers and back_layers", () => {
|
||||||
|
const cfg = normalizeDeckConfig({
|
||||||
|
layers: [
|
||||||
|
{ prop: "name", pos: "1,1-5,1" },
|
||||||
|
{ template: "{{body}}", pos: "1,2-5,8" },
|
||||||
|
],
|
||||||
|
back_layers: "logo:1,1-2,2",
|
||||||
|
});
|
||||||
|
expect(cfg.frontLayers).toHaveLength(2);
|
||||||
|
expect(cfg.frontLayers[1].template).toBe("{{body}}");
|
||||||
|
expect(cfg.backLayers).toHaveLength(1);
|
||||||
|
expect(cfg.backLayers[0].prop).toBe("logo");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing fields stay undefined", () => {
|
||||||
|
const cfg = normalizeDeckConfig({});
|
||||||
|
expect(cfg.sizeW).toBeUndefined();
|
||||||
|
expect(cfg.sizeH).toBeUndefined();
|
||||||
|
expect(cfg.gridW).toBeUndefined();
|
||||||
|
expect(cfg.gridH).toBeUndefined();
|
||||||
|
expect(cfg.bleed).toBeUndefined();
|
||||||
|
expect(cfg.padding).toBeUndefined();
|
||||||
|
expect(cfg.frontLayers).toEqual([]);
|
||||||
|
expect(cfg.backLayers).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import type { Align, CardShape, LayerConfig } from "./types";
|
||||||
|
import { parseLayers } from "./hooks/layer-parser";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YAML/JSON shape of an `md-deck` `data-config`.
|
||||||
|
*
|
||||||
|
* Mirrors the frontmatter `deck:` block and the `:md-deck` directive attrs,
|
||||||
|
* but allows `layers`/`back_layers` as structured lists where each layer is
|
||||||
|
* either a CSV `prop` or a markdown `template`.
|
||||||
|
*/
|
||||||
|
export interface DeckConfigYaml {
|
||||||
|
size?: string | [number, number];
|
||||||
|
grid?: string | [number, number];
|
||||||
|
bleed?: number | string;
|
||||||
|
padding?: number | string;
|
||||||
|
shape?: CardShape;
|
||||||
|
fixed?: boolean;
|
||||||
|
layers?: string | DeckLayerYaml[];
|
||||||
|
back_layers?: string | DeckLayerYaml[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeckLayerYaml {
|
||||||
|
prop?: string;
|
||||||
|
template?: string;
|
||||||
|
/** Grid placement "x1,y1-x2,y2" (1-based), same shape as the compact layers string. */
|
||||||
|
pos?: string;
|
||||||
|
font?: number;
|
||||||
|
fontSize?: number;
|
||||||
|
orientation?: "n" | "s" | "e" | "w";
|
||||||
|
align?: Align;
|
||||||
|
visible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedDeckConfig {
|
||||||
|
sizeW?: number;
|
||||||
|
sizeH?: number;
|
||||||
|
gridW?: number;
|
||||||
|
gridH?: number;
|
||||||
|
bleed?: number;
|
||||||
|
padding?: number;
|
||||||
|
shape?: CardShape;
|
||||||
|
fixed?: boolean;
|
||||||
|
frontLayers: LayerConfig[];
|
||||||
|
backLayers: LayerConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a "54x86" string or [54, 86] array into [w, h]. */
|
||||||
|
function parseDimension(
|
||||||
|
v?: string | [number, number],
|
||||||
|
): [number, number] | undefined {
|
||||||
|
if (!v) return undefined;
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
const [w, h] = v;
|
||||||
|
if (typeof w === "number" && typeof h === "number") return [w, h];
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const parts = String(v)
|
||||||
|
.toLowerCase()
|
||||||
|
.split("x")
|
||||||
|
.map((n) => Number(n.trim()));
|
||||||
|
if (
|
||||||
|
parts.length === 2 &&
|
||||||
|
Number.isFinite(parts[0]) &&
|
||||||
|
Number.isFinite(parts[1])
|
||||||
|
) {
|
||||||
|
return [parts[0], parts[1]];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a "x1,y1-x2,y2" placement string into grid coordinates. */
|
||||||
|
export function parsePos(
|
||||||
|
pos?: string,
|
||||||
|
): { x1: number; y1: number; x2: number; y2: number } | undefined {
|
||||||
|
if (!pos) return undefined;
|
||||||
|
const m = /^(\d+)\s*,\s*(\d+)\s*-\s*(\d+)\s*,\s*(\d+)$/.exec(pos.trim());
|
||||||
|
if (!m) return undefined;
|
||||||
|
return {
|
||||||
|
x1: Number(m[1]),
|
||||||
|
y1: Number(m[2]),
|
||||||
|
x2: Number(m[3]),
|
||||||
|
y2: Number(m[4]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a structured YAML layer to a LayerConfig. */
|
||||||
|
function layerYamlToConfig(l: DeckLayerYaml): LayerConfig {
|
||||||
|
const pos = parsePos(l.pos);
|
||||||
|
return {
|
||||||
|
prop: l.prop,
|
||||||
|
template: l.template,
|
||||||
|
visible: l.visible ?? true,
|
||||||
|
x1: pos?.x1 ?? 1,
|
||||||
|
y1: pos?.y1 ?? 1,
|
||||||
|
x2: pos?.x2 ?? 2,
|
||||||
|
y2: pos?.y2 ?? 2,
|
||||||
|
orientation: l.orientation,
|
||||||
|
fontSize: l.fontSize ?? l.font,
|
||||||
|
align: l.align,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a `layers`/`back_layers` value (compact string or structured list)
|
||||||
|
* into LayerConfig[]. Empty/absent → [].
|
||||||
|
*/
|
||||||
|
export function layersToConfigs(
|
||||||
|
layers?: string | DeckLayerYaml[],
|
||||||
|
): LayerConfig[] {
|
||||||
|
if (!layers) return [];
|
||||||
|
if (typeof layers === "string") {
|
||||||
|
return parseLayers(layers).map((l) => ({
|
||||||
|
prop: l.prop,
|
||||||
|
visible: true,
|
||||||
|
x1: l.x1,
|
||||||
|
y1: l.y1,
|
||||||
|
x2: l.x2,
|
||||||
|
y2: l.y2,
|
||||||
|
orientation: l.orientation,
|
||||||
|
fontSize: l.fontSize,
|
||||||
|
align: l.align,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return layers.map(layerYamlToConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a parsed `data-config` object into concrete numeric config +
|
||||||
|
* ready-to-use layer lists. Missing fields are left undefined so callers can
|
||||||
|
* apply defaults.
|
||||||
|
*/
|
||||||
|
export function normalizeDeckConfig(cfg: DeckConfigYaml): NormalizedDeckConfig {
|
||||||
|
const size = parseDimension(cfg.size);
|
||||||
|
const grid = parseDimension(cfg.grid);
|
||||||
|
return {
|
||||||
|
sizeW: size?.[0],
|
||||||
|
sizeH: size?.[1],
|
||||||
|
gridW: grid?.[0],
|
||||||
|
gridH: grid?.[1],
|
||||||
|
bleed: typeof cfg.bleed === "string" ? Number(cfg.bleed) : cfg.bleed,
|
||||||
|
padding:
|
||||||
|
typeof cfg.padding === "string" ? Number(cfg.padding) : cfg.padding,
|
||||||
|
shape: cfg.shape,
|
||||||
|
fixed: cfg.fixed,
|
||||||
|
frontLayers: layersToConfigs(cfg.layers),
|
||||||
|
backLayers: layersToConfigs(cfg.back_layers),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,11 +6,13 @@ import {
|
|||||||
closestCenter,
|
closestCenter,
|
||||||
} from "@thisbeyond/solid-dnd";
|
} from "@thisbeyond/solid-dnd";
|
||||||
import type { DeckStore } from "../hooks/deckStore";
|
import type { DeckStore } from "../hooks/deckStore";
|
||||||
|
import type { CardSide } from "../types";
|
||||||
import { toSortableId } from "../hooks/layer-crud";
|
import { toSortableId } from "../hooks/layer-crud";
|
||||||
import { LayerRow } from "./LayerRow";
|
import { LayerRow } from "./LayerRow";
|
||||||
|
|
||||||
export interface LayerEditorPanelProps {
|
export interface LayerEditorPanelProps {
|
||||||
store: DeckStore;
|
store: DeckStore;
|
||||||
|
side?: CardSide;
|
||||||
}
|
}
|
||||||
|
|
||||||
function LayerEditorPanel(props: LayerEditorPanelProps) {
|
function LayerEditorPanel(props: LayerEditorPanelProps) {
|
||||||
@@ -20,7 +22,7 @@ function LayerEditorPanel(props: LayerEditorPanelProps) {
|
|||||||
const [addMenuOpen, setAddMenuOpen] = createSignal(false);
|
const [addMenuOpen, setAddMenuOpen] = createSignal(false);
|
||||||
let addMenuRef: HTMLDivElement | undefined;
|
let addMenuRef: HTMLDivElement | undefined;
|
||||||
|
|
||||||
const side = () => store.state.activeSide;
|
const side = () => props.side || "front";
|
||||||
const layers = () =>
|
const layers = () =>
|
||||||
side() === "front"
|
side() === "front"
|
||||||
? store.state.frontLayerConfigs
|
? store.state.frontLayerConfigs
|
||||||
@@ -143,15 +145,6 @@ function LayerEditorPanel(props: LayerEditorPanelProps) {
|
|||||||
</SortableProvider>
|
</SortableProvider>
|
||||||
</DragDropSensors>
|
</DragDropSensors>
|
||||||
</DragDropProvider>
|
</DragDropProvider>
|
||||||
|
|
||||||
<hr class="my-4" />
|
|
||||||
<button
|
|
||||||
onClick={() => store.actions.copyCode()}
|
|
||||||
class="w-full bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded text-sm font-medium cursor-pointer flex items-center gap-2 justify-center"
|
|
||||||
>
|
|
||||||
<span>📋</span>
|
|
||||||
<span>复制代码</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { For, createSignal, onCleanup, onMount } from "solid-js";
|
import { For, createSignal, onCleanup, onMount } from "solid-js";
|
||||||
import { createSortable, useDragDropContext } from "@thisbeyond/solid-dnd";
|
import { createSortable, useDragDropContext } from "@thisbeyond/solid-dnd";
|
||||||
import type { DeckStore } from "../hooks/deckStore";
|
import type { DeckStore } from "../hooks/deckStore";
|
||||||
import type { LayerConfig } from "../types";
|
import type { Align, LayerConfig } from "../types";
|
||||||
import alignLeftIcon from "./icons/align-left.png";
|
import alignLeftIcon from "./icons/align-left.png";
|
||||||
import alignCenterIcon from "./icons/align-center.png";
|
import alignCenterIcon from "./icons/align-center.png";
|
||||||
import alignRightIcon from "./icons/align-right.png";
|
import alignRightIcon from "./icons/align-right.png";
|
||||||
@@ -17,7 +17,7 @@ export interface LayerRowProps {
|
|||||||
setOpenDropdown: (val: string | null) => void;
|
setOpenDropdown: (val: string | null) => void;
|
||||||
onUpdateOrientation: (o: "n" | "s" | "e" | "w") => void;
|
onUpdateOrientation: (o: "n" | "s" | "e" | "w") => void;
|
||||||
onUpdateFontSize: (fs?: number) => void;
|
onUpdateFontSize: (fs?: number) => void;
|
||||||
onUpdateAlign: (a?: "l" | "c" | "r") => void;
|
onUpdateAlign: (a?: Align) => void;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
}
|
}
|
||||||
@@ -29,11 +29,17 @@ const ORIENTATIONS = [
|
|||||||
{ value: "w" as const, label: "← 西" },
|
{ value: "w" as const, label: "← 西" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ALIGNS = [
|
const ALIGNS: { value: Align | ""; icon: string }[] = [
|
||||||
{ value: "" as const, icon: alignCenterIcon },
|
{ value: "", icon: alignCenterIcon },
|
||||||
{ value: "l" as const, icon: alignLeftIcon },
|
{ value: "l", icon: alignLeftIcon },
|
||||||
{ value: "c" as const, icon: alignCenterIcon },
|
{ value: "c", icon: alignCenterIcon },
|
||||||
{ value: "r" as const, icon: alignRightIcon },
|
{ value: "r", icon: alignRightIcon },
|
||||||
|
{ value: "tl", icon: "↖" },
|
||||||
|
{ value: "tc", icon: "↑" },
|
||||||
|
{ value: "tr", icon: "↗" },
|
||||||
|
{ value: "bl", icon: "↙" },
|
||||||
|
{ value: "bc", icon: "↓" },
|
||||||
|
{ value: "br", icon: "↘" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const FONT_PRESETS = [3, 5, 8, 12] as const;
|
const FONT_PRESETS = [3, 5, 8, 12] as const;
|
||||||
@@ -53,14 +59,28 @@ function orientChar(v: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function alignSrc(v: string) {
|
function alignIcon(v: string): import("solid-js").JSX.Element {
|
||||||
switch (v) {
|
switch (v) {
|
||||||
|
case "tl":
|
||||||
|
return "↖";
|
||||||
|
case "tc":
|
||||||
|
return "↑";
|
||||||
|
case "tr":
|
||||||
|
return "↗";
|
||||||
|
case "bl":
|
||||||
|
return "↙";
|
||||||
|
case "bc":
|
||||||
|
return "↓";
|
||||||
|
case "br":
|
||||||
|
return "↘";
|
||||||
case "l":
|
case "l":
|
||||||
return alignLeftIcon;
|
return <img src={alignLeftIcon} alt="align" class="w-5 h-5 not-prose" />;
|
||||||
case "r":
|
case "r":
|
||||||
return alignRightIcon;
|
return <img src={alignRightIcon} alt="align" class="w-5 h-5 not-prose" />;
|
||||||
default:
|
default:
|
||||||
return alignCenterIcon;
|
return (
|
||||||
|
<img src={alignCenterIcon} alt="align" class="w-5 h-5 not-prose" />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +140,7 @@ export function LayerRow(props: LayerRowProps) {
|
|||||||
class="text-sm flex-1 truncate cursor-pointer hover:text-blue-600 select-none"
|
class="text-sm flex-1 truncate cursor-pointer hover:text-blue-600 select-none"
|
||||||
onClick={props.onSelect}
|
onClick={props.onSelect}
|
||||||
>
|
>
|
||||||
{props.layer.prop}
|
{props.layer.prop || (props.layer.template ? "(模板)" : "")}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<DropdownButton
|
<DropdownButton
|
||||||
@@ -149,13 +169,7 @@ export function LayerRow(props: LayerRowProps) {
|
|||||||
</DropdownButton>
|
</DropdownButton>
|
||||||
|
|
||||||
<DropdownButton
|
<DropdownButton
|
||||||
icon={
|
icon={alignIcon(props.layer.align || "")}
|
||||||
<img
|
|
||||||
src={alignSrc(props.layer.align || "")}
|
|
||||||
alt="align"
|
|
||||||
class="w-5 h-5 not-prose"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
visible={props.layer.visible}
|
visible={props.layer.visible}
|
||||||
open={props.openDropdown === `align-${props.index}`}
|
open={props.openDropdown === `align-${props.index}`}
|
||||||
onToggle={() =>
|
onToggle={() =>
|
||||||
@@ -173,7 +187,13 @@ export function LayerRow(props: LayerRowProps) {
|
|||||||
onClick={() => props.onUpdateAlign(o.value || undefined)}
|
onClick={() => props.onUpdateAlign(o.value || undefined)}
|
||||||
class="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-gray-100 cursor-pointer whitespace-nowrap"
|
class="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-gray-100 cursor-pointer whitespace-nowrap"
|
||||||
>
|
>
|
||||||
|
{o.icon.endsWith(".png") ? (
|
||||||
<img src={o.icon} alt="" class="w-4 h-4 not-prose max-w-none" />
|
<img src={o.icon} alt="" class="w-4 h-4 not-prose max-w-none" />
|
||||||
|
) : (
|
||||||
|
<span class="w-4 h-4 flex items-center justify-center not-prose">
|
||||||
|
{o.icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|||||||
@@ -1,49 +1,32 @@
|
|||||||
import type { DeckStore } from '../hooks/deckStore';
|
import type { DeckStore } from "../hooks/deckStore";
|
||||||
import type { CardShape } from '../types';
|
import type { CardShape } from "../types";
|
||||||
|
|
||||||
export interface PropertiesEditorPanelProps {
|
export interface PropertiesEditorPanelProps {
|
||||||
store: DeckStore;
|
store: DeckStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 卡牌属性编辑面板:尺寸、网格、出血、内边距、正背面切换
|
* 卡牌尺寸编辑面板:尺寸、网格、出血、内边距、形状
|
||||||
*/
|
*/
|
||||||
export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) {
|
export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) {
|
||||||
const { store } = props;
|
const { store } = props;
|
||||||
|
|
||||||
return (
|
const shapeOptions: { value: CardShape; label: string }[] = [
|
||||||
<div class="w-64 flex-shrink-0">
|
{ value: "rectangle", label: "矩形" },
|
||||||
<h3 class="font-bold mb-2 mt-0">卡牌属性</h3>
|
{ value: "circle", label: "圆形" },
|
||||||
|
{ value: "triangle", label: "三角形" },
|
||||||
|
{ value: "hexagon", label: "六边形" },
|
||||||
|
];
|
||||||
|
|
||||||
{/* 正面/背面切换标签页 */}
|
return (
|
||||||
<div class="mb-4">
|
<div class="w-64 shrink-0">
|
||||||
<div class="flex gap-1">
|
<h3 class="font-bold mb-2 mt-0">尺寸</h3>
|
||||||
<button
|
|
||||||
onClick={() => store.actions.setActiveSide('front')}
|
|
||||||
class={`flex-1 px-3 py-1.5 rounded text-sm font-medium cursor-pointer border ${
|
|
||||||
store.state.activeSide === 'front'
|
|
||||||
? 'bg-blue-600 text-white border-blue-600'
|
|
||||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
正面
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => store.actions.setActiveSide('back')}
|
|
||||||
class={`flex-1 px-3 py-1.5 rounded text-sm font-medium cursor-pointer border ${
|
|
||||||
store.state.activeSide === 'back'
|
|
||||||
? 'bg-blue-600 text-white border-blue-600'
|
|
||||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
背面
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700">尺寸 (mm)</label>
|
<label class="block text-sm font-medium text-gray-700">
|
||||||
|
尺寸 (mm)
|
||||||
|
</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -83,7 +66,9 @@ export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700">出血 / 内边距 (mm)</label>
|
<label class="block text-sm font-medium text-gray-700">
|
||||||
|
出血 / 内边距 (mm)
|
||||||
|
</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -103,24 +88,20 @@ export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">卡片形状</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
<div class="grid grid-cols-2 gap-2">
|
卡片形状
|
||||||
{(['rectangle', 'circle', 'triangle', 'hexagon'] as CardShape[]).map((shape) => (
|
</label>
|
||||||
<button
|
<select
|
||||||
onClick={() => store.actions.setShape(shape)}
|
value={store.state.shape}
|
||||||
class={`px-3 py-1.5 rounded text-sm font-medium cursor-pointer border ${
|
onChange={(e) =>
|
||||||
store.state.shape === shape
|
store.actions.setShape(e.target.value as CardShape)
|
||||||
? 'bg-blue-600 text-white border-blue-600'
|
}
|
||||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
class="w-full border border-gray-300 rounded px-2 py-1 text-sm bg-white"
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{shape === 'rectangle' && '矩形'}
|
{shapeOptions.map((s) => (
|
||||||
{shape === 'circle' && '圆形'}
|
<option value={s.value}>{s.label}</option>
|
||||||
{shape === 'triangle' && '三角形'}
|
|
||||||
{shape === 'hexagon' && '六边形'}
|
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createStore } from "solid-js/store";
|
import { createStore } from "solid-js/store";
|
||||||
|
import yaml from "js-yaml";
|
||||||
import { calculateDimensions } from "./dimensions";
|
import { calculateDimensions } from "./dimensions";
|
||||||
import { loadCSV, CSV } from "../../utils/csv-loader";
|
import { loadCSVFromPath, CSV } from "../../utils/csv-loader";
|
||||||
import { formatLayers, initLayerConfigsForSide } from "./layer-parser";
|
import { formatLayers } from "./layer-parser";
|
||||||
import * as layerCrud from "./layer-crud";
|
import * as layerCrud from "./layer-crud";
|
||||||
import type {
|
import type {
|
||||||
CardData,
|
CardData,
|
||||||
@@ -41,6 +42,8 @@ export interface DeckState {
|
|||||||
cornerRadius: number;
|
cornerRadius: number;
|
||||||
shape: CardShape;
|
shape: CardShape;
|
||||||
fixed: boolean;
|
fixed: boolean;
|
||||||
|
/** True when the deck was configured via a yaml role=tag codeblock (data-config). */
|
||||||
|
isYamlBlock: boolean;
|
||||||
src: string;
|
src: string;
|
||||||
rawSrc: string;
|
rawSrc: string;
|
||||||
|
|
||||||
@@ -73,6 +76,7 @@ export interface DeckState {
|
|||||||
printFrontOddPageOffsetX: number;
|
printFrontOddPageOffsetX: number;
|
||||||
printFrontOddPageOffsetY: number;
|
printFrontOddPageOffsetY: number;
|
||||||
printDoubleSided: boolean;
|
printDoubleSided: boolean;
|
||||||
|
pltDoubleCut: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeckActions {
|
export interface DeckActions {
|
||||||
@@ -84,6 +88,7 @@ export interface DeckActions {
|
|||||||
setPadding: (padding: number) => void;
|
setPadding: (padding: number) => void;
|
||||||
setCornerRadius: (cornerRadius: number) => void;
|
setCornerRadius: (cornerRadius: number) => void;
|
||||||
setShape: (shape: CardShape) => void;
|
setShape: (shape: CardShape) => void;
|
||||||
|
setIsYamlBlock: (isYamlBlock: boolean) => void;
|
||||||
|
|
||||||
setCards: (cards: CSV<CardData>) => void;
|
setCards: (cards: CSV<CardData>) => void;
|
||||||
setActiveTab: (index: number) => void;
|
setActiveTab: (index: number) => void;
|
||||||
@@ -137,14 +142,14 @@ export interface DeckActions {
|
|||||||
loadCardsFromPath: (
|
loadCardsFromPath: (
|
||||||
path: string,
|
path: string,
|
||||||
rawSrc: string,
|
rawSrc: string,
|
||||||
layersStr?: string,
|
frontLayers?: LayerConfig[],
|
||||||
backLayersStr?: string,
|
backLayers?: LayerConfig[],
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
setError: (error: string | null) => void;
|
setError: (error: string | null) => void;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
|
|
||||||
generateCode: (backLayersStr?: string) => string;
|
generateCode: (backLayersStr?: string) => string;
|
||||||
copyCode: (backLayersStr?: string) => Promise<void>;
|
copyCode: (fallback?: (code: string) => void) => Promise<void>;
|
||||||
|
|
||||||
setExporting: (exporting: boolean) => void;
|
setExporting: (exporting: boolean) => void;
|
||||||
exportDeck: () => void;
|
exportDeck: () => void;
|
||||||
@@ -156,6 +161,7 @@ export interface DeckActions {
|
|||||||
setPrintFrontOddPageOffsetX: (offset: number) => void;
|
setPrintFrontOddPageOffsetX: (offset: number) => void;
|
||||||
setPrintFrontOddPageOffsetY: (offset: number) => void;
|
setPrintFrontOddPageOffsetY: (offset: number) => void;
|
||||||
setPrintDoubleSided: (doubleSided: boolean) => void;
|
setPrintDoubleSided: (doubleSided: boolean) => void;
|
||||||
|
setPltDoubleCut: (doubleCut: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeckStore {
|
export interface DeckStore {
|
||||||
@@ -174,6 +180,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
|||||||
cornerRadius: DECK_DEFAULTS.CORNER_RADIUS,
|
cornerRadius: DECK_DEFAULTS.CORNER_RADIUS,
|
||||||
shape: "rectangle",
|
shape: "rectangle",
|
||||||
fixed: false,
|
fixed: false,
|
||||||
|
isYamlBlock: false,
|
||||||
src: initialSrc,
|
src: initialSrc,
|
||||||
rawSrc: initialSrc,
|
rawSrc: initialSrc,
|
||||||
dimensions: null,
|
dimensions: null,
|
||||||
@@ -197,6 +204,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
|||||||
printFrontOddPageOffsetX: 0,
|
printFrontOddPageOffsetX: 0,
|
||||||
printFrontOddPageOffsetY: 0,
|
printFrontOddPageOffsetY: 0,
|
||||||
printDoubleSided: false,
|
printDoubleSided: false,
|
||||||
|
pltDoubleCut: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateDimensions = () => {
|
const updateDimensions = () => {
|
||||||
@@ -241,6 +249,9 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
|||||||
const setShape = (shape: CardShape) => {
|
const setShape = (shape: CardShape) => {
|
||||||
setState({ shape });
|
setState({ shape });
|
||||||
};
|
};
|
||||||
|
const setIsYamlBlock = (isYamlBlock: boolean) => {
|
||||||
|
setState({ isYamlBlock });
|
||||||
|
};
|
||||||
|
|
||||||
const setCards = (cards: CSV<CardData>) => setState({ cards, activeTab: 0 });
|
const setCards = (cards: CSV<CardData>) => setState({ cards, activeTab: 0 });
|
||||||
const setActiveTab = (index: number) => setState({ activeTab: index });
|
const setActiveTab = (index: number) => setState({ activeTab: index });
|
||||||
@@ -439,8 +450,8 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
|||||||
const loadCardsFromPath = async (
|
const loadCardsFromPath = async (
|
||||||
path: string,
|
path: string,
|
||||||
rawSrc: string,
|
rawSrc: string,
|
||||||
layersStr: string = "",
|
frontLayers: LayerConfig[] = [],
|
||||||
backLayersStr: string = "",
|
backLayers: LayerConfig[] = [],
|
||||||
) => {
|
) => {
|
||||||
if (!path) {
|
if (!path) {
|
||||||
setState({ error: "未指定 CSV 文件路径" });
|
setState({ error: "未指定 CSV 文件路径" });
|
||||||
@@ -450,7 +461,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
|||||||
setState({ isLoading: true, error: null, src: path, rawSrc: rawSrc });
|
setState({ isLoading: true, error: null, src: path, rawSrc: rawSrc });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await loadCSV(path);
|
const data = await loadCSVFromPath(path);
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
setState({
|
setState({
|
||||||
@@ -463,12 +474,8 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
|||||||
setState({
|
setState({
|
||||||
cards: data,
|
cards: data,
|
||||||
activeTab: 0,
|
activeTab: 0,
|
||||||
frontLayerConfigs: layerCrud.withKeys(
|
frontLayerConfigs: layerCrud.withKeys(frontLayers),
|
||||||
initLayerConfigsForSide(data, layersStr),
|
backLayerConfigs: layerCrud.withKeys(backLayers),
|
||||||
),
|
|
||||||
backLayerConfigs: layerCrud.withKeys(
|
|
||||||
initLayerConfigsForSide(data, backLayersStr),
|
|
||||||
),
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
updateDimensions();
|
updateDimensions();
|
||||||
@@ -484,6 +491,9 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
|||||||
const clearError = () => setState({ error: null });
|
const clearError = () => setState({ error: null });
|
||||||
|
|
||||||
const generateCode = (backLayersStr?: string) => {
|
const generateCode = (backLayersStr?: string) => {
|
||||||
|
if (state.isYamlBlock) {
|
||||||
|
return generateYamlCode();
|
||||||
|
}
|
||||||
const frontLayersStr = formatLayers(state.frontLayerConfigs);
|
const frontLayersStr = formatLayers(state.frontLayerConfigs);
|
||||||
const backLayersString =
|
const backLayersString =
|
||||||
backLayersStr || formatLayers(state.backLayerConfigs);
|
backLayersStr || formatLayers(state.backLayerConfigs);
|
||||||
@@ -511,15 +521,63 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
|||||||
return parts.join("");
|
return parts.join("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyCode = async (backLayersStr?: string) => {
|
/** Serialize the deck back to a yaml codeblock (round-trips templates). */
|
||||||
const code = generateCode(backLayersStr);
|
const generateYamlCode = () => {
|
||||||
|
const toYamlLayer = (l: LayerConfig) => {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
if (l.template) {
|
||||||
|
out.template = l.template;
|
||||||
|
} else {
|
||||||
|
out.prop = l.prop ?? "";
|
||||||
|
}
|
||||||
|
out.pos = `${l.x1},${l.y1}-${l.x2},${l.y2}`;
|
||||||
|
if (l.fontSize) out.font = l.fontSize;
|
||||||
|
if (l.orientation && l.orientation !== "n") out.orientation = l.orientation;
|
||||||
|
if (l.align) out.align = l.align;
|
||||||
|
if (!l.visible) out.visible = false;
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const config: Record<string, unknown> = {
|
||||||
|
size: `${state.sizeW}x${state.sizeH}`,
|
||||||
|
grid: `${state.gridW}x${state.gridH}`,
|
||||||
|
layers: state.frontLayerConfigs.map(toYamlLayer),
|
||||||
|
};
|
||||||
|
if (state.bleed !== DECK_DEFAULTS.BLEED) config.bleed = state.bleed;
|
||||||
|
if (state.padding !== DECK_DEFAULTS.PADDING) config.padding = state.padding;
|
||||||
|
if (state.shape !== "rectangle") config.shape = state.shape;
|
||||||
|
if (state.backLayerConfigs.length > 0) {
|
||||||
|
config.back_layers = state.backLayerConfigs.map(toYamlLayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = {
|
||||||
|
tag: "md-deck",
|
||||||
|
body: state.rawSrc || state.src,
|
||||||
|
"data-config": config,
|
||||||
|
};
|
||||||
|
const yamlStr = yaml.dump(doc, {
|
||||||
|
indent: 2,
|
||||||
|
lineWidth: -1,
|
||||||
|
noRefs: true,
|
||||||
|
});
|
||||||
|
const fence = "```yaml role=tag";
|
||||||
|
const backtick = "`";
|
||||||
|
return `${fence}\n${yamlStr}${backtick.repeat(3)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyCode = async (fallback?: (code: string) => void) => {
|
||||||
|
const code = generateCode();
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(code);
|
await navigator.clipboard.writeText(code);
|
||||||
alert("已复制到剪贴板!");
|
alert("已复制到剪贴板!");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("复制失败:", err);
|
console.error("复制失败:", err);
|
||||||
|
if (fallback) {
|
||||||
|
fallback(code);
|
||||||
|
} else {
|
||||||
alert("复制失败,请手动复制");
|
alert("复制失败,请手动复制");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const setExporting = (exporting: boolean) =>
|
const setExporting = (exporting: boolean) =>
|
||||||
@@ -553,6 +611,10 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
|||||||
setState({ printDoubleSided: doubleSided });
|
setState({ printDoubleSided: doubleSided });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setPltDoubleCut = (doubleCut: boolean) => {
|
||||||
|
setState({ pltDoubleCut: doubleCut });
|
||||||
|
};
|
||||||
|
|
||||||
const actions: DeckActions = {
|
const actions: DeckActions = {
|
||||||
setSizeW,
|
setSizeW,
|
||||||
setSizeH,
|
setSizeH,
|
||||||
@@ -562,6 +624,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
|||||||
setPadding,
|
setPadding,
|
||||||
setCornerRadius,
|
setCornerRadius,
|
||||||
setShape,
|
setShape,
|
||||||
|
setIsYamlBlock,
|
||||||
setCards,
|
setCards,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
updateCardData,
|
updateCardData,
|
||||||
@@ -599,6 +662,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
|||||||
setPrintFrontOddPageOffsetX,
|
setPrintFrontOddPageOffsetX,
|
||||||
setPrintFrontOddPageOffsetY,
|
setPrintFrontOddPageOffsetY,
|
||||||
setPrintDoubleSided,
|
setPrintDoubleSided,
|
||||||
|
setPltDoubleCut,
|
||||||
};
|
};
|
||||||
|
|
||||||
return { state, actions };
|
return { state, actions };
|
||||||
|
|||||||
@@ -4,15 +4,16 @@ import { CSV } from "../../utils/csv-loader";
|
|||||||
/**
|
/**
|
||||||
* 解析 layers 字符串
|
* 解析 layers 字符串
|
||||||
* 格式:body:1,7-5,8 title:1,1-4,1f6.6sl
|
* 格式:body:1,7-5,8 title:1,1-4,1f6.6sl
|
||||||
* f[fontSize] 表示字体大小(可选),方向字母(可选),对齐字母 l/c/r(可选)
|
* f[fontSize] 表示字体大小(可选),方向字母(可选),
|
||||||
|
* 对齐字母(可选):水平 l/c/r,可加垂直前缀 t/b 组成 tl/tr/bl/br
|
||||||
*/
|
*/
|
||||||
export function parseLayers(layersStr: string): Layer[] {
|
export function parseLayers(layersStr: string): Layer[] {
|
||||||
if (!layersStr) return [];
|
if (!layersStr) return [];
|
||||||
|
|
||||||
const layers: Layer[] = [];
|
const layers: Layer[] = [];
|
||||||
// 匹配:prop:x1,y1-x2,y2[ffontSize][direction][align]
|
// 匹配:prop:x1,y1-x2,y2[ffontSize][direction][[t|b]align]
|
||||||
const regex =
|
const regex =
|
||||||
/([^: ]+):(\d+),(\d+)-(\d+),(\d+)(?:f([\d.]+))?([nsew])?([lcr])?/g;
|
/([^: ]+):(\d+),(\d+)-(\d+),(\d+)(?:f([\d.]+))?([nsew])?([tb])?([lcr])?/g;
|
||||||
let match;
|
let match;
|
||||||
|
|
||||||
while ((match = regex.exec(layersStr)) !== null) {
|
while ((match = regex.exec(layersStr)) !== null) {
|
||||||
@@ -24,7 +25,7 @@ export function parseLayers(layersStr: string): Layer[] {
|
|||||||
y2: parseInt(match[5]),
|
y2: parseInt(match[5]),
|
||||||
fontSize: match[6] ? parseFloat(match[6]) : undefined,
|
fontSize: match[6] ? parseFloat(match[6]) : undefined,
|
||||||
orientation: match[7] as "n" | "s" | "e" | "w" | undefined,
|
orientation: match[7] as "n" | "s" | "e" | "w" | undefined,
|
||||||
align: match[8] as "l" | "c" | "r" | undefined,
|
align: [match[8], match[9]].filter(Boolean).join("") as Layer["align"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,8 +36,10 @@ export function parseLayers(layersStr: string): Layer[] {
|
|||||||
* 格式化 layers 为字符串
|
* 格式化 layers 为字符串
|
||||||
*/
|
*/
|
||||||
export function formatLayers(layers: LayerConfig[]): string {
|
export function formatLayers(layers: LayerConfig[]): string {
|
||||||
|
// Template-only layers have no prop and can't be represented in the
|
||||||
|
// compact string format, so they are skipped here.
|
||||||
return layers
|
return layers
|
||||||
.filter((l) => l.visible)
|
.filter((l) => l.visible && l.prop)
|
||||||
.map((l) => {
|
.map((l) => {
|
||||||
let str = `${l.prop}:${l.x1},${l.y1}-${l.x2},${l.y2}`;
|
let str = `${l.prop}:${l.x1},${l.y1}-${l.x2},${l.y2}`;
|
||||||
if (l.fontSize) {
|
if (l.fontSize) {
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ export function useLayerInteraction(
|
|||||||
const handleLayerClick = (index: number, e: MouseEvent) => {
|
const handleLayerClick = (index: number, e: MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!store.state.isEditing || store.state.fixed) return;
|
||||||
|
|
||||||
const currentlySelected = store.state.selectedLayer;
|
const currentlySelected = store.state.selectedLayer;
|
||||||
|
|
||||||
if (currentlySelected === index) {
|
if (currentlySelected === index) {
|
||||||
@@ -100,6 +102,7 @@ export function useLayerInteraction(
|
|||||||
|
|
||||||
const handleCardClick = (e: MouseEvent, cardEl: HTMLElement) => {
|
const handleCardClick = (e: MouseEvent, cardEl: HTMLElement) => {
|
||||||
if (store.state.draggingState) return;
|
if (store.state.draggingState) return;
|
||||||
|
if (!store.state.isEditing || store.state.fixed) return;
|
||||||
|
|
||||||
const { gridX, gridY } = calculateGridCoords(e, cardEl);
|
const { gridX, gridY } = calculateGridCoords(e, cardEl);
|
||||||
const overlapping = getOverlappingLayers(gridX, gridY);
|
const overlapping = getOverlappingLayers(gridX, gridY);
|
||||||
@@ -147,6 +150,7 @@ export function useLayerInteraction(
|
|||||||
edge?: "n" | "s" | "e" | "w",
|
edge?: "n" | "s" | "e" | "w",
|
||||||
e?: MouseEvent,
|
e?: MouseEvent,
|
||||||
) => {
|
) => {
|
||||||
|
if (!store.state.isEditing || store.state.fixed) return;
|
||||||
if (store.state.selectedLayer === null) return;
|
if (store.state.selectedLayer === null) return;
|
||||||
if (e) e.stopPropagation();
|
if (e) e.stopPropagation();
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export function usePlotterExport(store: DeckStore): UsePlotterExportReturn {
|
|||||||
const cardHeight = () => store.state.dimensions?.cardHeight || 88;
|
const cardHeight = () => store.state.dimensions?.cardHeight || 88;
|
||||||
const shape = () => store.state.shape;
|
const shape = () => store.state.shape;
|
||||||
const orientation = () => store.state.printOrientation || 'landscape';
|
const orientation = () => store.state.printOrientation || 'landscape';
|
||||||
|
const doubleCut = () => store.state.pltDoubleCut;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成单页满排时的 PLT 数据
|
* 生成单页满排时的 PLT 数据
|
||||||
@@ -60,7 +61,7 @@ export function usePlotterExport(store: DeckStore): UsePlotterExportReturn {
|
|||||||
|
|
||||||
// 将卡片路径转换为相对于 frameBoundsWithMargin 的坐标
|
// 将卡片路径转换为相对于 frameBoundsWithMargin 的坐标
|
||||||
// 原点在 frameBoundsWithMargin 的左上角
|
// 原点在 frameBoundsWithMargin 的左上角
|
||||||
const relativePaths = layout.cardPaths.map(cardPath => {
|
const relativePaths = layout.cardPaths.flatMap(cardPath => {
|
||||||
const relativePoints = cardPath.points.map(([x, y]) => {
|
const relativePoints = cardPath.points.map(([x, y]) => {
|
||||||
// 转换为相对于 frameBounds 左上角的坐标
|
// 转换为相对于 frameBounds 左上角的坐标
|
||||||
const relativeX = x - frameBounds.x;
|
const relativeX = x - frameBounds.x;
|
||||||
@@ -71,7 +72,11 @@ export function usePlotterExport(store: DeckStore): UsePlotterExportReturn {
|
|||||||
// 所以 plotterY = pltHeight - relativeY
|
// 所以 plotterY = pltHeight - relativeY
|
||||||
return [relativeX, pltHeight - relativeY] as [number, number];
|
return [relativeX, pltHeight - relativeY] as [number, number];
|
||||||
});
|
});
|
||||||
return relativePoints;
|
if (doubleCut()) {
|
||||||
|
// 重复一次:某些切割机需要每张卡牌切两次
|
||||||
|
return [relativePoints, relativePoints];
|
||||||
|
}
|
||||||
|
return [relativePoints];
|
||||||
});
|
});
|
||||||
|
|
||||||
// 起点和终点都在 frameBoundsWithMargin 的左上角 (0, pltHeight)
|
// 起点和终点都在 frameBoundsWithMargin 的左上角 (0, pltHeight)
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
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 { createDeckStore } from "./hooks/deckStore";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { createDeckStore, DECK_DEFAULTS } 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, LayerConfig } from "./types";
|
||||||
|
import { normalizeDeckConfig, layersToConfigs } from "./config";
|
||||||
import { DeckHeader } from "./DeckHeader";
|
import { DeckHeader } from "./DeckHeader";
|
||||||
|
import { CardList } from "./CardList";
|
||||||
import { DeckContent } from "./DeckContent";
|
import { DeckContent } from "./DeckContent";
|
||||||
|
import { EditorTabs } from "./EditorTabs";
|
||||||
import { PrintPreview } from "./PrintPreview";
|
import { PrintPreview } from "./PrintPreview";
|
||||||
import {
|
import { DataEditorPanel } from "./editor-panel";
|
||||||
DataEditorPanel,
|
|
||||||
LayerEditorPanel,
|
|
||||||
PropertiesEditorPanel,
|
|
||||||
} from "./editor-panel";
|
|
||||||
|
|
||||||
interface DeckProps {
|
interface DeckProps {
|
||||||
size?: string;
|
size?: string;
|
||||||
@@ -66,52 +66,80 @@ 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);
|
||||||
|
|
||||||
|
// 读取 data-config(yaml role=tag 代码块方式):结构化配置优先
|
||||||
|
let config:
|
||||||
|
| ReturnType<typeof normalizeDeckConfig>
|
||||||
|
| undefined;
|
||||||
|
const dataConfigAttr = element?.getAttribute("data-config");
|
||||||
|
if (dataConfigAttr) {
|
||||||
|
try {
|
||||||
|
config = normalizeDeckConfig(JSON.parse(dataConfigAttr));
|
||||||
|
// 记录来源,复制代码时输出 yaml role=tag 代码块
|
||||||
|
store.actions.setIsYamlBlock(true);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Invalid data-config on md-deck:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 解析 size 属性(支持旧格式 "54x86" 和新格式)
|
// 解析 size 属性(支持旧格式 "54x86" 和新格式)
|
||||||
if (props.size && props.size.includes("x")) {
|
if (config?.sizeW !== undefined && config.sizeH !== undefined) {
|
||||||
|
store.actions.setSizeW(config.sizeW);
|
||||||
|
store.actions.setSizeH(config.sizeH);
|
||||||
|
} else if (props.size && props.size.includes("x")) {
|
||||||
const [w, h] = props.size.split("x").map(Number);
|
const [w, h] = props.size.split("x").map(Number);
|
||||||
store.actions.setSizeW(w);
|
store.actions.setSizeW(w);
|
||||||
store.actions.setSizeH(h);
|
store.actions.setSizeH(h);
|
||||||
} else {
|
} else {
|
||||||
store.actions.setSizeW(props.sizeW ?? 54);
|
store.actions.setSizeW(props.sizeW ?? DECK_DEFAULTS.SIZE_W);
|
||||||
store.actions.setSizeH(props.sizeH ?? 86);
|
store.actions.setSizeH(props.sizeH ?? DECK_DEFAULTS.SIZE_H);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析 grid 属性(支持旧格式 "5x8" 和新格式)
|
// 解析 grid 属性(支持旧格式 "5x8" 和新格式)
|
||||||
if (props.grid && props.grid.includes("x")) {
|
if (config?.gridW !== undefined && config.gridH !== undefined) {
|
||||||
|
store.actions.setGridW(config.gridW);
|
||||||
|
store.actions.setGridH(config.gridH);
|
||||||
|
} else if (props.grid && props.grid.includes("x")) {
|
||||||
const [w, h] = props.grid.split("x").map(Number);
|
const [w, h] = props.grid.split("x").map(Number);
|
||||||
store.actions.setGridW(w);
|
store.actions.setGridW(w);
|
||||||
store.actions.setGridH(h);
|
store.actions.setGridH(h);
|
||||||
} else {
|
} else {
|
||||||
store.actions.setGridW(props.gridW ?? 5);
|
store.actions.setGridW(props.gridW ?? DECK_DEFAULTS.GRID_W);
|
||||||
store.actions.setGridH(props.gridH ?? 8);
|
store.actions.setGridH(props.gridH ?? DECK_DEFAULTS.GRID_H);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析 bleed 和 padding(支持旧字符串格式和新数字格式)
|
// 解析 bleed 和 padding(支持旧字符串格式和新数字格式)
|
||||||
if (typeof props.bleed === "string") {
|
if (config?.bleed !== undefined) {
|
||||||
|
store.actions.setBleed(config.bleed);
|
||||||
|
} else if (typeof props.bleed === "string") {
|
||||||
store.actions.setBleed(Number(props.bleed));
|
store.actions.setBleed(Number(props.bleed));
|
||||||
} else {
|
} else {
|
||||||
store.actions.setBleed(props.bleed ?? 1);
|
store.actions.setBleed(props.bleed ?? DECK_DEFAULTS.BLEED);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof props.padding === "string") {
|
if (config?.padding !== undefined) {
|
||||||
|
store.actions.setPadding(config.padding);
|
||||||
|
} else if (typeof props.padding === "string") {
|
||||||
store.actions.setPadding(Number(props.padding));
|
store.actions.setPadding(Number(props.padding));
|
||||||
} else {
|
} else {
|
||||||
store.actions.setPadding(props.padding ?? 2);
|
store.actions.setPadding(props.padding ?? DECK_DEFAULTS.PADDING);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置形状
|
// 设置形状
|
||||||
store.actions.setShape(props.shape ?? "rectangle");
|
store.actions.setShape(config?.shape ?? props.shape ?? "rectangle");
|
||||||
|
|
||||||
|
// 确定前后图层(data-config 优先,回退旧 layers 字符串)
|
||||||
|
const frontLayers: LayerConfig[] = config
|
||||||
|
? config.frontLayers
|
||||||
|
: layersToConfigs((props.layers as string) || "");
|
||||||
|
const backLayers: LayerConfig[] = config
|
||||||
|
? config.backLayers
|
||||||
|
: layersToConfigs((props.backLayers as string) || "");
|
||||||
|
|
||||||
// 加载 CSV 数据
|
// 加载 CSV 数据
|
||||||
store.actions.loadCardsFromPath(
|
store.actions.loadCardsFromPath(resolvedSrc, csvPath, frontLayers, backLayers);
|
||||||
resolvedSrc,
|
|
||||||
csvPath,
|
|
||||||
(props.layers as string) || "",
|
|
||||||
(props.backLayers as string) || "",
|
|
||||||
);
|
|
||||||
|
|
||||||
// 清理函数
|
// 清理函数
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
@@ -138,29 +166,19 @@ customElement<DeckProps>(
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
{/* 内容区域:错误/加载/卡牌预览/空状态 */}
|
{/* 左侧:卡牌列表导航 */}
|
||||||
{/* 左侧:CSV 数据编辑 */}
|
<Show when={store.state.cards.length > 0 && !store.state.error}>
|
||||||
{/*<Show when={store.state.isEditing && !store.state.fixed}>*/}
|
<CardList store={store} />
|
||||||
{/* <DataEditorPanel*/}
|
|
||||||
{/* activeTab={store.state.activeTab}*/}
|
|
||||||
{/* cards={store.state.cards}*/}
|
|
||||||
{/* updateCardData={store.actions.updateCardData}*/}
|
|
||||||
{/* />*/}
|
|
||||||
{/*</Show>*/}
|
|
||||||
|
|
||||||
<Show when={store.state.isEditing && !store.state.fixed}>
|
|
||||||
<div class="flex-1">
|
|
||||||
<PropertiesEditorPanel store={store} />
|
|
||||||
</div>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
{/* 中间:内容区域(错误/加载/卡牌预览/空状态) */}
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
<DeckContent store={store} isLoading={store.state.isLoading} />
|
<DeckContent store={store} isLoading={store.state.isLoading} />
|
||||||
|
|
||||||
{/* 右侧:属性/图层编辑面板 */}
|
|
||||||
<Show when={store.state.isEditing && !store.state.fixed}>
|
|
||||||
<div class="flex-1">
|
|
||||||
<LayerEditorPanel store={store} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧:属性/图层编辑面板(标签页切换) */}
|
||||||
|
<Show when={store.state.isEditing && !store.state.fixed}>
|
||||||
|
<EditorTabs store={store} />
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,21 +4,43 @@ export interface CardData {
|
|||||||
|
|
||||||
export type CardSide = "front" | "back";
|
export type CardSide = "front" | "back";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layer alignment: horizontal (l/c/r) optionally combined with vertical
|
||||||
|
* (t/b). "tl" = top-left, "br" = bottom-right, "tc" = top-center,
|
||||||
|
* "bc" = bottom-center, etc. Plain "l"/"c"/"r" means vertically centered.
|
||||||
|
*/
|
||||||
|
export type Align =
|
||||||
|
| "l"
|
||||||
|
| "c"
|
||||||
|
| "r"
|
||||||
|
| "tl"
|
||||||
|
| "tc"
|
||||||
|
| "tr"
|
||||||
|
| "bl"
|
||||||
|
| "bc"
|
||||||
|
| "br";
|
||||||
|
|
||||||
export type { CardShape } from "../../plotcutter/contour";
|
export type { CardShape } from "../../plotcutter/contour";
|
||||||
|
|
||||||
export interface Layer {
|
export interface Layer {
|
||||||
prop: string;
|
/** CSV column the layer reads, when it renders a prop value. */
|
||||||
|
prop?: string;
|
||||||
|
/** Markdown template (with {{var}} substitution) when the layer renders a template. */
|
||||||
|
template?: string;
|
||||||
x1: number;
|
x1: number;
|
||||||
y1: number;
|
y1: number;
|
||||||
x2: number;
|
x2: number;
|
||||||
y2: number;
|
y2: number;
|
||||||
orientation?: "n" | "s" | "e" | "w";
|
orientation?: "n" | "s" | "e" | "w";
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
align?: "l" | "c" | "r";
|
align?: Align;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LayerConfig {
|
export interface LayerConfig {
|
||||||
prop: string;
|
/** CSV column the layer reads, when it renders a prop value. */
|
||||||
|
prop?: string;
|
||||||
|
/** Markdown template (with {{var}} substitution) when the layer renders a template. */
|
||||||
|
template?: string;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
x1: number;
|
x1: number;
|
||||||
y1: number;
|
y1: number;
|
||||||
@@ -26,7 +48,7 @@ export interface LayerConfig {
|
|||||||
y2: number;
|
y2: number;
|
||||||
orientation?: "n" | "s" | "e" | "w";
|
orientation?: "n" | "s" | "e" | "w";
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
align?: "l" | "c" | "r";
|
align?: Align;
|
||||||
_key?: number;
|
_key?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,12 +57,12 @@ customElement("md-dice", { key: "" }, (props, { element }) => {
|
|||||||
const handleRoll = (e: MouseEvent) => {
|
const handleRoll = (e: MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// 点击骰子图标:总是重 roll
|
// 点击骰子图标:总是重 roll
|
||||||
const rollResult = rollFormula(formula).result;
|
const rollResult = rollFormula(formula);
|
||||||
setResult(rollResult.total);
|
setResult(rollResult.result.total);
|
||||||
setRollDetail(rollResult.detail);
|
setRollDetail(rollResult.result.plainDetail);
|
||||||
setIsRolled(true);
|
setIsRolled(true);
|
||||||
if (effectiveKey()) {
|
if (effectiveKey()) {
|
||||||
setDiceResultToUrl(effectiveKey(), rollResult.total);
|
setDiceResultToUrl(effectiveKey(), rollResult.result.total);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
@@ -45,7 +42,7 @@ customElement("md-embed", { headingBase: 0 }, (props, { element }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="md-embed not-prose">
|
<div class="md-embed">
|
||||||
<Show when={content.loading}>
|
<Show when={content.loading}>
|
||||||
<div class="text-gray-400 italic">加载中...</div>
|
<div class="text-gray-400 italic">加载中...</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -57,8 +54,8 @@ customElement("md-embed", { headingBase: 0 }, (props, { element }) => {
|
|||||||
<Show when={!content.loading && !content.error && content()}>
|
<Show when={!content.loading && !content.error && content()}>
|
||||||
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: markdown render */}
|
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: markdown render */}
|
||||||
<div
|
<div
|
||||||
class="prose"
|
class="prose text-black prose-sm"
|
||||||
innerHTML={parseMarkdown(content()!, iconPrefix())}
|
innerHTML={parseMarkdown(content()!, resolvedPath)}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+80
-40
@@ -1,9 +1,20 @@
|
|||||||
import { customElement, noShadowDOM } from 'solid-element';
|
import { customElement, noShadowDOM } from "solid-element";
|
||||||
import { createSignal, For, Show, createEffect, createMemo, createResource } from 'solid-js';
|
import {
|
||||||
import { marked } from '../markdown';
|
createSignal,
|
||||||
import { loadCSV, CSV, processVariables, isCSV } from './utils/csv-loader';
|
For,
|
||||||
import { resolvePath } from './utils/path';
|
Show,
|
||||||
import { areAllLabelsNumeric, weightedRandomIndex } from './utils/weighted-random';
|
createEffect,
|
||||||
|
createMemo,
|
||||||
|
createResource,
|
||||||
|
} from "solid-js";
|
||||||
|
import { parseMarkdown } from "../markdown";
|
||||||
|
import { parseCSVString, CSV, processVariables } from "./utils/csv-loader";
|
||||||
|
import { resolveContentRef } from "./utils/resolve-content";
|
||||||
|
import { parseSparkTableCsv } from "./utils/spark-table";
|
||||||
|
import {
|
||||||
|
areAllLabelsNumeric,
|
||||||
|
weightedRandomIndex,
|
||||||
|
} from "./utils/weighted-random";;
|
||||||
|
|
||||||
export interface TableProps {
|
export interface TableProps {
|
||||||
roll?: boolean;
|
roll?: boolean;
|
||||||
@@ -16,45 +27,63 @@ interface TableRow {
|
|||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
customElement('md-table', { roll: false, remix: false }, (props, { element }) => {
|
customElement(
|
||||||
|
"md-table",
|
||||||
|
{ roll: false, remix: false },
|
||||||
|
(props, { element }) => {
|
||||||
noShadowDOM();
|
noShadowDOM();
|
||||||
const [rows, setRows] = createSignal<CSV<TableRow>>([] as unknown as CSV<TableRow>);
|
const [rows, setRows] = createSignal<CSV<TableRow>>(
|
||||||
|
[] as unknown as CSV<TableRow>,
|
||||||
|
);
|
||||||
const [activeTab, setActiveTab] = createSignal(0);
|
const [activeTab, setActiveTab] = createSignal(0);
|
||||||
const [activeGroup, setActiveGroup] = createSignal<string | null>(null);
|
const [activeGroup, setActiveGroup] = createSignal<string | null>(null);
|
||||||
const [bodyHtml, setBodyHtml] = createSignal('');
|
const [bodyHtml, setBodyHtml] = createSignal("");
|
||||||
let tabsContainer: HTMLDivElement | undefined;
|
let tabsContainer: HTMLDivElement | undefined;
|
||||||
|
|
||||||
// 从 element 的 textContent 获取 CSV 路径或 inline CSV 数据
|
// 从 element 的 textContent 获取 CSV 路径或 inline CSV 数据
|
||||||
const rawContent = element?.textContent?.trim() || '';
|
const rawContent = element?.textContent?.trim() || "";
|
||||||
|
|
||||||
// 隐藏原始文本内容
|
// 隐藏原始文本内容
|
||||||
if (element) {
|
if (element) {
|
||||||
element.textContent = '';
|
element.textContent = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从父节点 article 的 data-src 获取当前 markdown 文件完整路径
|
// 从父节点 article 的 data-src 获取当前 markdown 文件完整路径
|
||||||
const articleEl = element?.closest('article[data-src]');
|
const articleEl = element?.closest("article[data-src]");
|
||||||
const articlePath = articleEl?.getAttribute('data-src') || '';
|
const articlePath = articleEl?.getAttribute("data-src") || "";
|
||||||
|
|
||||||
// 如果是 inline CSV,直接使用;否则解析相对路径
|
// 解析引用:inline CSV 直接使用,否则通过 registry 解析(含内联内容 id)
|
||||||
const contentOrPath = isCSV(rawContent) ? rawContent : resolvePath(articlePath, rawContent);
|
const [csvData] = createResource(
|
||||||
|
() => ({ ref: rawContent, docPath: articlePath }),
|
||||||
|
async ({ ref, docPath }) => {
|
||||||
|
const content = await resolveContentRef(ref, docPath);
|
||||||
|
if (content === null) {
|
||||||
|
throw new Error(`Failed to resolve table content: "${ref}"`);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// 使用 createResource 加载 CSV,自动响应路径变化并避免重复加载
|
// 当数据加载完成后更新 rows,并为火花表设置 data-spark(供 RevealManager
|
||||||
const [csvData] = createResource(() => contentOrPath, loadCSV);
|
// 悬停触发 /roll)。data-spark 在渲染时派生,不在扫描时注入。
|
||||||
|
|
||||||
// 当数据加载完成后更新 rows
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const data = csvData();
|
const content = csvData();
|
||||||
if (data) {
|
if (!content) return;
|
||||||
// 将加载的数据赋值给 rows,CSV 类型已经包含 sourcePath 等属性
|
setRows(parseCSVString(content) as unknown as CSV<TableRow>);
|
||||||
setRows(data as unknown as CSV<TableRow>);
|
if (element) {
|
||||||
|
const meta = parseSparkTableCsv(content);
|
||||||
|
if (meta) {
|
||||||
|
element.setAttribute("data-spark", meta.slug);
|
||||||
|
} else {
|
||||||
|
element.removeAttribute("data-spark");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 检测是否有 group 列
|
// 检测是否有 group 列
|
||||||
const hasGroup = createMemo(() => {
|
const hasGroup = createMemo(() => {
|
||||||
const allRows = rows();
|
const allRows = rows();
|
||||||
return allRows.length > 0 && 'group' in allRows[0];
|
return allRows.length > 0 && "group" in allRows[0];
|
||||||
});
|
});
|
||||||
|
|
||||||
// 获取所有分组
|
// 获取所有分组
|
||||||
@@ -75,13 +104,16 @@ customElement('md-table', { roll: false, remix: false }, (props, { element }) =>
|
|||||||
const allRows = rows();
|
const allRows = rows();
|
||||||
const group = activeGroup();
|
const group = activeGroup();
|
||||||
if (!group) return allRows;
|
if (!group) return allRows;
|
||||||
return allRows.filter(row => row.group === group);
|
return allRows.filter((row) => row.group === group);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 处理 body 内容中的 {{prop}} 语法并解析 markdown
|
// 处理 body 内容中的 {{prop}} 语法并解析 markdown
|
||||||
const processBody = (body: string, currentRow: TableRow): string => {
|
const processBody = (body: string, currentRow: TableRow): string => {
|
||||||
// 使用 marked 解析 markdown
|
// 使用 parseMarkdown 统一入口(设置图标 base path 等)
|
||||||
return marked.parse(processVariables(body, currentRow, rows(), filteredRows(), props.remix)) as string;
|
return parseMarkdown(
|
||||||
|
processVariables(body, currentRow, rows(), filteredRows(), props.remix),
|
||||||
|
articlePath,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 更新 body 内容
|
// 更新 body 内容
|
||||||
@@ -113,7 +145,7 @@ customElement('md-table', { roll: false, remix: false }, (props, { element }) =>
|
|||||||
if (filtered.length === 0) return;
|
if (filtered.length === 0) return;
|
||||||
|
|
||||||
// 检查所有 label 是否都是整数/整数范围格式
|
// 检查所有 label 是否都是整数/整数范围格式
|
||||||
const labels = filtered.map(row => row.label);
|
const labels = filtered.map((row) => row.label);
|
||||||
if (areAllLabelsNumeric(labels)) {
|
if (areAllLabelsNumeric(labels)) {
|
||||||
// 使用加权随机
|
// 使用加权随机
|
||||||
const randomIndex = weightedRandomIndex(labels);
|
const randomIndex = weightedRandomIndex(labels);
|
||||||
@@ -128,8 +160,12 @@ customElement('md-table', { roll: false, remix: false }, (props, { element }) =>
|
|||||||
|
|
||||||
// 滚动到可视区域
|
// 滚动到可视区域
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const activeButton = tabsContainer?.querySelector('.border-blue-600');
|
const activeButton = tabsContainer?.querySelector(".border-blue-600");
|
||||||
activeButton?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
|
activeButton?.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "nearest",
|
||||||
|
inline: "nearest",
|
||||||
|
});
|
||||||
}, 50);
|
}, 50);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -144,8 +180,8 @@ customElement('md-table', { roll: false, remix: false }, (props, { element }) =>
|
|||||||
onClick={() => handleGroupChange(null)}
|
onClick={() => handleGroupChange(null)}
|
||||||
class={`font-medium transition-colors ${
|
class={`font-medium transition-colors ${
|
||||||
activeGroup() === null
|
activeGroup() === null
|
||||||
? 'text-blue-600 border-b-2 border-blue-600'
|
? "text-blue-600 border-b-2 border-blue-600"
|
||||||
: 'text-gray-500 hover:text-gray-700'
|
: "text-gray-500 hover:text-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
全部
|
全部
|
||||||
@@ -156,8 +192,8 @@ customElement('md-table', { roll: false, remix: false }, (props, { element }) =>
|
|||||||
onClick={() => handleGroupChange(group)}
|
onClick={() => handleGroupChange(group)}
|
||||||
class={`font-medium transition-colors ${
|
class={`font-medium transition-colors ${
|
||||||
activeGroup() === group
|
activeGroup() === group
|
||||||
? 'text-blue-600 border-b-2 border-blue-600'
|
? "text-blue-600 border-b-2 border-blue-600"
|
||||||
: 'text-gray-500 hover:text-gray-700'
|
: "text-gray-500 hover:text-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{group}
|
{group}
|
||||||
@@ -171,21 +207,24 @@ customElement('md-table', { roll: false, remix: false }, (props, { element }) =>
|
|||||||
<Show when={props.roll}>
|
<Show when={props.roll}>
|
||||||
<button
|
<button
|
||||||
onClick={handleRoll}
|
onClick={handleRoll}
|
||||||
class="text-gray-500 hover:text-gray-700 flex-shrink-0 cursor-pointer"
|
class="text-gray-500 hover:text-gray-700 shrink-0 cursor-pointer"
|
||||||
title="随机切换"
|
title="随机切换"
|
||||||
>
|
>
|
||||||
🎲
|
🎲
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
<div ref={tabsContainer} class="flex gap-1 overflow-x-auto flex-1 min-w-0">
|
<div
|
||||||
|
ref={tabsContainer}
|
||||||
|
class="flex gap-1 overflow-x-auto flex-1 min-w-0"
|
||||||
|
>
|
||||||
<For each={filteredRows()}>
|
<For each={filteredRows()}>
|
||||||
{(row, index) => (
|
{(row, index) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab(index())}
|
onClick={() => setActiveTab(index())}
|
||||||
class={`font-medium transition-colors flex-shrink-0 min-w-[1.6em] cursor-pointer ${
|
class={`font-medium transition-colors shrink-0 min-w-[1.6em] cursor-pointer ${
|
||||||
activeTab() === index()
|
activeTab() === index()
|
||||||
? 'text-blue-600 border-b-2 border-blue-600'
|
? "text-blue-600 border-b-2 border-blue-600"
|
||||||
: 'text-gray-500 hover:text-gray-700'
|
: "text-gray-500 hover:text-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{row.label}
|
{row.label}
|
||||||
@@ -203,4 +242,5 @@ customElement('md-table', { roll: false, remix: false }, (props, { element }) =>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,216 +0,0 @@
|
|||||||
import {
|
|
||||||
createSignal,
|
|
||||||
onMount,
|
|
||||||
onCleanup,
|
|
||||||
Show, createEffect,
|
|
||||||
} from "solid-js";
|
|
||||||
import * as THREE from "three";
|
|
||||||
import { ThreeMFLoader } from "three/addons/loaders/3MFLoader.js";
|
|
||||||
|
|
||||||
export interface TokenViewerProps {
|
|
||||||
url: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MdTokenViewer(props: TokenViewerProps) {
|
|
||||||
|
|
||||||
const [viewerRef, setViewerRef] = createSignal<HTMLDivElement | null>(null);
|
|
||||||
const [isLoaded, setIsLoaded] = createSignal(false);
|
|
||||||
const [error, setError] = createSignal<string | null>(null);
|
|
||||||
|
|
||||||
let scene: THREE.Scene | null = null;
|
|
||||||
let camera: THREE.PerspectiveCamera | null = null;
|
|
||||||
let renderer: THREE.WebGLRenderer | null = null;
|
|
||||||
let mesh: THREE.Mesh | null = null;
|
|
||||||
let group: THREE.Group | null = null;
|
|
||||||
let animationId: number | null = null;
|
|
||||||
let isDragging = false;
|
|
||||||
let previousMousePosition = { x: 0, y: 0 };
|
|
||||||
|
|
||||||
// 加载 3MF 用于预览
|
|
||||||
const load3mf = async (url: string) => {
|
|
||||||
const viewerEl = viewerRef();
|
|
||||||
if (!viewerEl) return;
|
|
||||||
|
|
||||||
// 清理旧的场景
|
|
||||||
if (group) {
|
|
||||||
scene?.remove(group);
|
|
||||||
group.traverse((obj) => {
|
|
||||||
if ((obj as THREE.Mesh).isMesh) {
|
|
||||||
const meshObj = obj as THREE.Mesh;
|
|
||||||
meshObj.geometry.dispose();
|
|
||||||
(meshObj.material as THREE.Material).dispose();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
group = null;
|
|
||||||
mesh = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const loader = new ThreeMFLoader();
|
|
||||||
const object = await loader.loadAsync(url);
|
|
||||||
|
|
||||||
// 3MF 文件可能返回一个 Group,包含多个 Mesh
|
|
||||||
group = object instanceof THREE.Group ? object : new THREE.Group().add(object);
|
|
||||||
group.rotateX(Math.PI);
|
|
||||||
|
|
||||||
// 为每个 mesh 启用原始颜色
|
|
||||||
group.traverse((child) => {
|
|
||||||
if ((child as THREE.Mesh).isMesh) {
|
|
||||||
const childMesh = child as THREE.Mesh;
|
|
||||||
const material = childMesh.material as THREE.MeshStandardMaterial;
|
|
||||||
// 保留原始顶点颜色或材质颜色
|
|
||||||
if (childMesh.geometry.attributes.color) {
|
|
||||||
material.vertexColors = true;
|
|
||||||
}
|
|
||||||
// 确保材质是标准材质以支持光照
|
|
||||||
if (!(childMesh.material instanceof THREE.MeshStandardMaterial)) {
|
|
||||||
childMesh.material = new THREE.MeshStandardMaterial({
|
|
||||||
color: material.color,
|
|
||||||
metalness: 0.3,
|
|
||||||
roughness: 0.7,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
scene?.add(group);
|
|
||||||
mesh = group.children[0] as THREE.Mesh;
|
|
||||||
|
|
||||||
// 调整相机
|
|
||||||
if (camera && scene) {
|
|
||||||
const box = new THREE.Box3().setFromObject(group);
|
|
||||||
const size = box.getSize(new THREE.Vector3());
|
|
||||||
const maxDim = Math.max(size.x, size.y, size.z);
|
|
||||||
|
|
||||||
camera.position.set(maxDim * 2, maxDim * 2, maxDim * 2);
|
|
||||||
camera.lookAt(0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoaded(true);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("加载 3MF 预览失败:", e);
|
|
||||||
setError(e instanceof Error ? e.message : "加载模型失败");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 初始化 Three.js 场景
|
|
||||||
onMount(() => {
|
|
||||||
const viewerEl = viewerRef();
|
|
||||||
if (!viewerEl) return;
|
|
||||||
|
|
||||||
// 创建场景
|
|
||||||
scene = new THREE.Scene();
|
|
||||||
scene.background = new THREE.Color(0xf3f4f6);
|
|
||||||
|
|
||||||
// 创建相机
|
|
||||||
camera = new THREE.PerspectiveCamera(
|
|
||||||
45,
|
|
||||||
viewerEl.clientWidth / viewerEl.clientHeight,
|
|
||||||
0.1,
|
|
||||||
1000
|
|
||||||
);
|
|
||||||
camera.position.set(100, 100, 100);
|
|
||||||
|
|
||||||
// 创建渲染器
|
|
||||||
renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
||||||
renderer.setSize(viewerEl.clientWidth, viewerEl.clientHeight);
|
|
||||||
renderer.setPixelRatio(window.devicePixelRatio);
|
|
||||||
renderer.shadowMap.enabled = true;
|
|
||||||
|
|
||||||
viewerEl.appendChild(renderer.domElement);
|
|
||||||
|
|
||||||
// 添加灯光
|
|
||||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
|
||||||
scene.add(ambientLight);
|
|
||||||
|
|
||||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
|
||||||
directionalLight.position.set(50, 100, 50);
|
|
||||||
directionalLight.castShadow = true;
|
|
||||||
scene.add(directionalLight);
|
|
||||||
|
|
||||||
// 添加坐标轴辅助
|
|
||||||
const axesHelper = new THREE.AxesHelper(10);
|
|
||||||
scene.add(axesHelper);
|
|
||||||
|
|
||||||
// 鼠标控制
|
|
||||||
const handleMouseDown = (e: MouseEvent) => {
|
|
||||||
isDragging = true;
|
|
||||||
previousMousePosition = { x: e.clientX, y: e.clientY };
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
|
||||||
if (!isDragging || !group) return;
|
|
||||||
|
|
||||||
const deltaX = e.clientX - previousMousePosition.x;
|
|
||||||
const deltaY = e.clientY - previousMousePosition.y;
|
|
||||||
|
|
||||||
group.rotation.y += deltaX * 0.01;
|
|
||||||
group.rotation.x += deltaY * 0.01;
|
|
||||||
|
|
||||||
previousMousePosition = { x: e.clientX, y: e.clientY };
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
|
||||||
isDragging = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
viewerEl.addEventListener("mousedown", handleMouseDown);
|
|
||||||
document.addEventListener("mousemove", handleMouseMove);
|
|
||||||
document.addEventListener("mouseup", handleMouseUp);
|
|
||||||
|
|
||||||
// 动画循环
|
|
||||||
const animate = () => {
|
|
||||||
if (scene && camera && renderer && group) {
|
|
||||||
if (!isDragging) {
|
|
||||||
group.rotation.y += 0.005; // 自动旋转
|
|
||||||
}
|
|
||||||
renderer.render(scene, camera);
|
|
||||||
}
|
|
||||||
animationId = requestAnimationFrame(animate);
|
|
||||||
};
|
|
||||||
animate();
|
|
||||||
|
|
||||||
// 清理函数
|
|
||||||
onCleanup(() => {
|
|
||||||
if (animationId) cancelAnimationFrame(animationId);
|
|
||||||
viewerEl.removeEventListener("mousedown", handleMouseDown);
|
|
||||||
document.removeEventListener("mousemove", handleMouseMove);
|
|
||||||
document.removeEventListener("mouseup", handleMouseUp);
|
|
||||||
|
|
||||||
if (renderer) {
|
|
||||||
renderer.dispose();
|
|
||||||
viewerEl.removeChild(renderer.domElement);
|
|
||||||
}
|
|
||||||
if (scene) scene.clear();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (props.url) {
|
|
||||||
load3mf(props.url);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={setViewerRef}
|
|
||||||
class="relative border rounded-lg overflow-hidden bg-gray-50 aspect-square"
|
|
||||||
style={{ "min-height": "200px" }}
|
|
||||||
>
|
|
||||||
<div class="absolute top-2 left-2 z-10 bg-black/50 text-white text-xs px-2 py-1 rounded">
|
|
||||||
拖动旋转
|
|
||||||
</div>
|
|
||||||
<Show when={error()}>
|
|
||||||
<div class="absolute inset-0 flex items-center justify-center bg-black/50 text-white">
|
|
||||||
⚠️ {error()}
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={!isLoaded() && !error()}>
|
|
||||||
<div class="absolute inset-0 flex items-center justify-center text-gray-500">
|
|
||||||
<div class="animate-spin inline-block w-6 h-6 border-2 border-current border-t-transparent rounded-full mr-2" />
|
|
||||||
加载模型中...
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user