Compare commits

...
124 Commits
Author SHA1 Message Date
hypercross 9b83123b6e fix: heading scan skips code fences; unify content resolution
- deriveLinks strips fenced code blocks so markdown examples don't
  produce phantom link completions
- Add resolveContentEntry returning body + path + inline flag;
  scanDocDirectives uses it instead of re-deriving resolution, and
  parseDirectiveAttrs is replaced by the shared parseBlockAttrs
- Fix relative file refs with ./ prefix never resolving through
  resolveContent (pathIndex lookup joined the ./ verbatim)
2026-09-08 22:30:14 +08:00
hypercross b7a804f1cf refactor: remove as= override and render data-spark at runtime
- Drop resolveBlockAs and the as= attribute; scanDoc now switches
  directly on role and warns on unknown roles instead of silently
  stripping
- md-table derives data-spark from its loaded CSV via
  parseSparkTableCsv; delete injectSparkDirectives and the second
  registry pass so pathIndex is exactly scanDoc output
- Align spark-table dice header regex with isSparkTableHeader
2026-09-08 22:18:12 +08:00
hypercross 256c685f6c refactor: remove implicit content sniffing and table conversion
- Split loadCSV into parseCSVString (content) and loadCSVFromPath
  (path); drop isCSV/looksLikeCsv heuristics
- Delete coerceSparkTables and markedTable label-header magic;
  plain markdown tables now render as plain tables
- Add explicit markdown role=spark-table fence syntax that converts
  pipe tables to CSV at scan time, with dice-header validation
- Map ESM-only github-slugger and csv-parse browser build to CJS in
  jest config; add content-registry tests
2026-09-08 21:33:12 +08:00
hypercross 5061c4d19d docs: remove remaining yaml/tag legacy references 2026-09-08 20:59:30 +08:00
hypercross 638e8f6526 refactor(markdown): unify yaml tag block syntax on role=tag
Drop legacy yaml/tag info string; tag blocks now require
```yaml role=tag. Add fence-line tag=/id= attributes that
override YAML body values, and move parseBlockAttrs to
src/markdown/block-attrs.ts so scanner and render extensions
share one parser.
2026-09-08 20:56:14 +08:00
hypercross 80e2b5b209 fix(yaml/tag): prevent yaml role=tag from being stripped & generalize
data-config
2026-09-03 10:39:05 +08:00
hypercross 5fc2bc5262 feat(md-deck): add vertical alignment options to layers
Expand align from l/c/r to a 3x3 grid with vertical prefixes:
tl/tc/tr/bl/bc/br. Vertical position renders via flex
justify-content; compact layers strings and YAML both parse the
new values, and the layer editor dropdown gains corner/edge
options.
2026-09-03 10:17:44 +08:00
hypercross d75df5280f feat(markdown): support yaml role=tag info string
Accept yaml role=tag alongside legacy yaml/tag so the block
body gets yaml syntax highlighting, matching the CLI role=
attribute convention. Copy button now emits the role=tag form.
2026-09-03 10:10:06 +08:00
hypercross f1ddc8fb8e refactor(md-deck): pos string and yaml copy output
Replace per-axis x1/y1/x2/y2 layer fields with a single
pos string (x1,y1-x2,y2) matching the compact layers format.

When a deck is configured via a yaml/tag codeblock (data-config),
the copy button now emits a yaml/tag block serialized from current
store state so template layers round-trip.
2026-09-02 18:30:32 +08:00
hypercross 16dfc8a88c feat(md-deck): support structured data-config with template layers
Allow md-deck layers to render markdown templates via {{var}}
substitution instead of only CSV props. Add config.ts to normalize
a JSON data-config attribute (size, grid, shape, layers) emitted by
the yaml code-block tag, taking precedence over legacy string props.
2026-09-02 18:15:51 +08:00
hypercross 023d3a0cb9 style(md-embed): black text and prose-sm 2026-09-01 15:50:50 +08:00
hypercross 7c4865ba8c feat(journal): var stuff 2026-09-01 15:39:36 +08:00
hyper 49260ae4f6 fix(md-embed): proper font size/weight inheritence 2026-08-29 16:23:43 +08:00
hyper 750af4a50c refactor(markdown): Simplify table header detection logic
Remove unused `isSparkTableHeader` import and simplify condition
since spark tables are handled upstream by `coerceSparkTables`.
2026-08-07 13:21:11 +08:00
hyper c03528a293 feat: Unify spark table detection and content resolution
Export isSparkTableHeader from content-registry and use it in both CLI
and frontend table parsing. Add resolveContentRef to resolve content
references consistently. Write processed registry content back to the
file index so browser mode renders identically to CLI mode.
2026-08-07 13:15:51 +08:00
hyper 0a68122efd refactor(cli): centralize content registry building
Extract buildRegistryFromIndex and normalizePathKey from the CLI serve
command and the browser completions module into the shared
content-registry module. This ensures both paths produce identical
pathIndex/docContent, including the spark-injection pass that was
previously only applied in the CLI.
2026-08-07 12:46:08 +08:00
hyper e8aa7165cb refactor(completions): centralize content scanning
Add a browser-safe ContentRegistry owning path and inline content
stores, then derive completions from it. Remove the old block-processor,
directive-scanner, and link-source modules in favor of scanDoc,
deriveCompletions, and injectSparkDirectives, and register an inline
resolver for the frontend file index.
2026-08-07 12:17:35 +08:00
hyper 9e081891df feat(md-deck): Add fallback copy dialog for clipboard failure 2026-08-07 12:13:40 +08:00
hyper e3daac3080 fix(md-deck): Make active card state reactive
Wrap active in a getter so it updates when activeTab changes.
2026-08-07 12:07:54 +08:00
hyper a69a41875d fix: Prevent layer interactions when not editing
Guard layer selection, cursor, and resize interactions behind
the editing state to avoid unintended modifications in view mode.
2026-08-07 11:24:57 +08:00
hyper 6bcdca3a77 feat(md-deck): Add card list navigation and tabbed editor panels
- Move card tab selector to a left sidebar (CardList) with pagination
- Replace separate side toggle and layer panels with EditorTabs
- Move copy code button from layer panel to DeckHeader
- Simplify PropertiesEditorPanel by removing front/back toggle and
  using a select for shape
2026-08-07 11:20:26 +08:00
hyper 18cad20d2c feat: add PLT double cut option to plotter export 2026-07-23 20:08:51 +08:00
hypercross 8c9506c106 fix: strip .md extension from resolved article paths
Ensures that navigation to markdown files strips the extension,
allowing the SPA fallback to handle refreshes instead of requesting
the raw file.
2026-07-14 15:56:38 +08:00
hypercross a934901cce feat: add heading flash animation on hash change 2026-07-14 15:38:39 +08:00
hypercross d78d15d8ec fix(FileTree): use base path for anchor navigation 2026-07-14 15:23:30 +08:00
hypercross f877a58a31 fix(Article): resolve relative hrefs during navigation 2026-07-14 14:18:26 +08:00
hypercross 99dd49b077 refactor: simplify DocDialog and move documentation to markdown body
Simplify the `DocDialog` component by removing hardcoded property
tables and syntax blocks. Instead, rely on the raw markdown body
provided in the `DocEntry` to render documentation content. This
shifts the responsibility of formatting (syntax, props, etc.) from
the component to the individual markdown files, allowing for more
flexible and detailed documentation.
2026-07-13 15:29:08 +08:00
hypercross 7cb28e631e feat: add threshold support and tagmap support to variable system
Introduce a `threshold` column to tag modifiers in CSV declarations,
allowing modifiers to activate only when a tag's count meets a minimum
requirement.

Implement support for "tagmap" variables (e.g., `#warrior:1;#druid:2`)
which allow for complex, multi-tag state tracking. These variables
can receive specialized tagmap modifications rather than simple
numeric additions.
2026-07-13 15:09:50 +08:00
hypercross d74ba69fdf refactor: merge /spark command into /roll
Consolidate the `/spark` command into `/roll` to simplify the CLI.
The `/roll` command now handles both dice notation and spark table
slugs.

- Update `parseInput` to treat `/roll` arguments as a generic string.
- Update `dispatchCommand` to check for spark table matches before
  evaluating dice notation or variable expressions.
- Update command completions to show both dice and spark table
  suggestions under `/roll`.
- Update documentation and UI placeholders to reflect the change.
2026-07-13 14:43:55 +08:00
hypercross c4038a5213 refactor: clean up unused imports and styles
- Remove unused `MessageTypeDef` imports in `ComposePanel` and
  `DynamicForm`
- Remove unused `schema` memo in `DynamicForm`
- Update `FieldInput` label class to use `shrink-0` instead of
  `flex-shrink-0`
- Exclude test files from `tsconfig.cli.json`
2026-07-13 13:32:49 +08:00
hypercross ffa023b92a refactor: optimize reactive variable updates and parsing
- Replace `scanDeclareBlocks` with `parseDeclareCsv` in block processor
- Add error handling for declare block parsing
- Optimize `ReactiveVariableManager` by pre-computing variable keys
- Store template text and keys in data attributes for faster updates
- Adjust `VariableView` popup width via CSS class
2026-07-13 11:13:07 +08:00
hypercross c106b6f79c test: add variable system tests and jest config updates
- Add comprehensive tests for variable system, including parsing,
  expression evaluation, and reactivity.
- Add `moduleNameMapper` to `jest.config.js` to resolve `.js` imports
  to `.ts` source files.
- Add a mock for `csv-parse/browser/esm/sync` to support Node-based
  testing.
2026-07-13 11:08:48 +08:00
hypercross 2c762b0411 refactor: move block scanning logic to shared module
Import `FENCED_BLOCK_RE` and `parseBlockAttrs` from `block-scanner.js`
instead of defining them locally in `declare-parser.ts`.
2026-07-13 10:49:40 +08:00
hypercross f172da378a refactor: unify declare block scanning between CLI and client
Introduce `scanDeclareBlocks` in `declare-parser.ts` to provide a
single source of truth for parsing `role=declare` blocks. This
replaces redundant scanning logic in both the CLI and the journal
completions.

Also remove unused `dice.ts` and `spark-scanner.ts` files.
2026-07-13 10:43:44 +08:00
hypercross 3137970b97 feat: rebuild reactivity state from store after replay
Add `rebuildReactivityFromStore` to synchronize local reactivity
state with the hydrated variable store. This ensures tag activations
are correctly tracked following a reducer replay.

Also remove the unused `invalidateCompletions` function.
2026-07-13 10:34:44 +08:00
hypercross 6f01a29723 refactor(journal): simplify fallback variable retrieval
Return the fallback value as-is instead of applying local
modifications, as the stream value is already authoritative for
untracked variables.
2026-07-13 10:24:33 +08:00
hypercross 4c2eb65470 refactor: pass variable store explicitly to getCombined
Remove the mutable `streamFallback` global state and instead pass the
`VariableStore` as an explicit argument to `getCombined` and related
functions. This ensures correctness by avoiding reliance on mutable
module state during reactivity calculations.
2026-07-13 10:22:22 +08:00
hypercross c524dd5867 refactor: implement base/modifier separation for variable reactivity
Introduce a distinction between base values (set via `/set` or
declarations) and active modifiers (applied via tags). This ensures
tag transitions and numeric modifiers are calculated correctly during
cascades.

- Refactor `VariableView` to use a flat list with hover popups for
  declarations and active modifiers.
- Update `command-dispatcher` to use `getCombined` for accurate
  value lookups and `setBase` for variable updates.
- Update `var-reactivity` to manage `baseValues`, `activeMods`, and
  `sourceActivations` separately.
- Export new utility functions for accessing combined values,
  modifiers, and declaration expressions.
2026-07-13 10:10:47 +08:00
hypercross 411f4d79ff fix(journal): update command trigger condition
Change the command prefix check from "/stat" to "/set" in JournalInput.
2026-07-13 09:41:27 +08:00
hypercross ae44191b28 refactor: use csv-parse for declare block parsing
Replace manual CSV splitting with `csv-parse` to allow for
order-agnostic columns and better handling of quoted values.
The `tag` column is now optional.
2026-07-13 09:38:09 +08:00
hypercross a2d9605f4b feat(journal): seed declared variables on initialization
Implement `computeInitialValues` to calculate the starting state of
declared variables and seed them into the journal stream store during
the completions initialization process.
2026-07-13 01:07:13 +08:00
hypercross c9c977bee1 refactor: remove unused unsubscribe function from journalStream 2026-07-13 00:54:20 +08:00
hypercross 960e310208 feat(dev-server): enable historyApiFallback 2026-07-13 00:49:24 +08:00
hypercross 981c829d0f refactor: replace stat-parser with declare-parser
Replace the old stat-parser logic with a new declare-parser to handle
variable declarations and tag modifiers. This includes moving the
declare-parser logic to a shared location in cli/completions to avoid
duplication between the CLI and the journal component.
2026-07-12 19:14:35 +08:00
hypercross dbe2f9d9d8 docs: add new journal command documentation
Replace the attribute system documentation with new documentation for
the variable system, roll command, link command, and spark command.
2026-07-12 19:09:02 +08:00
hypercross 894f1735e5 refactor: replace stat system with variable system
Replaces the specialized `stat` system with a more general-purpose
variable system. This includes:

- Renaming `ReactiveStatManager` to `ReactiveVariableManager`
- Changing template syntax from `${key}` to `{{$key}}`
- Replacing `StatsView` with `VariableView` to show declared, plain,
  and tag-typed variables
- Updating command `/stat` to `/set`
- Refactoring the reactivity engine to support variable declarations
  and tag modifiers via `csv role=declare` blocks
2026-07-12 17:29:03 +08:00
hypercross 2983aa4440 feat: implement variable reactivity and expression engine
Add support for `role=declare` blocks in markdown to allow for
dynamic variable declarations and tag-based modifiers.

- Implement `declare-parser` to parse CSV-formatted declaration blocks.
- Implement `var-reactivity` to manage dependency graphs, topological
  sorting, and cascading updates when variables change.
- Implement `variable-expression` to evaluate arithmetic, dice rolls,
  and math functions within expressions.
2026-07-12 16:52:04 +08:00
hypercross 86abf34c10 feat: implement shared dispatch error signal
Introduce a shared `dispatchError` signal in the command dispatcher
to unify error reporting across different components. This allows
`CommandLinkManager` to surface errors through the `JournalInput`
UI, ensuring that errors from `:cmd[]` directive clicks are visible
to the user.
2026-07-12 14:38:06 +08:00
hypercross ffbfa65716 refactor: extract command dispatch logic to shared module
Move command parsing and dispatching logic from `CommandLinkManager`
and `JournalInput` into a new `command-dispatcher.ts` module. This
unifies how commands are handled whether they are typed manually or
triggered via `:cmd[]` directive clicks.
2026-07-12 14:26:32 +08:00
hypercross d690d5922e refactor: simplify command dispatch and update cmd-link
- Simplify `dispatchCommand` by removing role-based logic and ensuring
  commands are prefixed with a slash.
- Change `:cmd[]` directive output from a `span` to an `a` tag.
- Update `.cmd-link` styles to improve link appearance and prevent
  text selection.
2026-07-12 14:18:01 +08:00
hypercross 42e8971ff7 refactor: replace command link interception with :cmd[] directive
Replace the previous mechanism of intercepting specific command-prefixed
anchor tags with a dedicated `:cmd[]` markdown directive.

- Implement `cmdDirective` for `marked-directive` support.
- Update `CommandLinkManager` to listen for clicks on `[data-cmd]` spans
  instead of parsing `href` attributes.
- Add CSS styles for the new `.cmd-link` class.
2026-07-12 14:05:07 +08:00
hypercross 182d7ff28d feat: add CommandLinkManager to intercept command links
Implement a component that uses event delegation to intercept clicks
on markdown links starting with command prefixes (e.g., >roll, >spark).
It dispatches these as journal stream messages based on the user's
role (Observer, Player, or GM).
2026-07-12 13:48:34 +08:00
hypercross 736bcf4bb2 feat: add ReactiveStatManager for markdown template support
Introduce ReactiveStatManager to allow dynamic stat replacement within
rendered markdown content. This component scans the article DOM for
`${key}` patterns and reactively updates them based on the journal
stream.

As part of this change, the SVG-based StatSheet system is removed in
favor of this more flexible text-based approach.
2026-07-12 13:32:49 +08:00
hypercross 722a8110e6 feat(file-tree): use anchor tags for better navigation
Convert div elements to anchor tags in FileTree and HeadingNode to
enable native browser features like middle-click to open in new tabs.
The implementation preserves SPA navigation for left-clicks while
ensuring search parameters are preserved in the href.
2026-07-12 10:46:35 +08:00
hypercross 40f2190307 feat: sync player name and role on state patch
Persist name and role to localStorage and URL parameters when
received via patch to ensure synchronization. Broadcast full state
upon successful connection.
2026-07-12 10:39:53 +08:00
hypercross 241c8609f1 refactor: expose Comlink API via MessagePort in SharedWorker
Update the worker to use the 'onconnect' event to handle incoming
MessagePorts, ensuring each connecting tab receives its own port.
2026-07-12 10:14:36 +08:00
hypercross 2187b7ed82 feat: implement journal stream via Shared Worker
Move MQTT connection and message log management to a Shared Worker
using Comlink. This allows multiple browser tabs to share a single
connection and synchronized state.
2026-07-12 10:08:14 +08:00
hypercross fc6e37a13d fix(journal): update min-height for JournalInput textarea 2026-07-12 09:46:11 +08:00
hypercross e46cc879ae refactor: remove mothership journal module 2026-07-12 00:37:10 +08:00
hypercross 2068ecad10 feat: add remix mode for spark table rolling
Introduces a `remix` option that allows each data column in a spark
table to be rolled independently. This is enabled via a `remix=true`
attribute in the directive's extra attributes.

Also refactors spark table scanning by moving markdown parsing logic
from the frontend to a new CLI-side `spark-scanner.ts` utility,
improving separation of concerns.
2026-07-11 11:58:01 +08:00
hypercross 719bb6ad8a refactor: use CSV path instead of markdown file for spark tables
Switch the spark table resolution logic to use the direct path to the
backing CSV file rather than attempting to parse the containing
markdown file. This simplifies the lookup process and improves
reliability.

Also refactor `SparkTableMeta` to store rows as objects keyed by
header instead of raw string arrays.
2026-07-11 11:45:38 +08:00
hypercross b8cfb05e53 feat(journal): add ErrorPopup to display full error messages
Introduces an ErrorPopup component that allows users to view the
complete error message by clicking on the truncated error text in
the JournalInput.
2026-07-11 10:00:05 +08:00
hypercross 841fbc7bae refactor: simplify spark table detection logic 2026-07-11 09:56:18 +08:00
hypercross 6d0bd75cb6 refactor: simplify button positioning in RevealManager
Remove unused scroll container dependency and use relative rect
coordinates for button positioning.
2026-07-11 09:49:48 +08:00
hypercross e437d20d73 feat: update session creation to hydrate data immediately
Update `createSession` to return the session ID, allowing the UI to
automatically set the active session and trigger a server hydration
immediately after creation. This ensures the UI reflects the new
session state without a manual refresh.
2026-07-11 09:32:26 +08:00
hypercross f71af6a06d fix: update proxy path and MQTT broker URL in dev
Change the proxy path from `/journal` to `/.ttrpg` and ensure the
MQTT broker URL points to localhost:3000 during development to
match the CLI server configuration.
2026-07-11 09:29:16 +08:00
hypercross 75e862c310 feat: treat doc-entries markdown files as asset/source 2026-07-11 09:27:57 +08:00
hypercross c661d2a1d2 refactor: replace webpack context loading with dev server proxy
Remove the webpack-based file indexing strategy in favor of a proxy
approach. The dev server now proxies content requests to a local CLI
server, simplifying the data loading logic and removing webpack-specific
types and loaders.
2026-07-11 09:19:41 +08:00
hypercross 52563fd3ef fix(journal): close completions on Enter key press 2026-07-10 15:57:35 +08:00
hypercross 9790205468 style: remove hardcoded text color from Article components 2026-07-10 15:56:51 +08:00
hypercross d4ebca5fd0 refactor: move spark table parsing to directive scanner
Relocate spark table logic from the block processor to a dedicated
directive scanner. This allows spark tables to be treated as
`:md-table` directives and ensures they are processed in a second pass
after the content index is fully populated.
2026-07-10 15:55:45 +08:00
hypercross 89b32ef15e chore: rename project to Tabletop Toolkit
- Update package name and binary from ttrpg-tools/ttrpg to tttk
- Update UI titles in App.tsx and index.html
- Add Node.js engine requirement to package.json
2026-07-10 11:35:43 +08:00
hyper 3a20e9558b chore: Prepare package for npm publication 2026-07-09 16:23:40 +08:00
hyper c071cad50a refactor: Reformat code and update z-index class
Reformat md-table.tsx to use double quotes and consistent code style.
Change z-index class in PrintPreview from `z-[60]` to `z-60`.
2026-07-09 16:03:41 +08:00
hyper a20063c624 refactor(cli): Extract shared deck and frontmatter utils
Create deck-utils.ts and frontmatter/shared.ts to consolidate
duplicated logic. Rename ensure-deck-preview.ts to preview-deck.ts.
2026-07-09 14:31:08 +08:00
hyper 06c3916a95 feat: rename project to TTTK
- Update package name and CLI binary to 'tttk'
- Revise documentation to reflect new name
- Add default serve behavior when no subcommand is given
- Add directory existence check in serve command
- Refactor inline comments to Chinese
2026-07-09 14:14:45 +08:00
hypercross c38d9bdc4f feat(journal): improve sheet rendering and URL persistence
- Update SheetView to use full-width SVG and auto-height for better
  scaling
- Center template nodes in SheetView to maintain text alignment
- Enable overflow-y-auto in SheetView to support long sheets
- Persist selected sheet ID in URL search parameters in StatsView
2026-07-09 12:43:45 +08:00
hypercross fe87b1f80c feat(journal): support <tspan> in stat sheet templates
Update the stat sheet parser to scan <tspan> elements within <text>
nodes. This allows for individual binding of template patterns to
specific tspans, preserving their position attributes.
2026-07-09 12:30:36 +08:00
hypercross 30d1c5dc1b feat: add support for stat sheet SVG rendering
Introduces the ability to discover and render `*.sheet.svg` files.
These files can contain `<text>` elements with `${key}` templates
that reactively update based on the current journal stats.

- Add `StatSheet` type and CLI scanning logic
- Implement `SheetView` for rendering SVGs with live bindings
- Add `parseSheet` utility to extract template patterns from SVG
- Update file indexer to include `.svg` files
2026-07-09 12:04:25 +08:00
hypercross 4f3b03d082 feat: support custom labels for stat modifiers
Allows passing an optional label to `parseStatModifiers` to override
the default ID-based label. This enables more descriptive names in
the UI and improves how modifier labels are derived in the journal
view.
2026-07-09 11:28:17 +08:00
hypercross ef71a43f0a feat: add stat-modifiers role for automatic stat generation
Introduces a new `stat-modifiers` CSV role that simplifies the
creation of template stats and their associated modifier stats.
A single CSV block now automatically generates:
- A template stat definition.
- Multiple modifier stat definitions (prefixed with the template ID).
- The corresponding template table.

This reduces the need for manual YAML definitions for every
modifier associated with a template.
2026-07-09 11:16:39 +08:00
hypercross d83256e049 docs: refactor journal-stat documentation structure 2026-07-09 10:59:37 +08:00
hypercross a4cfcde613 refactor: consolidate block processing logic
Introduce a centralized `block-processor` and `block-scanner` to
handle markdown fenced code blocks. This replaces the previous
`inline-blocks.ts` and individual completion sources with a unified
approach that handles block stripping, directive replacement, and
metadata extraction (stats, templates, and spark tables) in a single
pass.
2026-07-09 10:56:54 +08:00
hypercross 1c69bf394c feat: add support for stat templates
Introduces a new `template` stat type that allows for table-based
lookups via CSV blocks in markdown files. When a template stat is
rolled, it uses a dice expression defined in the template to select
an entry, applies a label, and automatically updates associated
modifier stats using relative values.
2026-07-09 10:26:54 +08:00
hypercross 19c66640b5 refactor: extract stat parsing logic to shared module
Move YAML and CSV stat parsing logic from CLI and journal components
into a new shared `stat-parser.ts` module to enable reuse between
the CLI scanner and the client-side frontend.
2026-07-09 10:00:17 +08:00
hypercross ef8b56e5b6 feat: add stat completion source
Implement a new completion source that extracts stat definitions
from markdown files using `yaml` or `csv` code blocks with
`role=stat`.
2026-07-09 09:52:34 +08:00
hypercross 45cd9a01ac feat: add CSV support for journal stat blocks 2026-07-09 09:47:17 +08:00
hypercross 138c089514 feat: implement scoped stat system with player/global support
Introduces a scoping mechanism for statistics, allowing properties
to be defined as either "player" (prefixed with player name) or
"global".

- Adds `scope` property to StatDef
- Implements key resolution to handle bare keys vs full keys
- Updates `canModifyStat` to enforce player-specific permissions
- Enhances `makeStatLookup` to resolve scoped references in formulas
- Adds documentation for the new stat system
2026-07-09 09:41:16 +08:00
hypercross 44e4e15f3f feat: implement command autocomplete for journal input
Refactor journal input command parsing into a dedicated module and
add a `CompletionsDropdown` component to provide real-time suggestions
for commands (/roll, /spark, /link, /stat) and their arguments.
2026-07-09 09:31:19 +08:00
hypercross 9747409a1f feat: implement stat system and StatsView
Introduces a comprehensive stat management system including:
- `/stat` command support (set, del, roll)
- `StatsView` component for displaying grouped stat tables
- Formula evaluation for derived stats (supporting arithmetic and
  functions)
- Stat definition parsing from YAML blocks
- Permission checking for stat modification based on roles
2026-07-09 09:22:14 +08:00
hypercross 4faff9a391 feat(journal): allow players to emit roll messages 2026-07-09 09:06:25 +08:00
hypercross ae3045f96b fix(cli): preserve inline blocks without file or as attributes 2026-07-08 16:49:01 +08:00
hypercross 6ab6b76978 feat: pass source path to markdown parser for icon resolution
Pass the file's base path through the markdown parsing pipeline to
allow the icon directive to resolve relative asset paths correctly
using an absolute fallback chain.
2026-07-08 16:38:19 +08:00
hypercross 2dba19e1f8 feat: implement icon directive with relative path fallback
Refactor icon rendering to use a custom `marked-directive` instead of
a global icon prefix. This allows icons to use a CSS-based fallback
chain (searching up to 10 directory levels for an `assets/` folder),
making icon paths more robust and removing the need to pass path
prefixes through the component tree.
2026-07-08 16:24:09 +08:00
hypercross 749b1a6a4b feat: support multiple attributes and as directive in inline blocks
Update the inline block parser to support arbitrary key-value pairs.
If an `as` attribute is provided, the block is replaced with a
directive (e.g., `:directive[path]{attrs}`) instead of being
stripped. If no `as` attribute is present, the block is removed.
Additionally, filenames are now automatically generated using a
content hash if the `file` attribute is missing.
2026-07-08 16:02:19 +08:00
hypercross 2a9eee6410 feat: extract and index inline file blocks in markdown 2026-07-08 15:46:19 +08:00
hypercross 9a5ee695c2 docs: clarify dev environment index loading behavior
Update file-index.ts documentation and implementation to specify
that webpackContext loading is restricted to development mode.
This ensures the code block is tree-shaken during production builds.
2026-07-08 15:19:50 +08:00
hypercross e0c3b8f4a0 feat: add bundle analysis support
Add `webpack-bundle-analyzer` and a new `analyze` script to allow
visualizing bundle composition via `npm run analyze`.
2026-07-08 15:16:12 +08:00
hypercross d95615f210 refactor: remove token and token-viewer components
Remove the `md-token` and `md-token-viewer` components along with their
associated 3D generation and image tracing utilities. Update
documentation and project metadata to reflect these changes and rename
the project title to "Tabletop Tools".
2026-07-08 15:09:22 +08:00
hypercross 5097aba842 refactor: implement scroll container context and flex layout
Switch from fixed positioning to a flexbox-based layout for the
main application structure. This introduces a `ScrollContainerCtx` to
provide access to the main scrollable element, allowing child
components like `FileTree` and `RevealManager` to perform accurate
scrolling and positioning relative to the container instead of the
window.

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

- ConnectDialog for joining sessions with name and role selection
- CreateSessionDialog for starting new sessions
- InviteDialog for generating and copying player invite links
- SessionDropdown for switching between sessions (GM only)
- JournalHeader for displaying connection status and user info
2026-07-08 00:31:35 +08:00
hypercross 3d957706e9 feat(journal): add JournalContext to allow closing panel on mobile 2026-07-08 00:11:16 +08:00
hypercross 57ffb35f2b feat: publish initial manifest on startup and improve UI focus
- Publish the existing session manifest via the broker on startup
- Automatically focus the new session input field when it is shown
- Prevent event propagation when clicking to create a new session
2026-07-08 00:08:12 +08:00
hypercross 3eba8b3279 fix(ui): increase z-index for DocDialog elements
Update z-index from 50 to 60 for the dialog overlay and dropdown
to ensure they appear above other UI components.
2026-07-07 23:59:57 +08:00
hypercross c10b088fb1 feat: add Journal server documentation to DocDialog
Introduce a new documentation set for Journal server functionality,
including a dropdown selector in the DocDialog to switch between
"directives" and "journal" documentation modes.
2026-07-07 23:57:31 +08:00
hypercross 1ef41f04fc feat: localize journal components to Chinese 2026-07-07 23:51:35 +08:00
hypercross 9a41f541e9 feat: improve server startup logs and simplify journal input labels
- Add logic to detect the best LAN IPv4 address for easier network
  access
- Display both localhost and LAN access URLs in the CLI
- Remove file path from journal input autocomplete labels
2026-07-07 23:48:04 +08:00
hypercross 45c8722af8 fix: decode URI components before path processing
Ensure path normalization and encoding handle URI-encoded segments
correctly by decoding them first.
2026-07-07 23:43:02 +08:00
hypercross e6d74cc318 fix: remove command prefix from action prefill text 2026-07-07 23:32:33 +08:00
hypercross 779722fe85 refactor: preserve URL search params during navigation
Introduce `useNavigateWithParams` to ensure that existing URL search
parameters are maintained when navigating between pages. This is
applied to the Article component, FileTree, and journal links to
prevent losing state (like session or player info) during client-side
navigation.

Also intercepts markdown anchor links in the Article component to use
client-side navigation.
2026-07-07 23:26:44 +08:00
hypercross 48776424a7 fix(RevealManager): prevent GM listeners when disconnected
Only initialize GM hover listeners if the user is both a GM and
connected.
2026-07-07 23:17:57 +08:00
hypercross 4eaf6aab16 refactor: move GM action buttons to Solid components
Migrate GM action button injection from imperative DOM manipulation to
a reactive Solid component (`RevealManager`) using `Portal`. This
improves cleanup reliability and ensures buttons are proper Solid
components rather than raw HTML strings injected into the DOM.

Also adds URI encoding for link paths and simplifies the reveal
store logic.
2026-07-07 23:14:04 +08:00
hypercross aa326f38a0 refactor: introduce RevealManager and Article DOM context
Decouple the reveal logic from the `Article` component by introducing
a `RevealManager` component and an `ArticleDomCtx`. This allows the
reveal logic to reactively manage DOM injections and classes without
polluting the `Article` component's props or lifecycle.

Additionally, implements a robust `cleanupInjections` mechanism to
ensure that injected buttons and styling artifacts are properly removed
during navigation, role changes, or disconnections.
2026-07-07 22:19:04 +08:00
hypercross 1ef2560faa fix: normalize line endings in frontmatter parsing
Normalize CRLF line endings to LF before parsing frontmatter
to ensure regex matching works consistently across different platforms.
2026-07-07 21:47:25 +08:00
hypercross 6b3a915350 refactor: simplify spark button injection logic
Update the spark button injection to use `data-spark` slugs on
`md-table` elements instead of manual table parsing. This improves
reliability by leveraging the slug generated during markdown
rendering.
2026-07-07 21:44:00 +08:00
hypercross 56af7dea42 feat: add spark table roll buttons for GMs
Introduces the ability for GMs to inject spark roll buttons into
tables that match existing spark completions.

- Replaces `linkPrefill` with a more flexible `actionPrefill` system
  to support multiple command types (e.g., `/link`, `/spark`).
- Implements `injectSparkButtons` to identify spark tables in the
  DOM and insert action buttons.
- Updates `JournalInput` to handle structured prefill actions.
2026-07-07 21:38:43 +08:00
hypercross e801ac9b5f refactor: improve spark table lookup and error reporting
Replace the heuristic-based slug parsing with a more robust
combined-slug lookup to correctly handle page names containing
hyphens. Added `scanSparkTables` to provide better error messages
when a table is not found.
2026-07-07 19:13:11 +08:00
hypercross 9a312f76ad fix(completions): instantiate Slugger per file
Move Slugger instantiation inside the file loops to prevent
state leakage between files, as Slugger is stateful.
2026-07-07 19:07:37 +08:00
hypercross 97148aa010 refactor: improve column slug extraction logic 2026-07-07 19:05:47 +08:00
hypercross 2f29f8774d feat: add spark table completion and rolling support
Implement "spark tables" functionality, which allows users to roll
dice against markdown tables to retrieve specific values.

- Add `sparkTablesSource` to scan markdown files for tables starting
  with a dice notation (e.g., d6, d20).
- Implement `/spark` command in the journal to resolve and roll
  spark tables.
- Add a new message type `spark` with a dedicated UI component to
  render the results.
- Update completions API to include spark table metadata.
2026-07-07 19:02:17 +08:00
145 changed files with 10109 additions and 4803 deletions
+21
View File
@@ -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.
+6 -11
View File
@@ -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
# 安装依赖
npm install
# 全局安装 CLI
npm link
# 预览内容
ttrpg serve ./content
# 无需安装,直接使用
npx tttk serve ./content
```
## 文档导航
@@ -20,6 +14,7 @@ ttrpg serve ./content
| 文档 | 说明 |
|------|------|
| [📖 CLI 使用说明](./docs/cli.md) | CLI 安装、命令和用法 |
| [📦 npm](https://www.npmjs.com/package/tttk) | `npx tttk serve ./content` |
| [🛠️ 开发指南](./docs/development.md) | 项目结构、开发规范和构建 |
| [📝 Markdown 编写说明](./docs/markdown.md) | Markdown 语法和组件用法 |
| [📊 CSV 编写说明](./docs/csv.md) | CSV 文件格式、字段定义、变量语法 |
@@ -27,7 +22,7 @@ ttrpg serve ./content
## 功能概览
- **CLI 工具**: `serve` 预览模式 和 `compile` 编译模式
- **CLI 工具**: `tttk serve` 预览模式 和 `tttk compile` 编译模式
- **Markdown 解析**: 支持指令语法、YAML 标签、mermaid 图表
- **TTRPG 组件**: 骰子、表格、卡牌、标记、命令追踪器等
+26
View File
@@ -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
View File
@@ -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" 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
tag: md-deck
```yaml role=tag tag=md-deck
body: ./names.csv
size: 54x86
grid: 5x8
@@ -15,3 +14,24 @@ 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
```
```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
View File
@@ -1,18 +1,18 @@
# CLI 使用说明
TTRPG Tools 提供一个 CLI 工具,用于将目录内的各种 TTRPG 文档编译为 HTML。
TTTK 提供一个 CLI 工具,用于将目录内的各种 TTRPG 文档编译为 HTML。
## 安装
```bash
# 克隆仓库后安装依赖
npm install
# 无需安装,直接运行
npx tttk serve ./content
# 全局链接 CLI 工具
npm link
# 全局安装
npm install -g tttk
```
安装完成后,可在任意目录使用 `ttrpg` 命令。
安装完成后,可在任意目录使用 `tttk` 命令。
## 命令
@@ -23,7 +23,7 @@ CLI 使用子命令组织:
运行一个 Web 服务器预览目录中的内容,并实时监听文件更新。
```bash
ttrpg serve [dir] -p 3000
tttk serve [dir] -p 3000
```
**参数:**
@@ -50,13 +50,13 @@ ttrpg serve [dir] -p 3000
```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 - 编译模式
@@ -64,7 +64,7 @@ ttrpg serve ./docs -p 8080
将目录中的内容输出为带 hash 路由、单个 HTML 入口的 Web 应用。
```bash
ttrpg compile [dir] -o ./dist/output
tttk compile [dir] -o ./dist/output
```
**参数:**
@@ -90,13 +90,13 @@ ttrpg compile [dir] -o ./dist/output
```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
# 使用其他端口
ttrpg serve -p 8080
tttk serve -p 8080
```
### 编译输出为空
@@ -174,5 +174,5 @@ ttrpg serve -p 8080
ls -R ./content
# 重新编译
ttrpg compile ./content -o ./dist/output
tttk compile ./content -o ./dist/output
```
+3 -3
View File
@@ -28,7 +28,7 @@ npm test
## 项目结构
```
ttrpg-tools/
tttk/
├── src/
│ ├── cli/ # CLI 工具源码
│ │ ├── commands/ # 命令实现 (serve, compile)
@@ -41,7 +41,7 @@ ttrpg-tools/
│ │ ├── md-pins.tsx # 标记组件
│ │ ├── md-commander/ # 命令追踪器
│ │ ├── md-yarn-spinner.tsx # 叙事线组件
│ │ ├── md-token.tsx # 代币组件
│ │ ├── Article.tsx # 文章组件
│ │ ├── Sidebar.tsx # 侧边栏
│ │ ├── FileTree.tsx # 文件树
@@ -77,7 +77,7 @@ ttrpg-tools/
| 样式 | Tailwind CSS 4 + @tailwindcss/typography |
| Markdown | marked + marked-directive + marked-alert + marked-gfm-heading-id |
| 图表 | mermaid |
| 3D | three.js |
| 测试 | Jest |
| CLI | commander + chokidar |
+55 -35
View File
@@ -373,26 +373,30 @@ label,name,description
:md-table[./quests.csv]{roll=true remix=true}
```
**自动表格转换**
**内联表格(显式声明)**
标准 Markdown 表格会自动转换`md-table` 组件,当表头包含 `label``md-table-label` 列时
Markdown 表格会自动转换。如需内联表格,用代码块并声明 `role=spark-table`(首列需为骰子公式,如 `d6`
```markdown
| label | name | description |
|-------|------|-------------|
| 1 | 战士 | 近战专家 |
| 2 | 法师 | 奥术施法者 |
````markdown
```markdown role=spark-table
| d6 | 结果 |
|----|------|
| 1 | 遭遇强盗 |
| 2 | 平安无事 |
```
````
自动转换为 `:md-table` 组件。
扫描时转换为 CSV 并渲染为 `md-table` 组件。CSV 格式的内联表格用 `csv` 语言:
**特殊表头标识:**
````markdown
```csv role=spark-table
d6,结果
1,遭遇强盗
2,平安无事
```
````
| 表头 | 效果 |
|------|------|
| `label``md-table-label` | 转换为 md-table |
| `md-roll-label` 或骰子格式(如 `1d6` | 添加 `roll=true` |
| `md-remix-label` | 添加 `roll=true remix=true` |
普通 Markdown 表格(无 role 声明)始终按标准 GFM 表格渲染,不做任何转换。
### 🃏 卡牌组件 (md-deck)
@@ -423,6 +427,32 @@ layers="字段:起始行,起始列 - 结束列,字体大小"
示例:`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)
用于展示分支叙事结构,支持 Yarn Spinner 格式文件。
@@ -431,26 +461,7 @@ layers="字段:起始行,起始列 - 结束列,字体大小"
:md-yarn-spinner[./story.yarn]
```
### 🪙 代币组件 (md-token)
用于展示游戏代币/棋子。
```markdown
:md-token[./token.png]
```
### 🎨 代币预览组件 (md-token-viewer)
用于 3D 预览 3MF 格式的代币模型。
```markdown
:md-token-viewer[./token.3mf]
```
**功能:**
- 使用 Three.js 渲染 3D 模型
- 支持鼠标拖拽旋转
- 自动旋转展示
### 📋 命令追踪器 (md-commander)
@@ -482,10 +493,10 @@ track npc#john.dwarf.warrior[hp=4/4 ac=15 name="John"]
## YAML 标签
使用 ```yaml/tag 代码块创建自定义标签:
使用 ```yaml role=tag 代码块创建自定义标签`yaml` 语言可被语法高亮)
````markdown
```yaml/tag
```yaml role=tag
tag: tag-name
class: custom-class
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
+3 -2
View File
@@ -335,7 +335,7 @@ MCP 服务器提供以下 Resources,包含 TTRPG Tools 的文档和参考材
- `:md-table` - 表格组件
- `:md-deck` - 卡牌组件
- `:md-yarn-spinner` - 叙事线组件
- `:md-token` - 代币组件
- `:md-commander` - 命令追踪器
- YAML 标签
- Mermaid 图表
@@ -535,7 +535,8 @@ src/cli/
│ │ └── write-frontmatter.ts
│ ├── card/
│ │ └── card-crud.ts
│ ├── ensure-deck-preview.ts
│ ├── deck-utils.ts
│ ├── preview-deck.ts
│ └── generate-card-deck.ts
└── index.ts
```
+8
View File
@@ -4,6 +4,14 @@ export default {
testEnvironment: 'node',
testMatch: ['**/*.test.ts'],
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: {
'^.+\\.tsx?$': [
'ts-jest',
+170 -219
View File
@@ -1,20 +1,20 @@
{
"name": "ttrpg-tools",
"name": "tttk",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ttrpg-tools",
"name": "tttk",
"version": "0.0.1",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^0.5.0",
"@solidjs/router": "^0.15.0",
"@thisbeyond/solid-dnd": "^0.7.5",
"@types/three": "^0.183.1",
"aedes": "^1.1.1",
"chokidar": "^5.0.0",
"comlink": "^4.4.2",
"commander": "^14.0.3",
"csv-parse": "^6.1.0",
"csv-stringify": "^6.7.0",
@@ -27,17 +27,15 @@
"mqtt": "^5.15.1",
"solid-element": "^1.9.1",
"solid-js": "^1.9.3",
"three": "^0.183.2",
"three-3mf-exporter": "^45.1.0",
"uuid": "^14.0.1",
"ws": "^8.21.0",
"yarn-spinner-loader": "^0.1.0",
"zod": "^4.4.3"
},
"bin": {
"ttrpg": "dist/cli/index.js"
"tttk": "dist/cli/index.js"
},
"devDependencies": {
"@image-tracer-ts/core": "^1.0.2",
"@rsbuild/core": "^1.1.8",
"@rsbuild/plugin-babel": "^1.1.0",
"@rsbuild/plugin-solid": "^1.1.0",
@@ -47,14 +45,20 @@
"@types/jest": "^30.0.0",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.19.13",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
"cross-env": "^10.1.0",
"husky": "^9.1.7",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"tailwindcss": "^4.0.0",
"ts-jest": "^29.4.6",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"webpack-bundle-analyzer": "^5.3.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@alloc/quick-lru": {
@@ -871,11 +875,15 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"license": "Apache-2.0"
"node_modules/@discoveryjs/json-ext": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz",
"integrity": "sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.17.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.9.2",
@@ -908,6 +916,13 @@
"tslib": "^2.4.0"
}
},
"node_modules/@epic-web/invariant": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
"dev": true,
"license": "MIT"
},
"node_modules/@iconify/types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
@@ -925,13 +940,6 @@
"mlly": "^1.8.0"
}
},
"node_modules/@image-tracer-ts/core": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@image-tracer-ts/core/-/core-1.0.2.tgz",
"integrity": "sha512-IY1/AMqvu6444dEwaFwJXwskp2fyKTFFVKvKHXBCT7hGz7OLRLHrWGHJrCsVw6HPSeucoHD5j/gtieWVSzocEw==",
"dev": true,
"license": "MIT"
},
"node_modules/@istanbuljs/load-nyc-config": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@@ -2012,6 +2020,13 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
"dev": true,
"license": "MIT"
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz",
@@ -2942,12 +2957,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -3354,27 +3363,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.183.1",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz",
"integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==",
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": ">=0.5.17",
"@webgpu/types": "*",
"fflate": "~0.8.2",
"meshoptimizer": "~1.0.1"
}
},
"node_modules/@types/tough-cookie": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
@@ -3389,10 +3377,11 @@
"license": "MIT",
"optional": true
},
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
@@ -3431,12 +3420,6 @@
"d3-transition": "^3.0.1"
}
},
"node_modules/@webgpu/types": {
"version": "0.1.69",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz",
"integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==",
"license": "BSD-3-Clause"
},
"node_modules/abab": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
@@ -4350,6 +4333,12 @@
"node": ">= 0.8"
}
},
"node_modules/comlink": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/comlink/-/comlink-4.4.2.tgz",
"integrity": "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==",
"license": "Apache-2.0"
},
"node_modules/commander": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
@@ -4441,12 +4430,6 @@
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cose-base": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz",
@@ -4503,6 +4486,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/cross-env": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@epic-web/invariant": "^1.0.0",
"cross-spawn": "^7.0.6"
},
"bin": {
"cross-env": "dist/bin/cross-env.js",
"cross-env-shell": "dist/bin/cross-env-shell.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -5576,41 +5577,6 @@
],
"license": "BSD-3-Clause"
},
"node_modules/fast-xml-builder": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"path-expression-matcher": "^1.1.3"
}
},
"node_modules/fast-xml-parser": {
"version": "5.5.10",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.10.tgz",
"integrity": "sha512-go2J2xODMc32hT+4Xr/bBGXMaIoiCwrwp2mMtAvKyvEFW6S/v5Gn2pBmE4nvbwNjGhpcAiOwEv7R6/GZ6XRa9w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"fast-xml-builder": "^1.1.4",
"path-expression-matcher": "^1.2.1",
"strnum": "^2.2.2"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fastparallel": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz",
@@ -5640,12 +5606,6 @@
"bser": "2.1.1"
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -6082,12 +6042,6 @@
],
"license": "BSD-3-Clause"
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/import-local": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
@@ -6247,12 +6201,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -7959,18 +7907,6 @@
"node": ">=6"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/katex": {
"version": "0.16.45",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz",
@@ -8044,15 +7980,6 @@
"node": ">=6"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -8514,11 +8441,18 @@
"node": ">= 20"
}
},
"node_modules/meshoptimizer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz",
"integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==",
"license": "MIT"
"node_modules/mermaid/node_modules/uuid": {
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
@@ -8713,6 +8647,16 @@
"safe-buffer": "~5.2.0"
}
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -8832,6 +8776,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/opener": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
"integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==",
"dev": true,
"license": "(WTFPL OR MIT)",
"bin": {
"opener": "bin/opener-bin.js"
}
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -8893,12 +8847,6 @@
"integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==",
"license": "MIT"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parse-json": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
@@ -8947,21 +8895,6 @@
"node": ">=8"
}
},
"node_modules/path-expression-matcher": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.1.tgz",
"integrity": "sha512-d7gQQmLvAKXKXE2GeP9apIGbMYKz88zWdsn/BN2HRWVQsDFdUY36WSLTY0Jvd4HWi7Fb30gQ62oAOzdgJA6fZw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@@ -9298,21 +9231,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
@@ -9567,12 +9485,6 @@
"seroval": "^1.0"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -9609,6 +9521,21 @@
"dev": true,
"license": "ISC"
},
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
"integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@polka/url": "^1.0.0-next.24",
"mrmime": "^2.0.0",
"totalist": "^3.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@@ -9840,18 +9767,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strnum": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz",
"integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/stylis": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
@@ -9927,25 +9842,6 @@
"node": ">=8"
}
},
"node_modules/three": {
"version": "0.183.2",
"resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz",
"integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==",
"license": "MIT"
},
"node_modules/three-3mf-exporter": {
"version": "45.2.0",
"resolved": "https://registry.npmjs.org/three-3mf-exporter/-/three-3mf-exporter-45.2.0.tgz",
"integrity": "sha512-Xwuciopn3ecMhf9muVIRTh/GrSdxUTrdz43fMfazPZdxQ1E0eOBIGEhQEUrGlvRLLcWeaHo5MvZQCClTAMmmog==",
"license": "MIT",
"dependencies": {
"fast-xml-parser": "^5.0.9",
"jszip": "^3.10.1"
},
"peerDependencies": {
"three": "*"
}
},
"node_modules/tinyexec": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz",
@@ -10034,6 +9930,16 @@
"node": ">=0.6"
}
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
@@ -10338,16 +10244,16 @@
"license": "MIT"
},
"node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
"integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/v8-compile-cache-lib": {
@@ -10547,6 +10453,51 @@
"node": ">=12"
}
},
"node_modules/webpack-bundle-analyzer": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-5.3.0.tgz",
"integrity": "sha512-PEhAoqiJ+47d0uLMx/+zo5XOvaU+Vk6N2ZLht7H3n09QLy/fhyvqGNwjdRUHJDgMN8crBR2ZwVHkIswT3Xuawg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@discoveryjs/json-ext": "^0.6.3",
"acorn": "^8.0.4",
"acorn-walk": "^8.0.0",
"commander": "^14.0.2",
"escape-string-regexp": "^5.0.0",
"html-escaper": "^3.0.3",
"opener": "^1.5.2",
"picocolors": "^1.0.0",
"sirv": "^3.0.2",
"ws": "^8.19.0"
},
"bin": {
"webpack-bundle-analyzer": "lib/bin/analyzer.js"
},
"engines": {
"node": ">= 20.9.0"
}
},
"node_modules/webpack-bundle-analyzer/node_modules/escape-string-regexp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/webpack-bundle-analyzer/node_modules/html-escaper": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
"integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
"dev": true,
"license": "MIT"
},
"node_modules/whatwg-encoding": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz",
+29 -18
View File
@@ -1,27 +1,37 @@
{
"name": "ttrpg-tools",
"name": "tttk",
"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",
"bin": {
"ttrpg": "./dist/cli/index.js"
"tttk": "./dist/cli/index.js"
},
"main": "./dist/index.js",
"module": "./dist/index.js",
"exports": {
".": "./dist/index.js",
"./components": "./dist/components/index.js",
"./markdown": "./dist/markdown/index.js"
"files": [
"dist",
"LICENSE",
"README.md"
],
"repository": {
"type": "git",
"url": "https://github.com/hypercross/ttrpg-tools"
},
"engines": {
"node": ">=18"
},
"scripts": {
"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",
"analyze": "cross-env ANALYZE=true rsbuild build",
"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",
"tttk": "node --loader ts-node/esm ./src/cli/index.ts",
"test": "jest",
"prepare": "npx husky"
"prepare": "npx husky",
"prepublishOnly": "npm run build"
},
"keywords": [
"ttrpg",
@@ -29,15 +39,15 @@
"markdown",
"toolbox"
],
"author": "",
"author": "hypercross",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^0.5.0",
"@solidjs/router": "^0.15.0",
"@thisbeyond/solid-dnd": "^0.7.5",
"@types/three": "^0.183.1",
"aedes": "^1.1.1",
"chokidar": "^5.0.0",
"comlink": "^4.4.2",
"commander": "^14.0.3",
"csv-parse": "^6.1.0",
"csv-stringify": "^6.7.0",
@@ -50,14 +60,12 @@
"mqtt": "^5.15.1",
"solid-element": "^1.9.1",
"solid-js": "^1.9.3",
"three": "^0.183.2",
"three-3mf-exporter": "^45.1.0",
"uuid": "^14.0.1",
"ws": "^8.21.0",
"yarn-spinner-loader": "^0.1.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@image-tracer-ts/core": "^1.0.2",
"@rsbuild/core": "^1.1.8",
"@rsbuild/plugin-babel": "^1.1.0",
"@rsbuild/plugin-solid": "^1.1.0",
@@ -67,13 +75,16 @@
"@types/jest": "^30.0.0",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.19.13",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
"cross-env": "^10.1.0",
"husky": "^9.1.7",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"tailwindcss": "^4.0.0",
"ts-jest": "^29.4.6",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"webpack-bundle-analyzer": "^5.3.0"
}
}
+32 -21
View File
@@ -1,7 +1,8 @@
import { defineConfig } from '@rsbuild/core';
import { pluginBabel } from '@rsbuild/plugin-babel';
import { pluginSolid } from '@rsbuild/plugin-solid';
import tailwindcss from '@tailwindcss/postcss';
import { defineConfig } from "@rsbuild/core";
import { pluginBabel } from "@rsbuild/plugin-babel";
import { pluginSolid } from "@rsbuild/plugin-solid";
import tailwindcss from "@tailwindcss/postcss";
import { BundleAnalyzerPlugin } from "webpack-bundle-analyzer";
export default defineConfig({
plugins: [
@@ -16,32 +17,42 @@ export default defineConfig({
plugins: [tailwindcss()],
},
},
rspack: {
module: {
rules: [
{
test: /\.md|\.yarn|\.csv$/,
exclude: /\.schema\.csv$/,
type: 'asset/source'
},
]
}
}
rspack: {
module: {
rules: [
{
// Built-in doc entries are statically imported as strings
test: /[\\/]doc-entries[\\/].*\.md$/,
type: "asset/source",
},
],
},
plugins: [
process.env.ANALYZE ? new BundleAnalyzerPlugin() : undefined,
].filter(Boolean),
},
},
html: {
template: './src/index.html',
template: "./src/index.html",
},
source: {
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: {
distPath: {
root: 'dist/web',
root: "dist/web",
},
copy: process.env.NODE_ENV === 'development' ? [
{ from: './content', to: 'content' }
] : [],
},
});
+87 -35
View File
@@ -1,7 +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 { useJournalStream } from "./components/stores/journalStream";
import { addRevealedClasses } from "./components/stores/reveal";
// 导入组件以注册自定义元素
import "./components";
@@ -11,9 +17,30 @@ import {
DesktopSidebar,
DocDialog,
DataSourceDialog,
RevealManager,
ReactiveVariableManager,
CommandLinkManager,
} from "./components";
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 location = useLocation();
@@ -30,6 +57,8 @@ const App: Component = () => {
>({});
const [tocKey, setTocKey] = createSignal(0);
let mainRef!: HTMLElement;
const loadToc = async () => {
const toc = await generateToc();
setFileTree(toc.fileTree);
@@ -42,6 +71,8 @@ const App: Component = () => {
void loadToc();
});
useHeadingFlash();
const handleSourceChanged = () => {
setTocKey((k) => k + 1);
};
@@ -58,23 +89,10 @@ const App: Component = () => {
});
return (
<div class="min-h-screen bg-gray-50">
{/* 桌面端固定侧边栏 */}
<DesktopSidebar
fileTree={fileTree()}
pathHeadings={pathHeadings()}
onDataSourceOpen={() => setIsDataSourceOpen(true)}
/>
{/* 移动端抽屉式侧边栏 */}
<MobileSidebar
isOpen={isSidebarOpen()}
onClose={() => setIsSidebarOpen(false)}
fileTree={fileTree()}
pathHeadings={pathHeadings()}
onDataSourceOpen={() => setIsDataSourceOpen(true)}
/>
<header class="fixed top-0 left-0 right-0 bg-white shadow z-30">
<div class="max-w-4xl mx-auto px-4 py-4 flex items-center gap-4">
<div class="h-screen flex flex-col overflow-hidden bg-gray-50">
{/* Header */}
<header class="shrink-0 h-16 bg-white shadow z-30">
<div class="h-full max-w-4xl mx-auto px-4 flex items-center gap-4">
{/* 仅在移动端显示菜单按钮 */}
<button
onClick={() => setIsSidebarOpen(true)}
@@ -83,7 +101,7 @@ const App: Component = () => {
>
</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" />
<button
onClick={() => setIsJournalOpen((v) => !v)}
@@ -115,29 +133,63 @@ const App: Component = () => {
</button>
</div>
</header>
{/* fill the rest of the space */}
<div
class="fixed top-16 left-0 right-0 bottom-0 overflow-auto transition-all duration-300"
classList={{ "md:pr-[420px]": isJournalOpen() }}
>
<main class="max-w-4xl mx-auto px-4 py-8 pt-4 md:ml-64 2xl:ml-auto flex justify-center items-center">
<Article
class="prose text-black prose-sm max-w-full flex-1"
src={currentPath()}
onDom={(dom) => addRevealedClasses(dom, location.pathname)}
{/* 移动端抽屉式侧边栏 (overlay) */}
<MobileSidebar
isOpen={isSidebarOpen()}
onClose={() => setIsSidebarOpen(false)}
fileTree={fileTree()}
pathHeadings={pathHeadings()}
onDataSourceOpen={() => setIsDataSourceOpen(true)}
/>
{/* Body: sidebar + main + journal */}
<div class="flex flex-1 min-h-0">
{/* 桌面端固定侧边栏 (flex child) */}
<DesktopSidebar
fileTree={fileTree()}
pathHeadings={pathHeadings()}
onDataSourceOpen={() => setIsDataSourceOpen(true)}
/>
{/* Main content — the scrollable region */}
<ScrollContainerCtx.Provider value={() => mainRef}>
<main ref={mainRef} class="flex-1 min-w-0 overflow-y-auto">
<div class="max-w-4xl mx-auto px-4 py-8">
<Article
class="prose prose-sm max-w-full"
src={currentPath()}
>
<RevealManager />
<ReactiveVariableManager />
<CommandLinkManager />
</Article>
</div>
</main>
</ScrollContainerCtx.Provider>
{/* Journal panel wrapper — on desktop: flex child with width transition;
on mobile: passes through to JournalPanel's own overlay */}
<div
class="contents md:block md:shrink-0 md:overflow-hidden md:transition-all md:duration-300"
classList={{
"md:w-105": isJournalOpen(),
"md:w-0": !isJournalOpen(),
}}
>
<JournalPanel
open={isJournalOpen()}
onClose={() => setIsJournalOpen(false)}
/>
</main>
</div>
</div>
<DocDialog isOpen={isDocOpen()} onClose={() => setIsDocOpen(false)} />
<DataSourceDialog
isOpen={isDataSourceOpen()}
onClose={() => setIsDataSourceOpen(false)}
onSourceChanged={handleSourceChanged}
/>
<JournalPanel
open={isJournalOpen()}
onClose={() => setIsJournalOpen(false)}
/>
</div>
);
};
+6 -6
View File
@@ -1,14 +1,14 @@
import type { CompileCommandHandler } from '../types.js';
import type { CompileCommandHandler } from "../types.js";
export const compileCommand: CompileCommandHandler = async (dir, options) => {
console.log(`开始编译...`);
console.log(`目录:${dir}`);
console.log("开始编译...");
console.log(`目录:${dir}`);
console.log(`输出目录:${options.output}`);
// TODO: 实现编译逻辑
// 1. 扫描目录下的所有 .md 文件
// 2. 解析 markdown 并生成路由
// 3. 打包为带 hash 路由的单个 HTML 入口
console.log('编译完成!');
console.log("编译完成!");
};
+113 -55
View File
@@ -2,28 +2,32 @@ import type { ServeCommandHandler } from "../types.js";
import { createServer, Server, IncomingMessage, ServerResponse } from "http";
import { readdirSync, statSync, readFileSync, existsSync } 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 { networkInterfaces } from "os";
import { fileURLToPath } from "url";
import { createJournalServer } from "../journal.js";
import {
scanCompletions,
type CompletionsPayload,
} from "../completions/index.js";
buildRegistryFromIndex,
normalizePathKey,
deriveCompletions,
type ContentRegistry,
} from "../content-registry.js";
import type { CompletionsPayload } from "../completions/types.js";
interface ContentIndex {
[path: string]: string;
}
/**
* 获取 CLI 脚本文件所在目录路径(用于定位 dist 文件夹
* 获取 CLI 脚本文件所在目录路径(用于定位 dist 目录
*/
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const distDir = resolve(__dirname, "..", "..", "..", "dist", "web");
/**
* MIME 类型映射
* MIME 类型映射
*/
const MIME_TYPES: Record<string, string> = {
".html": "text/html",
@@ -42,7 +46,7 @@ const MIME_TYPES: Record<string, string> = {
};
/**
* 获取文件扩展名对应的 MIME 类型
* 根据文件扩展名获取对应的 MIME 类型
*/
function getMimeType(filePath: string): string {
const ext = extname(filePath).toLowerCase();
@@ -50,10 +54,39 @@ function getMimeType(filePath: string): string {
}
/**
* 扫描目录内的 .md 文件,生成索引
* 获取最佳网络 IP 用于显示(优先局域网 IPv4,回退到 localhost
*/
export function scanDirectory(dir: string): ContentIndex {
const index: ContentIndex = {};
function getBestIP(): string {
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) {
const entries = readdirSync(currentPath);
@@ -63,7 +96,7 @@ export function scanDirectory(dir: string): ContentIndex {
const fullPath = join(currentPath, entry);
const relPath = relativePath ? join(relativePath, entry) : entry;
const normalizedRelPath = "/" + relPath.split(sep).join("/");
const normalizedRelPath = normalizePathKey(relPath);
const stats = statSync(fullPath);
if (stats.isDirectory()) {
@@ -71,11 +104,11 @@ export function scanDirectory(dir: string): ContentIndex {
} else if (
entry.endsWith(".md") ||
entry.endsWith(".csv") ||
entry.endsWith(".yarn")
entry.endsWith(".yarn") ||
entry.endsWith(".svg")
) {
try {
const content = readFileSync(fullPath, "utf-8");
index[normalizedRelPath] = content;
index[normalizedRelPath] = readFileSync(fullPath, "utf-8");
} catch (e) {
console.error(`读取文件失败:${fullPath}`, e);
}
@@ -84,11 +117,12 @@ export function scanDirectory(dir: string): ContentIndex {
}
scan(dir, "");
return index;
return buildRegistryFromIndex(index);
}
/**
* 发送文件响应
* 发送文件响应(流式传输)
*/
function sendFile(res: ServerResponse, filePath: string) {
res.writeHead(200, {
@@ -99,7 +133,7 @@ function sendFile(res: ServerResponse, filePath: string) {
}
/**
* 发送 404 响应
* 发送 404 未找到响应
*/
function send404(res: ServerResponse) {
res.writeHead(404, { "Content-Type": "text/plain" });
@@ -107,7 +141,7 @@ function send404(res: ServerResponse) {
}
/**
* 发送 JSON 响应
* 发送 JSON 格式的响应
*/
function sendJson(res: ServerResponse, data: unknown) {
res.writeHead(200, {
@@ -119,7 +153,7 @@ function sendJson(res: ServerResponse, data: unknown) {
/**
* 尝试提供静态文件
* @returns 如果文件存在并成功发送返回 true
* @returns 文件存在并成功发送返回 true
*/
function tryServeStatic(
res: ServerResponse,
@@ -142,13 +176,14 @@ function tryServeStatic(
}
/**
* 创建请求处理器
* 创建 HTTP 请求处理器
*/
function createRequestHandler(
contentDir: string,
distDir: string,
getIndex: () => ContentIndex,
getCompletions: () => CompletionsPayload,
getRegistry: () => ContentRegistry,
) {
return (req: IncomingMessage, res: ServerResponse) => {
const url = req.url || "/";
@@ -166,6 +201,12 @@ function createRequestHandler(
return;
}
// 1c. 处理 /__CONTENT_REGISTRY.json(含每文档内联内容,供运行时解析)
if (filePath === "/__CONTENT_REGISTRY.json") {
sendJson(res, getRegistry());
return;
}
// 2. 处理 /static/ 目录(从 dist/web
if (filePath.startsWith("/static/")) {
if (tryServeStatic(res, filePath, distDir)) {
@@ -192,7 +233,7 @@ function createRequestHandler(
}
/**
* 内容服务器接口
* 内容服务器实例接口
*/
export interface ContentServer {
/**
@@ -218,7 +259,7 @@ export interface ContentServer {
}
/**
* 创建内容服务器
* 创建内容服务器HTTP + WebSocket + 文件监听)
*/
export function createContentServer(
contentDir: string,
@@ -226,25 +267,31 @@ export function createContentServer(
distPath: string = distDir,
host: string = "0.0.0.0",
): ContentServer {
let contentIndex: ContentIndex = {};
let completionsIndex: CompletionsPayload = { dice: [], links: [] };
let registry: ContentRegistry = { pathIndex: {}, docContent: {} };
let completionsIndex: CompletionsPayload = {
dice: [],
links: [],
sparkTables: [],
declarations: [],
tagModifiers: [],
};
/** Re-scan completions from current content index (cached) */
/** 从当前注册表重新派生补全数据 */
function recomputeCompletions(): void {
completionsIndex = scanCompletions(contentIndex);
completionsIndex = deriveCompletions(registry);
console.log(
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length}`,
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} declarations=${completionsIndex.declarations.length} tagModifiers=${completionsIndex.tagModifiers.length}`,
);
}
// 扫描内容目录生成索引
console.log("扫描内容目录...");
contentIndex = scanDirectory(contentDir);
console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`);
// 扫描内容目录生成注册表
console.log("正在扫描内容目录...");
registry = buildRegistry(contentDir);
console.log(`已索引 ${Object.keys(registry.pathIndex).length} 个文件`);
recomputeCompletions();
// 监听文件变化
console.log("监听文件变化...");
console.log("正在监听文件变化...");
const watcher = watch(contentDir, {
ignored: /(^|[\/\\])\./,
persistent: true,
@@ -256,14 +303,14 @@ export function createContentServer(
if (
path.endsWith(".md") ||
path.endsWith(".csv") ||
path.endsWith(".yarn")
path.endsWith(".yarn") ||
path.endsWith(".svg")
) {
try {
const content = readFileSync(path, "utf-8");
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
contentIndex[relPath] = content;
console.log(`[新增] ${relPath}`);
if (relPath.endsWith(".md")) recomputeCompletions();
// 全量重建注册表以刷新跨文件派生(spark 注入)
registry = buildRegistry(contentDir);
recomputeCompletions();
console.log(`[新增] ${path}`);
} catch (e) {
console.error(`读取新增文件失败:${path}`, e);
}
@@ -273,14 +320,13 @@ export function createContentServer(
if (
path.endsWith(".md") ||
path.endsWith(".csv") ||
path.endsWith(".yarn")
path.endsWith(".yarn") ||
path.endsWith(".svg")
) {
try {
const content = readFileSync(path, "utf-8");
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
contentIndex[relPath] = content;
console.log(`[更新] ${relPath}`);
if (relPath.endsWith(".md")) recomputeCompletions();
registry = buildRegistry(contentDir);
recomputeCompletions();
console.log(`[更新] ${path}`);
} catch (e) {
console.error(`读取更新文件失败:${path}`, e);
}
@@ -290,24 +336,25 @@ export function createContentServer(
if (
path.endsWith(".md") ||
path.endsWith(".csv") ||
path.endsWith(".yarn")
path.endsWith(".yarn") ||
path.endsWith(".svg")
) {
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
delete contentIndex[relPath];
console.log(`[删除] ${relPath}`);
if (relPath.endsWith(".md")) recomputeCompletions();
registry = buildRegistry(contentDir);
recomputeCompletions();
console.log(`[删除] ${path}`);
}
});
// ---- Journal / MQTT broker ----
let journal: Awaited<ReturnType<typeof createJournalServer>> | null = null;
// 创建 HTTP 服务器 BEFORE journal so we can pass it in
// 在 journal 之前创建 HTTP 服务器,以便传入给 journal
const handleRequest = createRequestHandler(
contentDir,
distPath,
() => contentIndex,
() => registry.pathIndex,
() => completionsIndex,
() => registry,
);
const server = createServer(handleRequest);
@@ -316,20 +363,25 @@ export function createContentServer(
});
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(`本机访问:http://localhost:${port}`);
if (bestIP !== "localhost") {
console.log(`局域网访问:http://${bestIP}:${port}`);
}
console.log(`内容目录:${contentDir}`);
console.log(`静态资源目录:${distPath}`);
console.log(`Journal 连接:ws://${displayHost}:${port}\n`);
console.log(`日志连接:ws://${bestIP}:${port}\n`);
});
return {
server,
watcher,
index: contentIndex,
index: registry.pathIndex,
completions: completionsIndex,
close() {
console.log("关闭内容服务器...");
console.log("正在关闭内容服务器...");
server.close();
watcher.close();
if (journal) journal.close();
@@ -342,6 +394,12 @@ export function createContentServer(
*/
export const serveCommand: ServeCommandHandler = async (dir, options) => {
const contentDir = resolve(dir);
if (!existsSync(contentDir)) {
console.error(`错误:目录不存在:${contentDir}`);
process.exit(1);
}
const port = parseInt(options.port, 10);
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";
+26
View File
@@ -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;
+102
View File
@@ -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 };
}
-32
View File
@@ -1,32 +0,0 @@
/**
* Completion index — orchestrates all registered completion sources.
*/
import { diceSource } from "./sources/dice.js";
import { linksSource } from "./sources/links.js";
import type { CompletionSource, CompletionsPayload } from "./types.js";
export type {
CompletionsPayload,
DiceCompletion,
LinkCompletion,
} from "./types.js";
/** Registered sources — open for extension */
const sources: CompletionSource[] = [diceSource, linksSource];
/**
* Scan the full content index and return structured completion data.
* Called at server startup and on any file change.
*/
export function scanCompletions(
index: Record<string, string>,
): CompletionsPayload {
const payload: Record<string, unknown[]> = {};
for (const source of sources) {
payload[source.key] = source.scan(index);
}
return payload as unknown as CompletionsPayload;
}
-38
View File
@@ -1,38 +0,0 @@
/**
* Dice completion source — extracts `<md-dice>` text content from markdown files.
*/
import type { CompletionSource, DiceCompletion } from "../types.js";
function looksLikeDice(raw: string): boolean {
if (raw.length > 80) return false;
return /^\d*d\d+/i.test(raw) || /^[+-]/.test(raw);
}
export const diceSource: CompletionSource = {
key: "dice",
scan(index) {
const items: DiceCompletion[] = [];
const tagRegex = /:md-dice\[([^[]+)\]/gi;
for (const [path, content] of Object.entries(index)) {
if (!path.endsWith(".md")) continue;
let match: RegExpExecArray | null;
tagRegex.lastIndex = 0;
while ((match = tagRegex.exec(content)) !== null) {
const raw = match[1].trim();
if (!raw || !looksLikeDice(raw)) continue;
items.push({
label: raw,
notation: raw,
source: path,
});
}
}
return items;
},
};
-55
View File
@@ -1,55 +0,0 @@
/**
* Link completion source — extracts markdown headings from all .md files.
*
* Produces two entries per heading section, plus one for the file itself.
* Uses github-slugger to match marked-gfm-heading-id's generated IDs.
*/
import Slugger from "github-slugger";
import type { CompletionSource, LinkCompletion } from "../types.js";
export const linksSource: CompletionSource = {
key: "links",
scan(index) {
const items: LinkCompletion[] = [];
for (const [filePath, content] of Object.entries(index)) {
if (!filePath.endsWith(".md")) continue;
// Strip .md extension for the router-friendly path
const basePath = filePath.replace(/\.md$/, "");
const fileName = fileNameFromPath(basePath);
const slugger = new Slugger();
// Add the file itself as a link (whole article)
items.push({
path: basePath,
label: fileName,
section: null,
});
// Parse headings for section-scoped links
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;
},
};
function fileNameFromPath(path: string): string {
const parts = path.split("/").filter(Boolean);
return parts[parts.length - 1] || path;
}
+27 -1
View File
@@ -2,6 +2,10 @@
* 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 */
@@ -22,10 +26,32 @@ export interface LinkCompletion {
section: string | null;
}
/** Top-level payload served at /__COMPLETIONS.json */
/** 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[];
}
/**
File diff suppressed because it is too large Load Diff
+199
View File
@@ -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);
});
});
+628
View File
@@ -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;
}
+23 -6
View File
@@ -8,25 +8,42 @@ import type { ServeOptions, CompileOptions } from "./types.js";
const program = new Command();
program
.name("ttrpg")
.description("TTRPG 工具箱 - 用于编译和预览 TTRPG 文档")
.name("tttk")
.description(
"TTRPG 内容预览服务器。不带参数运行时默认启动当前目录的预览服务。",
)
.version("0.0.1");
program
.command("serve")
.description("运行一个 web 服务器预览目录中的内容,并实时监听更新")
.description("启动 web 服务器预览目录中的内容,并实时监听文件更新")
.argument("[dir]", "要预览的目录", ".")
.option("-p, --port <port>", "HTTP 端口号", "3000")
.option("-h, --host <host>", "主机地址", "0.0.0.0")
.option("--host <host>", "主机地址", "0.0.0.0")
.action(serveCommand);
program
.command("compile")
.description("将目录中的内容输出为带 hash 路由、单个 html 入口的 web 应用")
.description("[实验性] 将目录内容编译为带 hash 路由的单页 Web 应用 (开发中)")
.argument("[dir]", "要编译的目录", ".")
.option("-o, --output <dir>", "输出目录", "./dist/output")
.action(compileCommand);
program.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]);
+40 -26
View File
@@ -1,14 +1,14 @@
/**
* Journal stream persistence — MQTT broker + JSONL append + session manifest
* 日志流持久化 — MQTT 代理 + JSONL 追加写入 + 会话清单
*
* Runs an aedes MQTT broker over WebSocket, attached to the existing
* HTTP server. Uses ws v8's built-in createWebSocketStream.
* 在现有 HTTP 服务器上挂载 aedes MQTT 代理(WebSocket 传输),
* 使用 ws v8 内置的 createWebSocketStream
*
* Pattern from aedes docs:
* 实现参考 aedes 文档:
* https://github.com/moscajs/aedes#mqtt-server-over-websocket
*
* Persistence uses aedes's internal subscribe/publish API, so the
* server process doesn't need to connect to itself as an MQTT client.
* 持久化使用 aedes 内部 subscribe/publish API,无需服务器进程
* 以 MQTT 客户端身份连接自身。
*/
import { join, resolve } from "path";
@@ -26,7 +26,7 @@ import type { AedesPublishPacket } from "aedes";
import { WebSocketServer, createWebSocketStream } from "ws";
// ---------------------------------------------------------------------------
// Types
// 类型定义
// ---------------------------------------------------------------------------
interface SessionMeta {
@@ -46,7 +46,7 @@ export interface JournalServer {
}
// ---------------------------------------------------------------------------
// Path helpers
// 路径工具函数
// ---------------------------------------------------------------------------
function dataDir(contentDir: string): string {
@@ -88,7 +88,7 @@ function appendStream(dataRoot: string, id: string, line: string): void {
}
// ---------------------------------------------------------------------------
// Server factory
// 服务器工厂
// ---------------------------------------------------------------------------
const $SESSIONS = "ttrpg/$SESSIONS";
@@ -98,15 +98,15 @@ export async function createJournalServer(
httpServer: HttpServer,
): Promise<JournalServer> {
const root = dataDir(contentDir);
console.log(`[journal] data dir: ${root}`);
console.log(`[日志] 数据目录:${root}`);
// ---- MQTT Broker ----
// ---- MQTT 代理 ----
const { Aedes: AedesFactory } = await import("aedes");
// aedes requires listen() to initialize persistence before handling connections.
// aedes 需要先 listen() 初始化持久化,再处理连接
const broker = await AedesFactory.createBroker();
// ---- WebSocket server (attached to the existing HTTP server) ----
// Pattern from aedes docs: pass { server } to WSS, use 'connection' event
// ---- WebSocket 服务器(挂载到现有 HTTP 服务器上) ----
// 参考 aedes 文档:给 WSS 传入 { server },使用 'connection' 事件
const wss = new WebSocketServer({ server: httpServer });
wss.on("connection", (ws, req) => {
@@ -115,13 +115,13 @@ export async function createJournalServer(
});
broker.on("client", (client: import("aedes").Client) => {
console.log(`[journal] client ready: ${client.id}`);
console.log(`[日志] 客户端已连接:${client.id}`);
});
broker.on("clientDisconnect", (client: import("aedes").Client) => {
console.log(`[journal] client disconnected: ${client.id}`);
console.log(`[日志] 客户端已断开:${client.id}`);
});
// ---- Persistence (internal subscribe, no separate MQTT client) ----
// ---- 持久化(内部订阅,无需独立 MQTT 客户端) ----
broker.subscribe(
"ttrpg/+/stream",
(packet, cb) => {
@@ -130,7 +130,7 @@ export async function createJournalServer(
try {
appendStream(root, sessionId, packet.payload.toString());
} catch (e) {
console.error(`[journal] stream append err for ${sessionId}:`, e);
console.error(`[日志] 会话 ${sessionId} 流记录写入失败:`, e);
}
}
cb();
@@ -150,14 +150,28 @@ export async function createJournalServer(
() => {},
);
console.log("[journal] persistence listener active");
console.log("[journal] broker available at ws://<host>:<port>");
// 启动时发布初始清单,让客户端能看到已有会话
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("[journal] shutting down...");
console.log("[日志] 正在关闭...");
await new Promise<void>((r) => wss.close(() => r()));
await new Promise<void>((r) => broker.close(() => r()));
},
@@ -165,7 +179,7 @@ export async function createJournalServer(
}
// ---------------------------------------------------------------------------
// Helpers
// 工具函数
// ---------------------------------------------------------------------------
function extractSessionId(topic: string): string | null {
@@ -174,7 +188,7 @@ function extractSessionId(topic: string): string | null {
}
// ---------------------------------------------------------------------------
// Session lifecycle
// 会话生命周期管理
// ---------------------------------------------------------------------------
function handleMetaChange(
@@ -198,7 +212,7 @@ function handleMetaChange(
} catch {
/* */
}
console.log(`[journal] session deleted: ${sessionId}`);
console.log(`[日志] 会话已删除:${sessionId}`);
}
} else {
manifest.sessions[sessionId] = {
@@ -206,7 +220,7 @@ function handleMetaChange(
created: meta.created || Date.now(),
players: meta.players || [],
};
console.log(`[journal] session updated: ${sessionId} (${meta.name})`);
console.log(`[日志] 会话已更新:${sessionId} (${meta.name})`);
}
saveManifest(root, manifest);
@@ -222,6 +236,6 @@ function handleMetaChange(
() => {},
);
} catch (e) {
console.error(`[journal] meta parse err for ${sessionId}:`, e);
console.error(`[日志] 会话 ${sessionId} 元数据解析失败:`, e);
}
}
+66 -78
View File
@@ -1,8 +1,11 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { parse } from 'csv-parse/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 { readFileSync, writeFileSync, existsSync } from "fs";
import { parse } from "csv-parse/browser/esm/sync";
import { stringify } from "csv-stringify/browser/esm/sync";
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;
}
/**
* 解析 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
*/
@@ -87,19 +55,19 @@ function loadCSVWithFrontmatter(filePath: string): {
records: CardData[];
headers: string[];
} {
const content = readFileSync(filePath, 'utf-8');
const content = readFileSync(filePath, "utf-8");
const { frontmatter, csvContent } = parseFrontMatter(content);
const records = parse(csvContent, {
columns: true,
comment: '#',
comment: "#",
trim: true,
skipEmptyLines: true
skipEmptyLines: true,
}) as CardData[];
// 获取表头
const firstLine = csvContent.split('\n')[0];
const headers = firstLine.split(',').map(h => h.trim());
const firstLine = csvContent.split("\n")[0];
const headers = firstLine.split(",").map((h) => h.trim());
return { frontmatter, records, headers };
}
@@ -111,30 +79,30 @@ function saveCSVWithFrontmatter(
filePath: string,
frontmatter: DeckFrontmatter | undefined,
records: CardData[],
headers?: string[]
headers?: string[],
): void {
// 序列化 frontmatter
const frontmatterStr = frontmatter ? serializeFrontMatter(frontmatter) : '';
const frontmatterStr = frontmatter ? serializeFrontMatter(frontmatter) : "";
// 确定表头
if (!headers || headers.length === 0) {
// 从 records 和 frontmatter.fields 推断表头
headers = ['label'];
headers = ["label"];
if (frontmatter?.fields && Array.isArray(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('body');
headers.push("body");
}
// 确保所有 record 都有 headers 中的列
for (const record of records) {
for (const header of headers) {
if (!(header in record)) {
record[header] = '';
record[header] = "";
}
}
}
@@ -142,11 +110,11 @@ function saveCSVWithFrontmatter(
// 序列化 CSV
const csvContent = stringify(records, {
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;
// 检查文件是否存在(create 操作可以不存在)
if (action !== 'create' && !existsSync(csv_file)) {
if (action !== "create" && !existsSync(csv_file)) {
return {
success: false,
message: `文件不存在:${csv_file}`
message: `文件不存在:${csv_file}`,
};
}
@@ -189,8 +157,8 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
// 执行操作
switch (action) {
case 'create': {
const newCards = Array.isArray(cards) ? cards : (cards ? [cards] : []);
case "create": {
const newCards = Array.isArray(cards) ? cards : cards ? [cards] : [];
for (const card of newCards) {
if (!card.label) {
card.label = generateNextLabel(records);
@@ -202,16 +170,22 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
success: true,
message: `成功创建 ${newCards.length} 张卡牌`,
cards: newCards,
count: newCards.length
count: newCards.length,
};
}
case 'read': {
const labelsToRead = Array.isArray(label) ? label : (label ? [label] : null);
case "read": {
const labelsToRead = Array.isArray(label)
? label
: label
? [label]
: null;
let resultCards: CardData[];
if (labelsToRead && labelsToRead.length > 0) {
resultCards = records.filter(r => labelsToRead.includes(r.label || ''));
resultCards = records.filter((r) =>
labelsToRead.includes(r.label || ""),
);
} else {
resultCards = records;
}
@@ -220,20 +194,26 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
success: true,
message: `成功读取 ${resultCards.length} 张卡牌`,
cards: resultCards,
count: resultCards.length
count: resultCards.length,
};
}
case 'update': {
const labelsToUpdate = Array.isArray(label) ? label : (label ? [label] : null);
const updateCards = Array.isArray(cards) ? cards : (cards ? [cards] : []);
case "update": {
const labelsToUpdate = Array.isArray(label)
? label
: label
? [label]
: null;
const updateCards = Array.isArray(cards) ? cards : cards ? [cards] : [];
let updatedCount = 0;
if (labelsToUpdate && labelsToUpdate.length > 0) {
// 按 label 更新
for (const updateCard of updateCards) {
const targetLabel = updateCard.label || labelsToUpdate[updatedCount % labelsToUpdate.length];
const index = records.findIndex(r => r.label === targetLabel);
const targetLabel =
updateCard.label ||
labelsToUpdate[updatedCount % labelsToUpdate.length];
const index = records.findIndex((r) => r.label === targetLabel);
if (index !== -1) {
records[index] = { ...records[index], ...updateCard };
updatedCount++;
@@ -243,7 +223,9 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
// 按 cards 中的 label 更新
for (const updateCard of updateCards) {
if (updateCard.label) {
const index = records.findIndex(r => r.label === updateCard.label);
const index = records.findIndex(
(r) => r.label === updateCard.label,
);
if (index !== -1) {
records[index] = { ...records[index], ...updateCard };
updatedCount++;
@@ -257,23 +239,29 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
success: true,
message: `成功更新 ${updatedCount} 张卡牌`,
cards: updateCards,
count: updatedCount
count: updatedCount,
};
}
case 'delete': {
const labelsToDelete = Array.isArray(label) ? label : (label ? [label] : null);
case "delete": {
const labelsToDelete = Array.isArray(label)
? label
: label
? [label]
: null;
let deletedCount = 0;
if (labelsToDelete && labelsToDelete.length > 0) {
const beforeCount = records.length;
records = records.filter(r => !labelsToDelete.includes(r.label || ''));
records = records.filter(
(r) => !labelsToDelete.includes(r.label || ""),
);
deletedCount = beforeCount - records.length;
} else if (cards) {
const cardsToDelete = Array.isArray(cards) ? cards : [cards];
const beforeCount = records.length;
records = records.filter(r =>
!cardsToDelete.some(c => c.label && r.label === c.label)
records = records.filter(
(r) => !cardsToDelete.some((c) => c.label && r.label === c.label),
);
deletedCount = beforeCount - records.length;
}
@@ -282,20 +270,20 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
return {
success: true,
message: `成功删除 ${deletedCount} 张卡牌`,
count: deletedCount
count: deletedCount,
};
}
default:
return {
success: false,
message: `未知操作:${action}`
message: `未知操作:${action}`,
};
}
} catch (error) {
return {
success: false,
message: `操作失败:${error instanceof Error ? error.message : '未知错误'}`
message: `操作失败:${error instanceof Error ? error.message : "未知错误"}`,
};
}
}
+134
View File
@@ -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");
}
-217
View File
@@ -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 : '未知错误'}`
};
}
}
+12 -32
View File
@@ -1,5 +1,5 @@
import { readFileSync, existsSync } from 'fs';
import yaml from 'js-yaml';
import { readFileSync, existsSync } from "fs";
import { parseFrontMatter } from "./shared.js";
/**
* 读取 CSV frontmatter 的参数
@@ -52,7 +52,7 @@ export interface CardFieldStyle {
/**
* 朝向:上侧朝向 "n" | "w" | "s" | "e"(北/西/南/东)
*/
up?: 'n' | 'w' | 's' | 'e';
up?: "n" | "w" | "s" | "e";
}
/**
@@ -74,7 +74,7 @@ export interface DeckConfig {
grid?: string;
bleed?: number;
padding?: number;
shape?: 'rectangle' | 'circle' | 'hex' | 'diamond';
shape?: "rectangle" | "circle" | "hex" | "diamond";
layers?: string;
back_layers?: string;
[key: string]: unknown;
@@ -89,45 +89,25 @@ export interface ReadFrontmatterResult {
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
*/
export function readFrontmatter(params: ReadFrontmatterParams): ReadFrontmatterResult {
export function readFrontmatter(
params: ReadFrontmatterParams,
): ReadFrontmatterResult {
const { csv_file } = params;
// 检查文件是否存在
if (!existsSync(csv_file)) {
return {
success: false,
message: `文件不存在:${csv_file}`
message: `文件不存在:${csv_file}`,
};
}
try {
// 读取文件内容
const content = readFileSync(csv_file, 'utf-8');
const content = readFileSync(csv_file, "utf-8");
// 解析 frontmatter
const { frontmatter } = parseFrontMatter(content);
@@ -136,19 +116,19 @@ export function readFrontmatter(params: ReadFrontmatterParams): ReadFrontmatterR
return {
success: true,
frontmatter: {},
message: `文件 ${csv_file} 没有 frontmatter,返回空对象`
message: `文件 ${csv_file} 没有 frontmatter,返回空对象`,
};
}
return {
success: true,
frontmatter,
message: `成功读取 ${csv_file} 的 frontmatter`
message: `成功读取 ${csv_file} 的 frontmatter`,
};
} catch (error) {
return {
success: false,
message: `读取失败:${error instanceof Error ? error.message : '未知错误'}`
message: `读取失败:${error instanceof Error ? error.message : "未知错误"}`,
};
}
}
+48
View File
@@ -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`;
}
+20 -53
View File
@@ -1,6 +1,6 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import yaml from 'js-yaml';
import type { DeckFrontmatter } from './read-frontmatter.js';
import { readFileSync, writeFileSync, existsSync } from "fs";
import type { DeckFrontmatter } from "./read-frontmatter.js";
import { parseFrontMatter, serializeFrontMatter } from "./shared.js";
/**
* 写入 CSV frontmatter 的参数
@@ -29,70 +29,37 @@ export interface WriteFrontmatterResult {
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
*/
export function writeFrontmatter(params: WriteFrontmatterParams): WriteFrontmatterResult {
export function writeFrontmatter(
params: WriteFrontmatterParams,
): WriteFrontmatterResult {
const { csv_file, frontmatter, merge = true } = params;
let csvContent = '';
let csvContent = "";
let existingFrontmatter: DeckFrontmatter | undefined;
// 如果文件存在且需要合并,先读取现有内容
if (merge && existsSync(csv_file)) {
try {
const content = readFileSync(csv_file, 'utf-8');
const content = readFileSync(csv_file, "utf-8");
const result = parseFrontMatter(content);
existingFrontmatter = result.frontmatter;
csvContent = result.csvContent;
} catch (error) {
return {
success: false,
message: `读取现有文件失败:${error instanceof Error ? error.message : '未知错误'}`
message: `读取现有文件失败:${error instanceof Error ? error.message : "未知错误"}`,
};
}
}
// 合并或替换 frontmatter
const finalFrontmatter = merge && existingFrontmatter
? { ...existingFrontmatter, ...frontmatter }
: frontmatter;
const finalFrontmatter =
merge && existingFrontmatter
? { ...existingFrontmatter, ...frontmatter }
: frontmatter;
// 序列化 frontmatter
const frontmatterStr = serializeFrontMatter(finalFrontmatter);
@@ -100,32 +67,32 @@ export function writeFrontmatter(params: WriteFrontmatterParams): WriteFrontmatt
// 如果文件不存在或没有 CSV 内容,创建一个空的 CSV 内容
if (!csvContent.trim()) {
// 从 frontmatter 推断 CSV 表头
const headers = ['label'];
const headers = ["label"];
if (finalFrontmatter.fields && Array.isArray(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('body');
csvContent = headers.join(',') + '\n';
headers.push("body");
csvContent = headers.join(",") + "\n";
}
// 写入文件
try {
const fullContent = frontmatterStr + csvContent;
writeFileSync(csv_file, fullContent, 'utf-8');
writeFileSync(csv_file, fullContent, "utf-8");
return {
success: true,
message: `成功写入 frontmatter 到 ${csv_file}`,
csv_file
csv_file,
};
} catch (error) {
return {
success: false,
message: `写入失败:${error instanceof Error ? error.message : '未知错误'}`
message: `写入失败:${error instanceof Error ? error.message : "未知错误"}`,
};
}
}
+74 -160
View File
@@ -1,5 +1,7 @@
import { writeFileSync, mkdirSync, existsSync } from 'fs';
import { join } from 'path';
import { writeFileSync, mkdirSync, existsSync } from "fs";
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"(北/西/南/东)
*/
up?: 'n' | 'w' | 's' | 'e';
up?: "n" | "w" | "s" | "e";
}
/**
@@ -45,18 +47,7 @@ export interface CardTemplate {
examples?: Record<string, string>[];
}
/**
* Deck 配置
*/
export interface DeckConfig {
size?: string;
grid?: string;
bleed?: number;
padding?: number;
shape?: 'rectangle' | 'circle' | 'hex' | 'diamond';
layers?: string;
backLayers?: string;
}
export type { DeckConfig };
/**
* 生成卡牌组的参数
@@ -73,31 +64,31 @@ export interface GenerateCardDeckParams {
/**
* 生成卡牌数据 CSV
*/
function generateCardCSV(
template: CardTemplate,
cardCount: number
): string {
function generateCardCSV(template: CardTemplate, cardCount: number): string {
const fields = template.fields;
// 构建 CSV 表头
const headers = ['label', ...fields.map(f => f.name), 'body'];
const headers = ["label", ...fields.map((f) => f.name), "body"];
// 生成示例数据
const rows: string[][] = [];
const examples = template.examples || [];
for (let i = 0; i < cardCount; i++) {
const row: string[] = [(i + 1).toString()];
// 为每个字段生成值
for (const field of fields) {
let value = '';
let value = "";
if (examples.length > 0) {
// 从示例中循环取值
const exampleIndex = i % examples.length;
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) {
// 从字段的示例中取值
value = field.examples[i % field.examples.length];
@@ -105,131 +96,49 @@ function generateCardCSV(
// 默认占位符
value = `{{${field.name}_${i + 1}}}`;
}
row.push(value);
}
// body 列使用模板语法
const bodyParts: string[] = [];
for (const field of fields) {
bodyParts.push(`**${field.name}:** {{${field.name}}}`);
}
row.push(bodyParts.join('\n\n'));
row.push(bodyParts.join("\n\n"));
rows.push(row);
}
// 组合 CSV 内容
const csvLines = [headers.join(',')];
const csvLines = [headers.join(",")];
for (const row of rows) {
csvLines.push(row.map(cell => {
// 处理包含逗号或换行的单元格
if (cell.includes(',') || cell.includes('\n') || cell.includes('"')) {
return `"${cell.replace(/"/g, '""')}"`;
}
return cell;
}).join(','));
csvLines.push(
row
.map((cell) => {
// 处理包含逗号或换行的单元格
if (cell.includes(",") || cell.includes("\n") || cell.includes('"')) {
return `"${cell.replace(/"/g, '""')}"`;
}
return cell;
})
.join(","),
);
}
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('');
return csvLines.join("\n");
}
/**
* 自动生成图层配置
*/
function autoGenerateLayers(fields: CardField[]): string {
if (fields.length === 0) return '';
if (fields.length === 0) return "";
const layers: string[] = [];
const totalHeight = 8;
const heightPerField = Math.floor((totalHeight - 2) / fields.length);
for (let i = 0; i < fields.length; i++) {
const field = fields[i];
const y1 = 2 + i * heightPerField;
@@ -237,8 +146,8 @@ function autoGenerateLayers(fields: CardField[]): string {
const fontSize = Math.min(12, Math.floor(80 / fields.length));
layers.push(`${field.name}:1,${y1}-${y2},${fontSize}`);
}
return layers.join(' ');
return layers.join(" ");
}
/**
@@ -256,61 +165,66 @@ export function generateCardDeck(params: GenerateCardDeckParams): {
card_count = 10,
card_template,
deck_config = {},
description
description,
} = params;
// 确保输出目录存在
if (!existsSync(output_dir)) {
mkdirSync(output_dir, { recursive: true });
}
// 生成文件名
const safeName = deck_name.toLowerCase().replace(/\s+/g, '-');
const safeName = deck_name.toLowerCase().replace(/\s+/g, "-");
const csvFileName = `${safeName}.csv`;
const mdFileName = `${safeName}.md`;
// 创建默认模板(如果没有提供)
const template: CardTemplate = card_template || {
fields: [
{ name: 'name', description: '卡牌名称', examples: ['示例卡牌 1', '示例卡牌 2'] },
{ name: 'type', description: '卡牌类型', examples: ['物品', '法术'] },
{ name: 'cost', description: '费用', examples: ['1', '2'] },
{ name: 'description', description: '效果描述', examples: ['这是一个效果描述', '这是另一个效果'] }
]
{
name: "name",
description: "卡牌名称",
examples: ["示例卡牌 1", "示例卡牌 2"],
},
{ name: "type", description: "卡牌类型", examples: ["物品", "法术"] },
{ name: "cost", description: "费用", examples: ["1", "2"] },
{
name: "description",
description: "效果描述",
examples: ["这是一个效果描述", "这是另一个效果"],
},
],
};
// 创建默认配置(如果没有提供)
const config: DeckConfig = {
size: deck_config.size || '54x86',
grid: deck_config.grid || '5x8',
size: deck_config.size || "54x86",
grid: deck_config.grid || "5x8",
bleed: deck_config.bleed ?? 1,
padding: deck_config.padding ?? 2,
shape: deck_config.shape || 'rectangle',
layers: deck_config.layers || autoGenerateLayers(template.fields)
shape: deck_config.shape || "rectangle",
layers: deck_config.layers || autoGenerateLayers(template.fields),
};
// 生成 CSV 内容
const csvContent = generateCardCSV(template, card_count);
const csvPath = join(output_dir, csvFileName);
writeFileSync(csvPath, csvContent, 'utf-8');
writeFileSync(csvPath, csvContent, "utf-8");
// 生成 Markdown 内容
const mdContent = generateDeckMarkdown(
const deckComponent = buildDeckComponent(`./${csvFileName}`, config);
const mdContent = generateDeckPreviewMd(
deck_name,
`./${csvFileName}`,
config,
description
deckComponent,
description,
);
const mdPath = join(output_dir, mdFileName);
writeFileSync(mdPath, mdContent, 'utf-8');
// 构建完整的 deck 组件代码
const deckComponent = buildDeckComponent(`./${csvFileName}`, config);
writeFileSync(mdPath, mdContent, "utf-8");
return {
mdFile: mdPath,
csvFile: csvPath,
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
View File
@@ -1,8 +1,13 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { dirname, join, basename, extname, relative } from 'path';
import { execSync } from 'child_process';
import yaml from 'js-yaml';
import type { DeckFrontmatter, DeckConfig } from './frontmatter/read-frontmatter.js';
import { readFileSync, writeFileSync, existsSync } from "fs";
import { relative } from "path";
import { execSync } from "child_process";
import {
buildDeckComponent,
titleFromCsvPath,
defaultMdPath,
readDeckConfig,
generateDeckPreviewMd,
} from "./deck-utils.js";
/**
* 预览 Deck 的参数
@@ -37,88 +42,35 @@ export interface PreviewDeckResult {
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) {
try {
// Windows 使用 start 命令
if (process.platform === 'win32') {
execSync(`start "" "${url}"`, { stdio: 'ignore' });
} else if (process.platform === 'darwin') {
execSync(`open "${url}"`, { stdio: 'ignore' });
if (process.platform === "win32") {
execSync(`start "" "${url}"`, { stdio: "ignore" });
} else if (process.platform === "darwin") {
execSync(`open "${url}"`, { stdio: "ignore" });
} else {
execSync(`xdg-open "${url}"`, { stdio: 'ignore' });
execSync(`xdg-open "${url}"`, { stdio: "ignore" });
}
console.error(`已打开浏览器:${url}`);
} 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 并打开浏览器
*/
@@ -129,108 +81,61 @@ export function previewDeck(params: PreviewDeckParams): PreviewDeckResult {
if (!existsSync(csv_file)) {
return {
success: false,
message: `CSV 文件不存在:${csv_file}`
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 mdFilePath = md_file || defaultMdPath(csv_file);
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)}]`)) {
// 仍然打开浏览器
const relativeMdPath = relative(process.cwd(), mdFilePath).split('\\').join('/');
const previewUrl = `http://localhost:3000/${relativeMdPath}`;
const existingContent = readFileSync(mdFilePath, "utf-8");
if (
existingContent.includes(`:md-deck[${csv_file}]`) ||
existingContent.includes(`:md-deck[./${csv_file.split("/").pop()}]`)
) {
const previewUrl = computePreviewUrl(mdFilePath);
openBrowser(previewUrl);
return {
success: true,
message: `预览文件 ${mdFilePath} 已存在`,
md_file: mdFilePath,
created: false,
preview_url: previewUrl
preview_url: previewUrl,
};
}
} catch (error) {
} catch {
// 读取失败,继续创建
}
}
// 读取 CSV 的 frontmatter 获取配置
let deckConfig: DeckConfig | undefined;
try {
const csvContent = readFileSync(csv_file, 'utf-8');
const { frontmatter } = parseFrontMatter(csvContent);
deckConfig = frontmatter?.deck;
} catch (error) {
// 忽略错误,使用默认配置
}
const deckConfig = readDeckConfig(csv_file);
// 确定标题
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());
}
const deckTitle = title || titleFromCsvPath(csv_file);
// 构建 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);
// 构建 md-deck 组件代码
const deckComponent = buildDeckComponent(
`./${csv_file.split("/").pop()}`,
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('');
const mdContent = generateDeckPreviewMd(
deckTitle,
deckComponent,
description,
);
// 写入文件
try {
writeFileSync(mdFilePath, mdLines.join('\n'), 'utf-8');
writeFileSync(mdFilePath, mdContent, "utf-8");
// 计算预览 URL
const relativeMdPath = relative(process.cwd(), mdFilePath).split('\\').join('/');
const previewUrl = `http://localhost:3000/${relativeMdPath.slice(0, -3)}`;
// 打开浏览器
const previewUrl = computePreviewUrl(mdFilePath);
openBrowser(previewUrl);
return {
@@ -240,12 +145,12 @@ export function previewDeck(params: PreviewDeckParams): PreviewDeckResult {
: `更新预览文件 ${mdFilePath}`,
md_file: mdFilePath,
created,
preview_url: previewUrl
preview_url: previewUrl,
};
} catch (error) {
return {
success: false,
message: `写入失败:${error instanceof Error ? error.message : '未知错误'}`
message: `写入失败:${error instanceof Error ? error.message : "未知错误"}`,
};
}
}
+62 -23
View File
@@ -1,27 +1,40 @@
import {
Component,
ParentProps,
createContext,
useContext,
createEffect,
onCleanup,
Show,
createResource,
createMemo,
createSignal,
} from "solid-js";
import { parseMarkdown } from "../markdown";
import { extractSection } from "../data-loader";
import mermaid from "mermaid";
import { getIndexedData } from "../data-loader/file-index";
import { resolvePath } from "./utils/path";
import { useLocation } from "@solidjs/router";
import { useNavigateWithParams } from "./useNavigateWithParams";
export interface ArticleProps {
src: string;
section?: string; // 指定要显示的标题(不含 #
iconPath?: string; // 图标路径前缀,默认为 "./assets",空字符串表示禁用
onLoaded?: () => void;
onError?: (error: Error) => void;
class?: string; // 额外的 class 用于样式控制
scrollToHash?: boolean; // 是否自动滚动到 hash
onDom?: (dom: HTMLDivElement) => void;
}
// ---------------------------------------------------------------------------
// 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: {
@@ -59,19 +72,13 @@ function scrollToHash(hash: string) {
* Article 组件
* 用于将特定 src 位置的 md 文件显示为 markdown 文章
*/
export const Article: Component<ArticleProps> = (props) => {
const location = useLocation();
export const Article: Component<ArticleProps & ParentProps> = (props) => {
const navigate = useNavigateWithParams();
const [content, { refetch }] = createResource(
() => ({ src: props.src, section: props.section }),
fetchArticleContent,
);
let innerDiv!: HTMLDivElement;
// 解析 iconPath,默认为 "./assets",空字符串表示禁用
const iconPrefix = createMemo(() => {
if (props.iconPath === "") return undefined; // 空字符串禁用图标前缀
return resolvePath(props.src, props.iconPath ?? "./assets");
});
const [contentDom, setContentDom] = createSignal<HTMLDivElement>();
createEffect(() => {
const data = content();
@@ -81,19 +88,48 @@ export const Article: Component<ArticleProps> = (props) => {
void mermaid.run();
// 内容渲染后检查 hash 并滚动
scrollToHash(location.hash);
props.onDom?.(innerDiv);
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(() => {
// 清理时清空内容,触发内部组件的销毁
});
return (
<article
class={`prose text-black ${props.class || ""}`}
class={`prose ${props.class || ""}`}
data-src={props.src}
>
<Show when={content.loading}>
@@ -103,11 +139,14 @@ export const Article: Component<ArticleProps> = (props) => {
<div class="text-red-500">{content.error?.message}</div>
</Show>
<Show when={!content.loading && !content.error && content()}>
<div
class="relative"
ref={innerDiv}
innerHTML={parseMarkdown(content()!, iconPrefix())}
/>
<ArticleDomCtx.Provider value={contentDom}>
<div
class="relative"
ref={setContentDom}
innerHTML={parseMarkdown(content()!, props.src)}
/>
{props.children}
</ArticleDomCtx.Provider>
</Show>
</article>
);
+58
View File
@@ -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;
+83 -70
View File
@@ -7,6 +7,10 @@ import {
onMount,
} from "solid-js";
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 {
isOpen: boolean;
@@ -14,17 +18,42 @@ export interface DocDialogProps {
}
const DocDialog: Component<DocDialogProps> = (props) => {
const [docSet, setDocSet] = createSignal<DocSet>("directives");
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 = () =>
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) => {
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(() => {
document.addEventListener("keydown", handleKeyDown);
// Close dropdown on outside click
document.addEventListener("click", (e) => {
if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
setDropdownOpen(false);
}
});
});
onCleanup(() => {
@@ -34,14 +63,54 @@ const DocDialog: Component<DocDialogProps> = (props) => {
return (
<Show when={props.isOpen}>
<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) => {
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="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
onClick={props.onClose}
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">
<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) => (
<button
onClick={() => setSelectedTag(entry.tag)}
@@ -64,7 +133,12 @@ const DocDialog: Component<DocDialogProps> = (props) => {
}`}
>
<span class="mr-2">{entry.icon}</span>
<span class="font-mono text-xs">{`:${entry.tag}`}</span>
<Show
when={docSet() === "directives"}
fallback={<span class="text-xs">{entry.title}</span>}
>
<span class="font-mono text-xs">{`:${entry.tag}`}</span>
</Show>
</button>
)}
</For>
@@ -83,78 +157,17 @@ const DocDialog: Component<DocDialogProps> = (props) => {
};
/** Single entry documentation content */
const DocContent: Component<{ entry: DocEntry }> = (props) => {
const DocContent: Component<{ entry: AnyEntry }> = (props) => {
const e = props.entry;
return (
<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}
<code class="ml-3 text-base font-mono bg-gray-100 px-2 py-0.5 rounded text-blue-700">
:{e.tag}
</code>
</h2>
<p class="text-gray-600 mb-6">{e.description}</p>
<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}
</div>
<div class="text-sm leading-relaxed whitespace-pre-wrap text-gray-800">
{e.body}
</div>
</div>
);
+39 -13
View File
@@ -1,6 +1,8 @@
import { Component, createMemo, createSignal, Show } from "solid-js";
import { useLocation } from "@solidjs/router";
import { type FileNode, type TocNode } from "../data-loader";
import { useNavigate } from "@solidjs/router";
import { useNavigateWithParams } from "./useNavigateWithParams";
import { useScrollContainer } from "../App";
/**
* 检查当前文件路径是否在文件夹内
@@ -22,7 +24,8 @@ export const FileTreeNode: Component<{
onClose: () => void;
isHidden?: (node: FileNode) => boolean;
}> = (props) => {
const navigate = useNavigate();
const navigate = useNavigateWithParams();
const location = useLocation();
const isDir = !!props.node.children;
const isActive = createMemo(() => props.currentPath === props.node.path);
// 默认收起,除非当前文件在该文件夹内
@@ -30,21 +33,28 @@ export const FileTreeNode: Component<{
isDir && isPathInDir(props.currentPath, props.node.path),
);
const handleClick = () => {
const href = () => props.node.path + location.search;
const handleClick = (e: MouseEvent) => {
if (isDir) {
e.preventDefault();
setIsExpanded(!isExpanded());
} else {
} else if (e.button === 0) {
// Left-click: use SPA navigation
e.preventDefault();
navigate(props.node.path);
props.onClose();
}
// Middle-click / right-click: let the browser handle the <a> natively
};
const indent = props.depth * 12;
return (
<div class={props.isHidden?.(props.node) === true ? "hidden" : ""}>
<div
class={`flex items-center py-1 px-2 cursor-pointer hover:bg-gray-100 rounded ${
<a
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"
}`}
style={{ "padding-left": `${indent + 8}px` }}
@@ -57,7 +67,7 @@ export const FileTreeNode: Component<{
<span class="mr-1 text-gray-400">📄</span>
</Show>
<span class="text-sm truncate">{props.node.name}</span>
</div>
</a>
<Show when={isDir && isExpanded() && props.node.children}>
<div>
{props.node.children!.map((child) => (
@@ -85,9 +95,10 @@ export const HeadingNode: Component<{
depth: number;
isHidden?: (node: TocNode) => boolean;
}> = (props) => {
const navigate = useNavigate();
const navigate = useNavigateWithParams();
const location = useLocation();
const anchor = props.node.id || "";
const href = `${props.basePath}#${anchor}`;
const href = () => `${props.basePath}${location.search}#${anchor}`;
const hasChildren = !!props.node.children;
// 默认收起,除非当前锚点在该节点内
const [isExpanded, setIsExpanded] = createSignal(props.depth <= 0);
@@ -98,16 +109,31 @@ export const HeadingNode: Component<{
setIsExpanded(!isExpanded());
}
};
const scrollContainer = useScrollContainer();
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(() => {
const element = document.getElementById(anchor);
if (element) {
const scroller = scrollContainer();
const navBarHeight = 80;
const elementPosition = element.getBoundingClientRect().top;
const offsetPosition = window.scrollY + elementPosition - navBarHeight;
window.scrollTo({ top: offsetPosition, behavior: "instant" });
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" });
}
}
});
};
@@ -129,7 +155,7 @@ export const HeadingNode: Component<{
</span>
</span>
<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"
onClick={handleClick}
>
+200
View File
@@ -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 -2
View File
@@ -158,7 +158,7 @@ export const MobileSidebar: Component<SidebarProps> = (props) => {
};
/**
*
* flex child, no fixed positioning
*/
export const DesktopSidebar: Component<{
fileTree?: FileNode[];
@@ -182,7 +182,7 @@ export const DesktopSidebar: Component<{
});
return (
<aside class="hidden md: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
fileTree={fileTree()}
pathHeadings={pathHeadings()}
+8 -14
View File
@@ -4,16 +4,14 @@ export interface DocEntry {
tag: string;
icon: string;
title: string;
description: string;
syntax: string;
props: { name: string; type: string; default?: string; desc: string }[];
/** Markdown body (everything after frontmatter ---) */
/** Full markdown body (everything after frontmatter ---) */
body: string;
}
/** Splits frontmatter and markdown body from a raw .md string. */
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;
try {
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 {
const match = raw.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/);
return match ? match[1] : raw;
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): DocEntry | null {
@@ -34,9 +33,6 @@ function parseEntry(raw: string): DocEntry | null {
tag: fm.tag as string,
icon: fm.icon 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),
};
}
@@ -53,8 +49,7 @@ import mdBorderRaw from "../doc-entries/md-border.md";
import mdEmbedRaw from "../doc-entries/md-embed.md";
import mdDeckRaw from "../doc-entries/md-deck.md";
import mdYarnRaw from "../doc-entries/md-yarn-spinner.md";
import mdTokenRaw from "../doc-entries/md-token.md";
import mdTokenViewerRaw from "../doc-entries/md-token-viewer.md";
import mdCommanderRaw from "../doc-entries/md-commander.md";
const rawDocuments: string[] = [
@@ -68,8 +63,7 @@ const rawDocuments: string[] = [
mdEmbedRaw,
mdDeckRaw,
mdYarnRaw,
mdTokenRaw,
mdTokenViewerRaw,
mdCommanderRaw,
];
+3 -3
View File
@@ -10,12 +10,13 @@ import "./md-embed";
import "./md-deck";
import "./md-commander/index";
import "./md-yarn-spinner";
import "./md-token";
import "./md-token-viewer";
// 导出组件
export { Article } from "./Article";
export type { ArticleProps } from "./Article";
export { RevealManager } from "./RevealManager";
export { CommandLinkManager } from "./CommandLinkManager";
export { ReactiveVariableManager } from "./journal/ReactiveVariableManager";
export { MobileSidebar, DesktopSidebar } from "./Sidebar";
export type { SidebarProps } from "./Sidebar";
export { FileTreeNode, HeadingNode } from "./FileTree";
@@ -32,7 +33,6 @@ export type { FontProps } from "./md-font";
export type { BorderProps } from "./md-border";
export type { EmbedProps } from "./md-embed";
export type { YarnSpinnerProps } from "./md-yarn-spinner";
export type { TokenProps } from "./md-token";
// 导出 md-commander 相关
export type {
+61
View File
@@ -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>
);
};
+1 -2
View File
@@ -5,7 +5,6 @@
import { Component, createSignal, For, Show, createMemo } from "solid-js";
import { registeredTypes, canEmit } from "./registry";
import { sendMessage, useJournalStream } from "../stores/journalStream";
import type { MessageTypeDef } from "./registry";
import { DynamicForm } from "./DynamicForm";
export const ComposePanel: Component = () => {
@@ -68,7 +67,7 @@ export const ComposePanel: Component = () => {
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() ? "..." : "Send"}
{sending() ? "..." : "发送"}
</button>
</div>
+123
View File
@@ -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>
);
};
+15 -11
View File
@@ -25,13 +25,17 @@ export const ConnectionBar: Component = () => {
const handleCreateSession = () => {
const name = newSessionName().trim();
if (!name) return;
createSession(name);
const id = createSession(name);
if (id) {
setSessionId(id);
hydrateFromServer(id).catch(() => {});
}
setNewSessionName("");
setShowNewSession(false);
};
const handleDeleteSession = (id: string) => {
if (confirm(`Delete session "${id}"? This cannot be undone.`)) {
if (confirm(`确定要删除会话 "${id}"?此操作不可撤销。`)) {
deleteSession(id);
}
};
@@ -41,7 +45,7 @@ export const ConnectionBar: Component = () => {
await hydrateFromServer(id);
setSessionId(id);
} catch (e) {
setError("Failed to load session");
setError("无法加载会话");
}
};
@@ -54,7 +58,7 @@ export const ConnectionBar: Component = () => {
{/* 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">connected</span>
<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}
@@ -62,12 +66,12 @@ export const ConnectionBar: Component = () => {
</Show>
<div class="flex-1" />
<Show when={stream.myName !== "gm"}>
<span class="text-xs text-gray-400">Playing as {stream.myName}</span>
<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="Disconnect"
title="断开连接"
>
</button>
@@ -77,12 +81,12 @@ export const ConnectionBar: Component = () => {
<Show when={stream.myName === "gm"}>
<div class="space-y-1">
<div class="flex items-center gap-1">
<span class="text-xs text-gray-500">Sessions:</span>
<span class="text-xs text-gray-500"></span>
<button
onClick={() => setShowNewSession((v) => !v)}
class="text-xs text-blue-600 hover:underline"
>
+ new
+
</button>
</div>
@@ -92,7 +96,7 @@ export const ConnectionBar: Component = () => {
type="text"
value={newSessionName()}
onInput={(e) => setNewSessionName(e.currentTarget.value)}
placeholder="Session name"
placeholder="会话名称"
class="flex-1 border border-gray-300 rounded px-2 py-0.5 text-xs"
onKeyDown={(e) => e.key === "Enter" && handleCreateSession()}
/>
@@ -100,7 +104,7 @@ export const ConnectionBar: Component = () => {
onClick={handleCreateSession}
class="bg-green-600 text-white rounded px-2 py-0.5 text-xs hover:bg-green-700"
>
Create
Create Create
</button>
</div>
</Show>
@@ -126,7 +130,7 @@ export const ConnectionBar: Component = () => {
handleDeleteSession(id);
}}
class="text-gray-400 hover:text-red-500 text-xs"
title="Delete session"
title="删除会话"
>
</button>
@@ -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>
);
};
+4 -8
View File
@@ -9,7 +9,6 @@
import { Component, For, Show, createMemo } from "solid-js";
import { z } from "zod";
import { getMessageType } from "./registry";
import type { MessageTypeDef } from "./registry";
type FormData = Record<string, unknown>;
@@ -22,9 +21,6 @@ interface DynamicFormProps {
export const DynamicForm: Component<DynamicFormProps> = (props) => {
const def = createMemo(() => getMessageType(props.type));
// Generate default on type change
const schema = createMemo(() => def()?.schema);
return (
<div class="space-y-1.5">
<Show when={def()}>
@@ -53,7 +49,7 @@ const SchemaFields: Component<{
const shape = unwrapShape(props.schema);
if (!shape) {
return <p class="text-xs text-gray-400">No fields</p>;
return <p class="text-xs text-gray-400"></p>;
}
return (
@@ -98,7 +94,7 @@ const SchemaFields: Component<{
checked={isSet()}
onChange={toggleOptional}
class="mt-1.5"
title={`Include ${key}`}
title={`包含 ${key}`}
/>
</Show>
<div class={`flex-1 ${isOptional && !isSet() ? "opacity-40" : ""}`}>
@@ -136,7 +132,7 @@ const FieldInput: Component<{
return (
<div class="flex items-center gap-1">
<label
class="text-xs text-gray-500 w-20 flex-shrink-0 truncate"
class="text-xs text-gray-500 w-20 shrink-0 truncate"
title={props.label}
>
{props.label}
@@ -204,7 +200,7 @@ const FieldInput: Component<{
}}
disabled={props.disabled}
class={inputClass}
placeholder="comma-separated"
placeholder="逗号分隔"
/>
</Show>
</div>
+30
View File
@@ -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>
);
};
+90
View File
@@ -0,0 +1,90 @@
/**
* InviteDialog input player name, copy invite link
*/
import { Component, createSignal } from "solid-js";
import { useJournalStream } from "../stores/journalStream";
interface InviteDialogProps {
onClose: () => void;
}
export const InviteDialog: Component<InviteDialogProps> = (props) => {
const stream = useJournalStream();
const [playerName, setPlayerName] = createSignal("");
const [copied, setCopied] = createSignal(false);
const inviteLink = () => {
const url = new URL(window.location.href);
url.searchParams.set("session", stream.sessionId || "default");
if (playerName().trim()) {
url.searchParams.set("player", playerName().trim());
}
url.searchParams.set("autojoin", "1");
return url.toString();
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(inviteLink());
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
const input = document.getElementById(
"invite-link-input",
) as HTMLInputElement;
if (input) input.select();
}
};
return (
<div
class="absolute inset-0 z-50 flex items-center justify-center bg-black/20"
onClick={props.onClose}
>
<div
class="bg-white rounded-lg border border-gray-200 shadow-xl w-[280px] p-4 space-y-3"
onClick={(e) => e.stopPropagation()}
>
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-800"></h3>
<button
onClick={props.onClose}
class="text-gray-400 hover:text-gray-600 text-sm"
>
</button>
</div>
<p class="text-xs text-gray-500">
</p>
<input
type="text"
value={playerName()}
onInput={(e) => setPlayerName(e.currentTarget.value)}
placeholder="玩家名字"
class="w-full border border-gray-300 rounded px-2 py-1 text-xs"
autofocus
/>
<div class="flex gap-1">
<input
id="invite-link-input"
type="text"
value={inviteLink()}
readonly
class="flex-1 border border-gray-200 rounded px-2 py-1 text-xs bg-gray-50 text-gray-600 font-mono truncate"
/>
<button
onClick={handleCopy}
class={`shrink-0 rounded px-2 py-1 text-xs font-medium transition-colors ${
copied()
? "bg-green-100 text-green-700"
: "bg-blue-600 text-white hover:bg-blue-700"
}`}
>
{copied() ? "已复制!" : "复制"}
</button>
</div>
</div>
</div>
);
};
+18
View File
@@ -0,0 +1,18 @@
/**
* Journal context provides `onClose` to message renderers so that
* clicking a link message can close the panel on mobile.
*/
import { createContext, useContext } from "solid-js";
export interface JournalContextValue {
onClose: () => void;
}
const JournalContext = createContext<JournalContextValue>();
export function useJournalContext(): JournalContextValue | undefined {
return useContext(JournalContext);
}
export { JournalContext };
+80
View File
@@ -0,0 +1,80 @@
/**
* JournalHeader status dot, player name, session dropdown, disconnect & close
*/
import { Component, Show } from "solid-js";
import { useJournalStream, disconnectStream } from "../stores/journalStream";
import { SessionDropdown } from "./SessionDropdown";
interface JournalHeaderProps {
onClose: () => void;
onCreateSession: () => void;
}
export const JournalHeader: Component<JournalHeaderProps> = (props) => {
const stream = useJournalStream();
const handleDisconnect = () => disconnectStream();
return (
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 shrink-0">
<div class="flex items-center gap-2 min-w-0">
<span
class="w-2 h-2 rounded-full shrink-0"
classList={{
"bg-gray-400": stream.connectionStatus === "disconnected",
"bg-yellow-400 animate-pulse":
stream.connectionStatus === "connecting",
"bg-green-500": stream.connectionStatus === "connected",
"bg-red-500": stream.connectionStatus === "error",
}}
title={stream.connectionStatus}
/>
<Show
when={stream.connected && stream.sessionId}
fallback={
<h2 class="text-sm font-semibold text-gray-700 truncate"></h2>
}
>
<div class="min-w-0">
<p class="text-sm font-semibold text-gray-700 truncate leading-tight flex items-center gap-1">
{stream.myName}
<Show when={stream.myRole !== "gm"}>
<span
class={`text-[10px] px-1 rounded ${
stream.myRole === "observer"
? "bg-gray-200 text-gray-500"
: "bg-blue-100 text-blue-600"
}`}
>
{stream.myRole}
</span>
</Show>
</p>
{/* Clickable subtitle — session dropdown (GM only) */}
<Show when={stream.myRole === "gm"}>
<SessionDropdown onCreate={props.onCreateSession} />
</Show>
</div>
</Show>
</div>
<div class="flex items-center gap-1 shrink-0">
<Show when={stream.connected}>
<button
onClick={handleDisconnect}
class="text-xs text-gray-400 hover:text-red-500 px-1"
title="断开连接"
>
</button>
</Show>
<button
onClick={props.onClose}
class="text-gray-400 hover:text-gray-600 text-sm px-1"
title="隐藏面板"
>
</button>
</div>
</div>
);
};
+123 -285
View File
@@ -5,65 +5,22 @@
* - Plain text "chat" type
* - `/roll 3d6kh1` "roll" type (result resolved client-side by GM)
* - `/link path#section` "link" type
* - `/` alone opens completions dropdown (populated from /__COMPLETIONS.json
* in CLI mode, or client-side scan of the in-memory file index in dev mode)
* - `/stat set key=value` "stat" type
* - `/` alone opens completions dropdown
*
* Delegates command dispatch to the shared command-dispatcher module.
*/
import {
Component,
createSignal,
createEffect,
onMount,
onCleanup,
Show,
For,
Switch,
Match,
} from "solid-js";
import { Component, createSignal, createEffect, onMount, Show } from "solid-js";
import { sendMessage, useJournalStream } from "../stores/journalStream";
import { linkPrefill, setLinkPrefill } from "../stores/reveal";
import { actionPrefill, setActionPrefill } from "../stores/reveal";
import { useJournalCompletions, ensureCompletions } from "./completions";
import { resolveRollPayload } from "./types/roll";
// ---- Helpers ----
interface CompletionItem {
label: string;
kind: "command" | "value" | "no-results";
insertText: string;
}
interface ParsedInput {
type: "chat" | "roll" | "link";
payload: Record<string, unknown>;
error?: string;
}
function parseInput(raw: string): ParsedInput {
if (raw.startsWith("/roll ")) {
const notation = raw.slice("/roll ".length).trim();
if (!notation)
return { type: "roll", payload: {}, error: "Dice notation required" };
return { type: "roll", payload: { notation, label: notation } };
}
if (raw.startsWith("/link ")) {
const arg = raw.slice("/link ".length).trim();
if (!arg) return { type: "link", payload: {}, error: "Path required" };
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 } };
}
// /roll or /link with no space — need to complete, don't send
if (raw === "/roll" || raw === "/link") {
return { type: "chat", payload: {}, error: "Complete the command" };
}
return { type: "chat", payload: { text: raw } };
}
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 ----
@@ -76,170 +33,118 @@ export const JournalInput: Component = () => {
const isGm = () => stream.myRole === "gm";
const [text, setText] = createSignal("");
const [error, setError] = createSignal<string | null>(null);
const [showErrorPopup, setShowErrorPopup] = createSignal(false);
const [sending, setSending] = createSignal(false);
const [showCompletions, setShowCompletions] = createSignal(false);
const [selectedIdx, setSelectedIdx] = createSignal(0);
let textareaRef!: HTMLTextAreaElement;
let completionsRef!: HTMLDivElement;
// Ensure completions are loading on mount
onMount(() => {
void ensureCompletions();
});
// Listen for /link prefill requests from article headings
// Listen for action prefill requests from article buttons
createEffect(() => {
const prefilled = linkPrefill();
if (prefilled) {
setText(prefilled);
setLinkPrefill(null);
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 ----
function handleSend() {
async function handleSend() {
const raw = text().trim();
if (!raw) return;
// Players / observers: everything is plain chat
if (isPlayer() || isObserver()) {
// Observers: everything is plain chat
if (isObserver()) {
const result = sendMessage("chat", { text: raw });
if (!result.success) {
setError(result.error);
} else {
setText("");
}
textareaRef?.focus();
const r = unwrapSendResult(result);
finish(r.ok, r.error);
return;
}
const parsed = parseInput(raw);
if (parsed.error) {
setError(parsed.error);
setDispatchError(parsed.error);
return;
}
setError(null);
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);
// GM roll: resolve the dice result locally
if (parsed.type === "roll") {
const p = resolveRollPayload(
parsed.payload as { notation: string; label?: string },
);
const result = sendMessage("roll", p);
if (!result.success) {
setError(result.error);
} else {
setText("");
}
setSending(false);
textareaRef?.focus();
return;
}
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,
});
const result = sendMessage(parsed.type, parsed.payload);
if (!result.success) {
setError(result.error);
} else {
// 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();
}
// ---- Completions ----
// ---- Scroll selected completion into view ----
createEffect(() => {
const idx = selectedIdx();
if (!showCompletions() || !completionsRef) return;
const el = completionsRef.querySelector(`[data-comp-idx="${idx}"]`);
if (el) el.scrollIntoView({ block: "nearest" });
});
// ---- Completions ----
function buildCompletions(): CompletionItem[] {
const raw = text().trim();
// Only GM gets completions
if (!isGm()) return [];
const data = comp.data;
const commands = [
{ label: "/roll", kind: "command" as const, insertText: "/roll " },
{ label: "/link", kind: "command" as const, insertText: "/link " },
];
// Show commands when user types / or starts typing a command name
if (raw.startsWith("/") && !raw.includes(" ")) {
const prefix = raw.toLowerCase();
const matches = commands.filter((c) =>
c.label.toLowerCase().startsWith(prefix),
);
return matches.length > 0
? matches
: [{ label: "Unknown command", kind: "no-results", insertText: "" }];
/** 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);
}
// After /roll — show dice suggestions
if (raw.startsWith("/roll ")) {
const prefix = raw.slice("/roll ".length).toLowerCase();
const matches = data.dice
.filter(
(d) =>
d.notation.toLowerCase().includes(prefix) ||
d.label.toLowerCase().includes(prefix),
)
.slice(0, 8);
if (matches.length === 0) {
return [{ label: "No dice found", kind: "no-results", insertText: "" }];
}
return matches.map((d) => ({
label: d.notation,
kind: "value" as const,
insertText: "/roll " + d.notation,
}));
}
// 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: "No links found", 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,
};
});
}
return [];
setSending(false);
textareaRef?.focus();
}
function currentCompletions(): CompletionItem[] {
return buildCompletions();
/** 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);
@@ -248,7 +153,7 @@ export const JournalInput: Component = () => {
}
function selectCompletion(dir: "up" | "down") {
const comps = currentCompletions();
const comps = completions();
if (comps.length === 0) return;
setSelectedIdx((prev) => {
if (dir === "down") return (prev + 1) % comps.length;
@@ -256,14 +161,17 @@ export const JournalInput: Component = () => {
});
}
function openCompletions() {
setShowCompletions(true);
setSelectedIdx(0);
}
// ---- Keyboard ----
function handleKeyDown(e: KeyboardEvent) {
const comps = currentCompletions();
const comps = completions();
const open = showCompletions();
// When opening completions with Tab, if the text matches a command prefix
// and there are options, accept the first. Otherwise just show.
if (e.key === "Tab") {
if (open) {
e.preventDefault();
@@ -272,12 +180,10 @@ export const JournalInput: Component = () => {
}
return;
}
// If not open and starts with /, open completions (GM only)
const raw = text();
if (isGm() && raw.startsWith("/")) {
if ((isGm() || raw.startsWith("/set")) && raw.startsWith("/")) {
e.preventDefault();
setShowCompletions(true);
setSelectedIdx(0);
openCompletions();
return;
}
return;
@@ -294,9 +200,9 @@ export const JournalInput: Component = () => {
selectCompletion("up");
return;
}
if (e.key === "Enter" && comps[selectedIdx()].kind !== "no-results") {
if (e.key === "Enter") {
e.preventDefault();
acceptCompletion(comps[selectedIdx()]);
setShowCompletions(false);
return;
}
if (e.key === "Escape") {
@@ -305,7 +211,6 @@ export const JournalInput: Component = () => {
}
}
// Send
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
@@ -317,8 +222,7 @@ export const JournalInput: Component = () => {
const raw = input.value;
setText(raw);
// Completions visibility — keep open while user types any / (GM only)
if (isGm() && raw.startsWith("/")) {
if ((isGm() || raw.startsWith("/set")) && raw.startsWith("/")) {
setShowCompletions(true);
setSelectedIdx(0);
} else {
@@ -330,128 +234,55 @@ export const JournalInput: Component = () => {
textareaRef.style.height = Math.min(textareaRef.scrollHeight, 150) + "px";
}
// ---- Click outside ----
onMount(() => {
const handler = (e: MouseEvent) => {
if (completionsRef && !completionsRef.contains(e.target as Node)) {
setShowCompletions(false);
}
};
document.addEventListener("mousedown", handler);
onCleanup(() => document.removeEventListener("mousedown", handler));
});
// Completions placeholder — shown in the dropdown area when no matches
function renderCompletionsDropdown() {
const comps = currentCompletions();
if (comps.length === 0) return null;
return (
<div
ref={completionsRef}
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={comps}>
{(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() === selectedIdx()
? "bg-blue-50 text-blue-700"
: "hover:bg-gray-100 text-gray-700"
}`}
onClick={() => acceptCompletion(item)}
onMouseEnter={() =>
item.kind !== "no-results" && setSelectedIdx(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>
);
}
// ---- Render ----
return (
<div class="border-t border-gray-200 bg-white relative">
{/* Completions loading / error / empty teaser */}
<Show when={showCompletions()}>
<Switch>
<Match when={comp.state.status === "loading"}>
<div
ref={completionsRef}
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"
>
Loading completions
</div>
</Match>
<Match when={comp.state.status === "error"}>
<div
ref={completionsRef}
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"
>
{(comp.state as any).message || "Failed to load completions"}
</div>
</Match>
<Match
when={
comp.state.status === "empty" || comp.state.status === "loaded"
}
>
{renderCompletionsDropdown()}
</Match>
</Switch>
</Show>
<CompletionsDropdown
show={showCompletions()}
items={completions()}
selectedIdx={selectedIdx()}
onSelect={acceptCompletion}
onHover={(idx) => setSelectedIdx(idx)}
onClose={() => setShowCompletions(false)}
state={comp.state}
/>
{/* Textarea + actions */}
<div class="flex flex-col">
<Show
when={!isObserver()}
fallback={
<div class="px-3 py-4 text-xs text-gray-400 italic text-center">
You are observing. Open this panel on another device to join as GM
or player.
</div>
}
>
<textarea
ref={textareaRef}
id="journal-input-textarea"
value={text()}
onInput={handleInput}
onKeyDown={handleKeyDown}
placeholder={
isGm()
? "Type a message, or /roll or /link..."
: "Type a message..."
? "输入消息,或使用 /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-[60px]"
bg-transparent min-h-15"
/>
{/* Bottom row: error + buttons */}
<div class="flex items-center justify-between px-2 pb-2">
<Show when={error()}>
<p class="text-red-500 text-xs truncate max-w-[60%]">{error()}</p>
<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
@@ -461,12 +292,19 @@ export const JournalInput: Component = () => {
hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed
transition-colors"
>
Send
</button>
</div>
</div>
</Show>
</div>
{/* Error popup */}
<ErrorPopup
message={dispatchError()}
show={showErrorPopup()}
onClose={() => setShowErrorPopup(false)}
/>
</div>
);
};
+93 -428
View File
@@ -1,24 +1,21 @@
/**
* JournalPanel right panel container for the journal stream
*
* Fixed overlay. Shows stream when connected, connect dialog when not.
* On mobile: fixed overlay with backdrop.
* On desktop: positioned by the parent flex container (width transition).
* Auto-connects to ws://{current host}:{current port} — no URL input needed.
*/
import { Component, Show, createSignal, onMount, For } from "solid-js";
import {
connectStream,
hydrateFromServer,
useJournalStream,
setMyName,
setMyRole,
setSessionId,
sessions,
createSession,
disconnectStream,
} from "../stores/journalStream";
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;
@@ -28,6 +25,8 @@ export interface JournalPanelProps {
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 = () =>
@@ -37,432 +36,98 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
return (
<>
{/* Backdrop — only on mobile, starts below header */}
{/* 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 — always mounted for exit animation, visibility toggled */}
{/* Panel — on mobile: fixed overlay; on desktop: fills the parent wrapper */}
<aside
class={`fixed top-16 right-0 bottom-0 z-50 bg-white border-l border-gray-200
flex flex-col w-full max-w-md md:w-[420px] shadow-lg
transition-transform duration-300 ease-in-out ${
props.open ? "translate-x-0" : "translate-x-full"
}`}
aria-hidden={!props.open}
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}
>
{/* Header */}
<JournalHeader onClose={props.onClose} />
<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="Invite a player"
>
+ invite
</button>
</div>
</Show>
<Show
when={stream.connected}
fallback={
<div class="flex-1 flex items-center justify-center p-4 overflow-y-auto">
<ConnectDialog />
{/* 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>
}
>
<div class="flex-1 min-h-0">
<StreamView />
</div>
<JournalInput />
</Show>
</Show>
{/* Invite modal */}
<Show when={showInvite()}>
<InviteDialog onClose={() => setShowInvite(false)} />
</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>
</>
);
};
// ---------------------------------------------------------------------------
// Journal Header — status dot, player name, session dropdown, disconnect & close
// ---------------------------------------------------------------------------
interface JournalHeaderProps {
onClose: () => void;
}
const JournalHeader: Component<JournalHeaderProps> = (props) => {
const stream = useJournalStream();
const manifest = sessions;
const [dropdownOpen, setDropdownOpen] = createSignal(false);
const [newSessionName, setNewSessionName] = createSignal("");
const [showNewInput, setShowNewInput] = createSignal(false);
let dropdownRef!: HTMLDivElement;
const handleSessionSelect = async (id: string) => {
try {
await hydrateFromServer(id);
setSessionId(id);
} catch {
/* */
}
setDropdownOpen(false);
};
const handleCreate = () => {
const name = newSessionName().trim();
if (!name) return;
createSession(name);
setNewSessionName("");
setShowNewInput(false);
};
const handleDisconnect = () => disconnectStream();
// Close dropdown on outside click
if (typeof document !== "undefined") {
document.addEventListener("click", (e) => {
if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
setDropdownOpen(false);
setShowNewInput(false);
}
});
}
return (
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 shrink-0">
<div class="flex items-center gap-2 min-w-0">
<span
class="w-2 h-2 rounded-full shrink-0"
classList={{
"bg-gray-400": stream.connectionStatus === "disconnected",
"bg-yellow-400 animate-pulse":
stream.connectionStatus === "connecting",
"bg-green-500": stream.connectionStatus === "connected",
"bg-red-500": stream.connectionStatus === "error",
}}
title={stream.connectionStatus}
/>
<Show
when={stream.connected && stream.sessionId}
fallback={
<h2 class="text-sm font-semibold text-gray-700 truncate">
Disconnected
</h2>
}
>
<div class="min-w-0">
<p class="text-sm font-semibold text-gray-700 truncate leading-tight flex items-center gap-1">
{stream.myName}
<Show when={stream.myRole !== "gm"}>
<span
class={`text-[10px] px-1 rounded ${
stream.myRole === "observer"
? "bg-gray-200 text-gray-500"
: "bg-blue-100 text-blue-600"
}`}
>
{stream.myRole}
</span>
</Show>
</p>
{/* Clickable subtitle — session dropdown (GM only) */}
<Show when={stream.myRole === "gm"}>
<div ref={dropdownRef} class="relative">
<button
onClick={() => setDropdownOpen((v) => !v)}
class="text-[10px] text-gray-400 hover:text-gray-600 truncate leading-tight cursor-pointer text-left"
>
{stream.sessionName || stream.sessionId}{" "}
<span class="text-gray-300"></span>
</button>
<Show when={dropdownOpen()}>
<div class="absolute top-full left-0 mt-1 w-48 bg-white border border-gray-200 rounded shadow-lg z-50 py-1 max-h-48 overflow-y-auto">
<For each={Object.entries(manifest().sessions)}>
{([id, meta]) => (
<button
onClick={() => handleSessionSelect(id)}
class={`w-full text-left px-3 py-1 text-xs hover:bg-gray-100 flex items-center gap-1 ${
id === stream.sessionId
? "bg-blue-50 text-blue-700"
: "text-gray-700"
}`}
>
<span class="truncate flex-1">{meta.name || id}</span>
<Show when={id === stream.sessionId}>
<span class="text-blue-500 shrink-0"></span>
</Show>
</button>
)}
</For>
<div class="border-t border-gray-100 my-0.5" />
<Show
when={!showNewInput()}
fallback={
<div class="px-3 py-1 flex gap-1">
<input
type="text"
value={newSessionName()}
onInput={(e) =>
setNewSessionName(e.currentTarget.value)
}
placeholder="Session name"
class="flex-1 border border-gray-300 rounded px-1.5 py-0.5 text-xs"
autofocus
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
if (e.key === "Escape") setShowNewInput(false);
}}
/>
<button
onClick={handleCreate}
class="bg-blue-600 text-white rounded px-2 py-0.5 text-xs hover:bg-blue-700"
>
+
</button>
</div>
}
>
<button
onClick={() => setShowNewInput(true)}
class="w-full text-left px-3 py-1 text-xs text-blue-600 hover:bg-gray-100"
>
+ New session
</button>
</Show>
</div>
</Show>
</div>
</Show>
</div>
</Show>
</div>
<div class="flex items-center gap-1 shrink-0">
<Show when={stream.connected}>
<button
onClick={handleDisconnect}
class="text-xs text-gray-400 hover:text-red-500 px-1"
title="Disconnect"
>
</button>
</Show>
<button
onClick={props.onClose}
class="text-gray-400 hover:text-gray-600 text-sm px-1"
title="Hide panel"
>
</button>
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// Connect dialog — one input: player name. Broker URL is always current host.
// ---------------------------------------------------------------------------
const ConnectDialog: Component = () => {
const stream = useJournalStream();
const [playerName, setPlayerName] = createSignal(stream.myName);
const [role, setRole] = createSignal<"gm" | "player" | "observer">(
stream.myRole,
);
const [connecting, setConnecting] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [autoJoined, setAutoJoined] = createSignal(false);
// Auto-join when URL has both session and player params
onMount(() => {
const params = new URL(window.location.href).searchParams;
if (
params.has("session") &&
params.has("player") &&
params.has("autojoin")
) {
setPlayerName(params.get("player") || "");
setRole("player");
setAutoJoined(true);
handleConnect();
}
});
const handleConnect = async () => {
const name = playerName().trim() || stream.myName;
if (!name) return;
const brokerUrl = `ws://${window.location.host}`;
setConnecting(true);
setError(null);
try {
setMyName(name);
setMyRole(role());
const sessionId = stream.sessionId || "default";
setSessionId(sessionId);
await hydrateFromServer(sessionId);
await connectStream(sessionId, brokerUrl);
} catch (e) {
const msg = e instanceof Error ? e.message : "Connection failed";
setError(msg);
} finally {
setConnecting(false);
}
};
return (
<Show
when={!autoJoined() || error()}
fallback={
<p class="text-sm text-gray-500 text-center">
{connecting() ? "Joining session…" : "Joined!"}
</p>
}
>
<div class="w-full max-w-sm space-y-3">
<p class="text-sm text-gray-600 text-center">
Enter your name and role to join the session.
<br />
<span class="text-xs text-gray-400">
Connecting to {window.location.host}
</span>
</p>
<input
type="text"
value={playerName()}
onInput={(e) => setPlayerName(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && handleConnect()}
placeholder="Your name"
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" ? "GM" : r === "player" ? "Player" : "Observer"}
</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() ? "Connecting..." : "Join"}
</button>
<Show when={error()}>
<p class="text-red-500 text-sm text-center">{error()}</p>
</Show>
</div>
</Show>
);
};
// ---------------------------------------------------------------------------
// Invite dialog — input player name, copy invite link
// ---------------------------------------------------------------------------
interface InviteDialogProps {
onClose: () => void;
}
const InviteDialog: Component<InviteDialogProps> = (props) => {
const stream = useJournalStream();
const [playerName, setPlayerName] = createSignal("");
const [copied, setCopied] = createSignal(false);
const inviteLink = () => {
const url = new URL(window.location.href);
url.searchParams.set("session", stream.sessionId || "default");
if (playerName().trim()) {
url.searchParams.set("player", playerName().trim());
}
url.searchParams.set("autojoin", "1");
return url.toString();
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(inviteLink());
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
const input = document.getElementById(
"invite-link-input",
) as HTMLInputElement;
if (input) input.select();
}
};
return (
<div class="absolute inset-0 z-50 flex items-center justify-center bg-black/20">
<div class="bg-white rounded-lg border border-gray-200 shadow-xl w-[280px] p-4 space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-800">Invite Player</h3>
<button
onClick={props.onClose}
class="text-gray-400 hover:text-gray-600 text-sm"
>
</button>
</div>
<p class="text-xs text-gray-500">
Enter a player name and share the link. They will join as a player.
</p>
<input
type="text"
value={playerName()}
onInput={(e) => setPlayerName(e.currentTarget.value)}
placeholder="Player name"
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() ? "Copied!" : "Copy"}
</button>
</div>
</div>
</div>
);
};
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function escapeAttr(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
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>
);
};
+2 -2
View File
@@ -45,7 +45,7 @@ export const StreamMessageCard: Component<{
<button
onClick={() => revertLatest()}
class="text-xs text-gray-300 hover:text-red-500 ml-auto px-0.5 leading-none"
title="Revert this message"
title="撤回此消息"
>
×
</button>
@@ -87,7 +87,7 @@ 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} reverted
{props.msg.sender}
</span>
</div>
);
+2 -2
View File
@@ -30,8 +30,8 @@ export const StreamView: Component = () => {
<Show when={messages().length === 0}>
<p class="text-center text-gray-400 text-xs py-8">
{stream.connected
? "No messages yet. Start by posting something."
: "Connect to a session to see messages."}
? "暂无消息,发送点什么开始吧。"
: "连接会话以查看消息。"}
</p>
</Show>
</div>
+124
View File
@@ -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 };
}
+71
View File
@@ -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 } };
}
+115 -53
View File
@@ -2,37 +2,44 @@
* 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 and headings.
* 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 { extractHeadings } from "../../data-loader/toc";
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";
// ------------------- Types (mirrors CLI) -------------------
export type { VarDeclaration, TagModifier };
export interface DiceCompletion {
label: string;
notation: string;
source: string;
}
// Re-export CLI types so consumers don't need to know about the CLI path
export type { DiceCompletion, LinkCompletion, SparkTableCompletion };
export interface LinkCompletion {
path: string;
label: string;
section: string | null;
}
export interface JournalCompletions {
dice: DiceCompletion[];
links: LinkCompletion[];
}
/** Convenience alias — same shape as the CLI's CompletionsPayload. */
export type JournalCompletions = CompletionsPayload;
export type CompletionsState =
| { status: "loading" }
@@ -46,6 +53,23 @@ 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> {
@@ -56,48 +80,55 @@ async function tryServer(): Promise<JournalCompletions | null> {
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");
const dice: DiceCompletion[] = [];
const links: LinkCompletion[] = [];
const tagRegex = /<md-dice[^>]*>\s*([\s\S]*?)\s*<\/md-dice>/gi;
// 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;
// Dice scan
let match: RegExpExecArray | null;
tagRegex.lastIndex = 0;
while ((match = tagRegex.exec(content)) !== null) {
const raw = match[1].trim();
if (!raw || raw.length > 80) continue;
if (!/^\d*d\d+/i.test(raw) && !/^[+-]/.test(raw)) continue;
dice.push({ label: raw, notation: raw, source: filePath });
}
// Link scan (headings)
const basePath = filePath.replace(/\.md$/, "");
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
links.push({ path: basePath, label: fileName, section: null });
for (const heading of extractHeadings(content)) {
links.push({
path: basePath,
label: `${fileName} § ${heading.title}`,
section: heading.id ?? null,
});
}
index[filePath] = content;
}
return { dice, links };
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) -------------------
@@ -108,6 +139,16 @@ const _initPromise: Promise<void> = (async () => {
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;
}
@@ -116,6 +157,15 @@ const _initPromise: Promise<void> = (async () => {
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" });
}
@@ -137,14 +187,6 @@ export function ensureCompletions(): Promise<void> {
return _initPromise;
}
/** Force a re-fetch on the next page load. For runtime, call before invalidate triggers. */
let _invalidated = false;
export function invalidateCompletions(): void {
_invalidated = true;
// On next import (page reload), the module will re-init.
// For a runtime invalidation, you could call init again.
}
/**
* Reactive hooks for the journal input.
* Returns the current completions state + a convenience `data` extractor.
@@ -157,5 +199,25 @@ export function useJournalCompletions(): {
if (s.status === "loaded") {
return { state: s, data: s.data };
}
return { state: s, data: { dice: [], links: [] } };
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 });
}
}
+10
View File
@@ -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";
+21 -1
View File
@@ -37,10 +37,30 @@ export type {
// UI components
export { JournalPanel } from "./JournalPanel";
export type { JournalPanelProps } from "./JournalPanel";
export { JournalHeader } from "./JournalHeader";
export { ConnectDialog } from "./ConnectDialog";
export { InviteDialog } from "./InviteDialog";
export { SessionDropdown } from "./SessionDropdown";
export { CreateSessionDialog } from "./CreateSessionDialog";
export { ConnectionBar } from "./ConnectionBar";
export { StreamView } from "./StreamView";
export { StreamMessageCard } from "./StreamMessage";
export { ComposePanel } from "./ComposePanel";
export { JournalInput } from "./JournalInput";
export { DynamicForm } from "./DynamicForm";
export { useJournalCompletions, invalidateCompletions } from "./completions";
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";
+1 -1
View File
@@ -19,7 +19,7 @@ export type ChatPayload = z.infer<typeof schema>;
registerMessageType<ChatPayload>({
type: "chat",
label: "Chat",
label: "聊天",
emitters: ["gm", "player"],
schema,
defaultPayload: () => ({ text: "" }),
+2
View File
@@ -7,5 +7,7 @@
import "./chat";
import "./roll";
import "./spark";
import "./link";
import "./intent";
import "./var";
+4 -4
View File
@@ -18,7 +18,7 @@ export type IntentPayload = z.infer<typeof intentSchema>;
registerMessageType<IntentPayload>({
type: "intent",
label: "Declare Intent",
label: "声明意图",
emitters: ["player"],
schema: intentSchema,
defaultPayload: () => ({ description: "" }),
@@ -26,7 +26,7 @@ registerMessageType<IntentPayload>({
<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">Intent</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 && (
@@ -52,14 +52,14 @@ export type ResolutionPayload = z.infer<typeof resolutionSchema>;
registerMessageType<ResolutionPayload>({
type: "resolution",
label: "Resolve Intent",
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">Resolution</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>
+21 -6
View File
@@ -6,11 +6,12 @@
*/
import { Component } from "solid-js";
import { useNavigate } from "@solidjs/router";
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),
@@ -22,7 +23,16 @@ export type LinkPayload = z.infer<typeof schema>;
function fileNameFromPath(path: string): string {
const parts = path.split("/").filter(Boolean);
return parts[parts.length - 1] || path;
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 */
@@ -34,11 +44,12 @@ function slugToTitle(slug: string): string {
}
const RevealLink: Component<LinkPayload> = (p) => {
const navigate = useNavigate();
const navigate = useNavigateWithParams();
const ctx = useJournalContext();
const target = () => {
let t = p.path;
if (p.section) t += `#${p.section}`;
let t = encodePath(p.path);
if (p.section) t += `#${encodeURIComponent(p.section)}`;
return t;
};
@@ -50,6 +61,10 @@ const RevealLink: Component<LinkPayload> = (p) => {
const handleClick = (e: MouseEvent) => {
e.preventDefault();
navigate(target());
// On mobile, close the journal panel to reveal the article
if (window.innerWidth < 768) {
ctx?.onClose();
}
};
return (
@@ -75,7 +90,7 @@ const RevealLink: Component<LinkPayload> = (p) => {
registerMessageType<LinkPayload>({
type: "link",
label: "Link Article",
label: "链接文章",
emitters: ["gm"],
schema,
defaultPayload: () => ({ path: "/" }),
+4 -4
View File
@@ -1,9 +1,9 @@
/**
* Built-in message type: roll
*
* Emitters: gm
* Emitters: gm, player
* Command: /roll formula
* The GM's browser rolls the formula and publishes the resolved result.
* The sender's browser rolls the formula and publishes the resolved result.
*/
import { z } from "zod";
@@ -43,8 +43,8 @@ export function resolveRollPayload(raw: {
registerMessageType<RollPayload>({
type: "roll",
label: "Roll Dice",
emitters: ["gm"],
label: "掷骰",
emitters: ["gm", "player"],
schema,
defaultPayload: () => ({
notation: "1d20",
+162
View File
@@ -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>
);
},
});
+62
View File
@@ -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;
}
}),
);
},
});
+692
View File
@@ -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;
}
@@ -2,7 +2,7 @@ import { createStore } from "solid-js/store";
import type { MdCommanderCommand, MdCommanderCommandMap } from "../types";
import { setupHelpCommand, clearCommand, rollCommand, trackCommand, untrackCommand, listTrackCommand } from "../commands";
import {resolvePath} from "../../utils/path";
import {loadCSV} from "../../utils/csv-loader";
import {loadCSVFromPath} from "../../utils/csv-loader";
const defaultCommands: MdCommanderCommandMap = {
help: setupHelpCommand({}),
@@ -111,7 +111,7 @@ export async function loadCommandTemplatesFromCSV(
setCommandsError(undefined);
try {
const csv = await loadCSV<CommandTemplateRow>(resolvePath(articlePath, path));
const csv = await loadCSVFromPath<CommandTemplateRow>(resolvePath(articlePath, path));
// 按命令分组模板
const templatesByCommand = new Map<string, CommandTemplateRow[]>();
+29 -17
View File
@@ -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 { 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 { processVariables } from "../utils/csv-loader";
import { resolvePath } from "../utils/path";
import type { LayerInteractionHandlers } from "./hooks/useLayerInteraction";
export interface CardLayerProps {
@@ -26,25 +25,35 @@ export function CardLayer(props: CardLayerProps) {
const draggingState = () => props.store.state.draggingState;
function renderLayerContent(content: string) {
const iconPath = resolvePath(
props.store.state.cards.sourcePath,
props.cardData.iconPath ?? "./assets",
);
return parseMarkdown(
processVariables(content, props.cardData, props.store.state.cards),
iconPath,
props.store.state.cards.sourcePath,
) as string;
}
const getAlignStyle = (align?: "l" | "c" | "r") => {
if (align === "l") return "left";
if (align === "r") return "right";
return "center";
const getAlignStyle = (align?: Align) => {
const horizontal = align?.includes("l")
? "left"
: 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) =>
selectedLayer() === layerIndex;
const isEditing = () =>
props.store.state.isEditing && !props.store.state.fixed;
const getFrameBounds = (layer: LayerConfig) => {
const dims = dimensions();
const left = (layer.x1 - 1) * dims.cellWidth;
@@ -80,20 +89,23 @@ export function CardLayer(props: CardLayerProps) {
return (
<>
<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={{
"cursor-pointer": isEditing(),
"ring-2 ring-blue-500 ring-offset-1":
isSelected() && !draggingState(),
isSelected() && !draggingState() && isEditing(),
}}
style={{
...getLayerStyle(layer, dimensions()),
"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)}
/>
<Show when={isSelected()}>
<Show when={isSelected() && isEditing()}>
<div
class="absolute border-2 border-blue-500 pointer-events-none z-10"
style={{
+86
View File
@@ -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>
);
}
+85 -37
View File
@@ -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";
export interface DeckHeaderProps {
@@ -10,46 +11,93 @@ export interface DeckHeaderProps {
*/
export function DeckHeader(props: DeckHeaderProps) {
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 (
<div class="flex items-center gap-2 border-b border-gray-200 pb-2 mb-4">
{/* 编辑按钮 */}
<button
onClick={() => store.actions.setIsEditing(!store.state.isEditing)}
class={`px-3 py-1 rounded text-sm font-medium transition-colors cursor-pointer ${
store.state.isEditing && !store.state.fixed
? "bg-blue-100 text-blue-600"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{store.state.isEditing ? "✓ 编辑中" : "✏️ 编辑"}
</button>
<>
<div class="flex items-center gap-2 border-b border-gray-200 pb-2 mb-4">
{/* 编辑按钮 */}
<button
onClick={() => store.actions.setIsEditing(!store.state.isEditing)}
class={`px-3 py-1 rounded text-sm font-medium transition-colors cursor-pointer ${
store.state.isEditing && !store.state.fixed
? "bg-blue-100 text-blue-600"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{store.state.isEditing ? "✓ 编辑中" : "✏️ 编辑"}
</button>
{/* 导出 PDF 按钮 */}
<button
onClick={() => store.actions.exportDeck()}
class="px-2 py-1 rounded text-xs font-medium transition-colors cursor-pointer bg-green-100 text-green-600 hover:bg-green-200"
>
📥 PDF
</button>
{/* 导出 PDF 按钮 */}
<button
onClick={() => store.actions.exportDeck()}
class="px-2 py-1 rounded text-xs font-medium transition-colors cursor-pointer bg-green-100 text-green-600 hover:bg-green-200"
>
📥 PDF
</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
onClick={() => store.actions.setActiveTab(index())}
class={`font-medium transition-colors shrink-0 min-w-[1.6em] cursor-pointer px-2 py-1 rounded ${
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>
)}
</For>
{/* 复制代码按钮 */}
<button
onClick={handleCopy}
class="px-2 py-1 rounded text-xs font-medium transition-colors cursor-pointer bg-purple-100 text-purple-600 hover:bg-purple-200"
>
📋
</button>
</div>
</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>
</div>
</div>
</div>
</Portal>
</Show>
</>
);
}
+59
View File
@@ -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>
);
}
+1 -1
View File
@@ -115,7 +115,7 @@ export function PrintPreview(props: PrintPreviewProps) {
<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">
<PrintPreviewHeader
store={store}
@@ -179,6 +179,20 @@ export function PrintPreviewHeader(props: PrintPreviewHeaderProps) {
</label>
</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">
<label class="text-sm text-gray-600">:</label>
<div class="flex items-center gap-1">
+131
View File
@@ -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([]);
});
});
+148
View File
@@ -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,
} from "@thisbeyond/solid-dnd";
import type { DeckStore } from "../hooks/deckStore";
import type { CardSide } from "../types";
import { toSortableId } from "../hooks/layer-crud";
import { LayerRow } from "./LayerRow";
export interface LayerEditorPanelProps {
store: DeckStore;
side?: CardSide;
}
function LayerEditorPanel(props: LayerEditorPanelProps) {
@@ -20,7 +22,7 @@ function LayerEditorPanel(props: LayerEditorPanelProps) {
const [addMenuOpen, setAddMenuOpen] = createSignal(false);
let addMenuRef: HTMLDivElement | undefined;
const side = () => store.state.activeSide;
const side = () => props.side || "front";
const layers = () =>
side() === "front"
? store.state.frontLayerConfigs
@@ -143,15 +145,6 @@ function LayerEditorPanel(props: LayerEditorPanelProps) {
</SortableProvider>
</DragDropSensors>
</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>
);
}
@@ -1,7 +1,7 @@
import { For, createSignal, onCleanup, onMount } from "solid-js";
import { createSortable, useDragDropContext } from "@thisbeyond/solid-dnd";
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 alignCenterIcon from "./icons/align-center.png";
import alignRightIcon from "./icons/align-right.png";
@@ -17,7 +17,7 @@ export interface LayerRowProps {
setOpenDropdown: (val: string | null) => void;
onUpdateOrientation: (o: "n" | "s" | "e" | "w") => void;
onUpdateFontSize: (fs?: number) => void;
onUpdateAlign: (a?: "l" | "c" | "r") => void;
onUpdateAlign: (a?: Align) => void;
onSelect: () => void;
onRemove: () => void;
}
@@ -29,11 +29,17 @@ const ORIENTATIONS = [
{ value: "w" as const, label: "← 西" },
];
const ALIGNS = [
{ value: "" as const, icon: alignCenterIcon },
{ value: "l" as const, icon: alignLeftIcon },
{ value: "c" as const, icon: alignCenterIcon },
{ value: "r" as const, icon: alignRightIcon },
const ALIGNS: { value: Align | ""; icon: string }[] = [
{ value: "", icon: alignCenterIcon },
{ value: "l", icon: alignLeftIcon },
{ value: "c", icon: alignCenterIcon },
{ 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;
@@ -53,14 +59,28 @@ function orientChar(v: string) {
}
}
function alignSrc(v: string) {
function alignIcon(v: string): import("solid-js").JSX.Element {
switch (v) {
case "tl":
return "↖";
case "tc":
return "↑";
case "tr":
return "↗";
case "bl":
return "↙";
case "bc":
return "↓";
case "br":
return "↘";
case "l":
return alignLeftIcon;
return <img src={alignLeftIcon} alt="align" class="w-5 h-5 not-prose" />;
case "r":
return alignRightIcon;
return <img src={alignRightIcon} alt="align" class="w-5 h-5 not-prose" />;
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"
onClick={props.onSelect}
>
{props.layer.prop}
{props.layer.prop || (props.layer.template ? "(模板)" : "")}
</span>
<DropdownButton
@@ -149,13 +169,7 @@ export function LayerRow(props: LayerRowProps) {
</DropdownButton>
<DropdownButton
icon={
<img
src={alignSrc(props.layer.align || "")}
alt="align"
class="w-5 h-5 not-prose"
/>
}
icon={alignIcon(props.layer.align || "")}
visible={props.layer.visible}
open={props.openDropdown === `align-${props.index}`}
onToggle={() =>
@@ -173,7 +187,13 @@ export function LayerRow(props: LayerRowProps) {
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"
>
<img src={o.icon} alt="" class="w-4 h-4 not-prose max-w-none" />
{o.icon.endsWith(".png") ? (
<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>
)}
</For>
@@ -1,49 +1,32 @@
import type { DeckStore } from '../hooks/deckStore';
import type { CardShape } from '../types';
import type { DeckStore } from "../hooks/deckStore";
import type { CardShape } from "../types";
export interface PropertiesEditorPanelProps {
store: DeckStore;
}
/**
*
*
*/
export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) {
const { store } = props;
return (
<div class="w-64 flex-shrink-0">
<h3 class="font-bold mb-2 mt-0"></h3>
const shapeOptions: { value: CardShape; label: string }[] = [
{ value: "rectangle", label: "矩形" },
{ value: "circle", label: "圆形" },
{ value: "triangle", label: "三角形" },
{ value: "hexagon", label: "六边形" },
];
{/* 正面/背面切换标签页 */}
<div class="mb-4">
<div class="flex gap-1">
<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>
return (
<div class="w-64 shrink-0">
<h3 class="font-bold mb-2 mt-0"></h3>
<div class="space-y-3">
<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">
<input
type="number"
@@ -83,7 +66,9 @@ export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) {
</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">
<input
type="number"
@@ -93,34 +78,30 @@ export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) {
placeholder="出血"
/>
<input
type="number"
class="w-full border border-gray-300 rounded px-2 py-1 text-sm"
value={store.state.padding}
onChange={(e) => store.actions.setPadding(Number(e.target.value))}
placeholder="内边距"
type="number"
class="w-full border border-gray-300 rounded px-2 py-1 text-sm"
value={store.state.padding}
onChange={(e) => store.actions.setPadding(Number(e.target.value))}
placeholder="内边距"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"></label>
<div class="grid grid-cols-2 gap-2">
{(['rectangle', 'circle', 'triangle', 'hexagon'] as CardShape[]).map((shape) => (
<button
onClick={() => store.actions.setShape(shape)}
class={`px-3 py-1.5 rounded text-sm font-medium cursor-pointer border ${
store.state.shape === shape
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
}`}
>
{shape === 'rectangle' && '矩形'}
{shape === 'circle' && '圆形'}
{shape === 'triangle' && '三角形'}
{shape === 'hexagon' && '六边形'}
</button>
<label class="block text-sm font-medium text-gray-700 mb-2">
</label>
<select
value={store.state.shape}
onChange={(e) =>
store.actions.setShape(e.target.value as CardShape)
}
class="w-full border border-gray-300 rounded px-2 py-1 text-sm bg-white"
>
{shapeOptions.map((s) => (
<option value={s.value}>{s.label}</option>
))}
</div>
</select>
</div>
</div>
</div>
+81 -17
View File
@@ -1,7 +1,8 @@
import { createStore } from "solid-js/store";
import yaml from "js-yaml";
import { calculateDimensions } from "./dimensions";
import { loadCSV, CSV } from "../../utils/csv-loader";
import { formatLayers, initLayerConfigsForSide } from "./layer-parser";
import { loadCSVFromPath, CSV } from "../../utils/csv-loader";
import { formatLayers } from "./layer-parser";
import * as layerCrud from "./layer-crud";
import type {
CardData,
@@ -41,6 +42,8 @@ export interface DeckState {
cornerRadius: number;
shape: CardShape;
fixed: boolean;
/** True when the deck was configured via a yaml role=tag codeblock (data-config). */
isYamlBlock: boolean;
src: string;
rawSrc: string;
@@ -73,6 +76,7 @@ export interface DeckState {
printFrontOddPageOffsetX: number;
printFrontOddPageOffsetY: number;
printDoubleSided: boolean;
pltDoubleCut: boolean;
}
export interface DeckActions {
@@ -84,6 +88,7 @@ export interface DeckActions {
setPadding: (padding: number) => void;
setCornerRadius: (cornerRadius: number) => void;
setShape: (shape: CardShape) => void;
setIsYamlBlock: (isYamlBlock: boolean) => void;
setCards: (cards: CSV<CardData>) => void;
setActiveTab: (index: number) => void;
@@ -137,14 +142,14 @@ export interface DeckActions {
loadCardsFromPath: (
path: string,
rawSrc: string,
layersStr?: string,
backLayersStr?: string,
frontLayers?: LayerConfig[],
backLayers?: LayerConfig[],
) => Promise<void>;
setError: (error: string | null) => void;
clearError: () => void;
generateCode: (backLayersStr?: string) => string;
copyCode: (backLayersStr?: string) => Promise<void>;
copyCode: (fallback?: (code: string) => void) => Promise<void>;
setExporting: (exporting: boolean) => void;
exportDeck: () => void;
@@ -156,6 +161,7 @@ export interface DeckActions {
setPrintFrontOddPageOffsetX: (offset: number) => void;
setPrintFrontOddPageOffsetY: (offset: number) => void;
setPrintDoubleSided: (doubleSided: boolean) => void;
setPltDoubleCut: (doubleCut: boolean) => void;
}
export interface DeckStore {
@@ -174,6 +180,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
cornerRadius: DECK_DEFAULTS.CORNER_RADIUS,
shape: "rectangle",
fixed: false,
isYamlBlock: false,
src: initialSrc,
rawSrc: initialSrc,
dimensions: null,
@@ -197,6 +204,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
printFrontOddPageOffsetX: 0,
printFrontOddPageOffsetY: 0,
printDoubleSided: false,
pltDoubleCut: false,
});
const updateDimensions = () => {
@@ -241,6 +249,9 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
const setShape = (shape: CardShape) => {
setState({ shape });
};
const setIsYamlBlock = (isYamlBlock: boolean) => {
setState({ isYamlBlock });
};
const setCards = (cards: CSV<CardData>) => setState({ cards, activeTab: 0 });
const setActiveTab = (index: number) => setState({ activeTab: index });
@@ -439,8 +450,8 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
const loadCardsFromPath = async (
path: string,
rawSrc: string,
layersStr: string = "",
backLayersStr: string = "",
frontLayers: LayerConfig[] = [],
backLayers: LayerConfig[] = [],
) => {
if (!path) {
setState({ error: "未指定 CSV 文件路径" });
@@ -450,7 +461,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
setState({ isLoading: true, error: null, src: path, rawSrc: rawSrc });
try {
const data = await loadCSV(path);
const data = await loadCSVFromPath(path);
if (data.length === 0) {
setState({
@@ -463,12 +474,8 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
setState({
cards: data,
activeTab: 0,
frontLayerConfigs: layerCrud.withKeys(
initLayerConfigsForSide(data, layersStr),
),
backLayerConfigs: layerCrud.withKeys(
initLayerConfigsForSide(data, backLayersStr),
),
frontLayerConfigs: layerCrud.withKeys(frontLayers),
backLayerConfigs: layerCrud.withKeys(backLayers),
isLoading: false,
});
updateDimensions();
@@ -484,6 +491,9 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
const clearError = () => setState({ error: null });
const generateCode = (backLayersStr?: string) => {
if (state.isYamlBlock) {
return generateYamlCode();
}
const frontLayersStr = formatLayers(state.frontLayerConfigs);
const backLayersString =
backLayersStr || formatLayers(state.backLayerConfigs);
@@ -511,14 +521,62 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
return parts.join("");
};
const copyCode = async (backLayersStr?: string) => {
const code = generateCode(backLayersStr);
/** Serialize the deck back to a yaml codeblock (round-trips templates). */
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 {
await navigator.clipboard.writeText(code);
alert("已复制到剪贴板!");
} catch (err) {
console.error("复制失败:", err);
alert("复制失败,请手动复制");
if (fallback) {
fallback(code);
} else {
alert("复制失败,请手动复制");
}
}
};
@@ -553,6 +611,10 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
setState({ printDoubleSided: doubleSided });
};
const setPltDoubleCut = (doubleCut: boolean) => {
setState({ pltDoubleCut: doubleCut });
};
const actions: DeckActions = {
setSizeW,
setSizeH,
@@ -562,6 +624,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
setPadding,
setCornerRadius,
setShape,
setIsYamlBlock,
setCards,
setActiveTab,
updateCardData,
@@ -599,6 +662,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
setPrintFrontOddPageOffsetX,
setPrintFrontOddPageOffsetY,
setPrintDoubleSided,
setPltDoubleCut,
};
return { state, actions };
+8 -5
View File
@@ -4,15 +4,16 @@ import { CSV } from "../../utils/csv-loader";
/**
* layers
* 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[] {
if (!layersStr) return [];
const layers: Layer[] = [];
// 匹配:prop:x1,y1-x2,y2[ffontSize][direction][align]
// 匹配:prop:x1,y1-x2,y2[ffontSize][direction][[t|b]align]
const regex =
/([^: ]+):(\d+),(\d+)-(\d+),(\d+)(?:f([\d.]+))?([nsew])?([lcr])?/g;
/([^: ]+):(\d+),(\d+)-(\d+),(\d+)(?:f([\d.]+))?([nsew])?([tb])?([lcr])?/g;
let match;
while ((match = regex.exec(layersStr)) !== null) {
@@ -24,7 +25,7 @@ export function parseLayers(layersStr: string): Layer[] {
y2: parseInt(match[5]),
fontSize: match[6] ? parseFloat(match[6]) : 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
*/
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
.filter((l) => l.visible)
.filter((l) => l.visible && l.prop)
.map((l) => {
let str = `${l.prop}:${l.x1},${l.y1}-${l.x2},${l.y2}`;
if (l.fontSize) {
@@ -87,6 +87,8 @@ export function useLayerInteraction(
const handleLayerClick = (index: number, e: MouseEvent) => {
e.stopPropagation();
if (!store.state.isEditing || store.state.fixed) return;
const currentlySelected = store.state.selectedLayer;
if (currentlySelected === index) {
@@ -100,6 +102,7 @@ export function useLayerInteraction(
const handleCardClick = (e: MouseEvent, cardEl: HTMLElement) => {
if (store.state.draggingState) return;
if (!store.state.isEditing || store.state.fixed) return;
const { gridX, gridY } = calculateGridCoords(e, cardEl);
const overlapping = getOverlappingLayers(gridX, gridY);
@@ -147,6 +150,7 @@ export function useLayerInteraction(
edge?: "n" | "s" | "e" | "w",
e?: MouseEvent,
) => {
if (!store.state.isEditing || store.state.fixed) return;
if (store.state.selectedLayer === null) return;
if (e) e.stopPropagation();
@@ -35,6 +35,7 @@ export function usePlotterExport(store: DeckStore): UsePlotterExportReturn {
const cardHeight = () => store.state.dimensions?.cardHeight || 88;
const shape = () => store.state.shape;
const orientation = () => store.state.printOrientation || 'landscape';
const doubleCut = () => store.state.pltDoubleCut;
/**
* PLT
@@ -60,7 +61,7 @@ export function usePlotterExport(store: DeckStore): UsePlotterExportReturn {
// 将卡片路径转换为相对于 frameBoundsWithMargin 的坐标
// 原点在 frameBoundsWithMargin 的左上角
const relativePaths = layout.cardPaths.map(cardPath => {
const relativePaths = layout.cardPaths.flatMap(cardPath => {
const relativePoints = cardPath.points.map(([x, y]) => {
// 转换为相对于 frameBounds 左上角的坐标
const relativeX = x - frameBounds.x;
@@ -71,7 +72,11 @@ export function usePlotterExport(store: DeckStore): UsePlotterExportReturn {
// 所以 plotterY = pltHeight - relativeY
return [relativeX, pltHeight - relativeY] as [number, number];
});
return relativePoints;
if (doubleCut()) {
// 重复一次:某些切割机需要每张卡牌切两次
return [relativePoints, relativePoints];
}
return [relativePoints];
});
// 起点和终点都在 frameBoundsWithMargin 的左上角 (0, pltHeight)
+62 -44
View File
@@ -1,17 +1,17 @@
import { customElement, noShadowDOM } from "solid-element";
import { Show, onCleanup } from "solid-js";
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 type { CardShape } from "./types";
import type { CardShape, LayerConfig } from "./types";
import { normalizeDeckConfig, layersToConfigs } from "./config";
import { DeckHeader } from "./DeckHeader";
import { CardList } from "./CardList";
import { DeckContent } from "./DeckContent";
import { EditorTabs } from "./EditorTabs";
import { PrintPreview } from "./PrintPreview";
import {
DataEditorPanel,
LayerEditorPanel,
PropertiesEditorPanel,
} from "./editor-panel";
import { DataEditorPanel } from "./editor-panel";
interface DeckProps {
size?: string;
@@ -66,52 +66,80 @@ customElement<DeckProps>(
const store = createDeckStore(resolvedSrc);
// 生成唯一 ID 并注册到全局注册表
const deckId = `deck-${crypto.randomUUID()}`;
const deckId = `deck-${uuidv4()}`;
registerDeck(deckId, store, resolvedSrc, csvPath);
// 读取 data-configyaml 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" 和新格式)
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);
store.actions.setSizeW(w);
store.actions.setSizeH(h);
} else {
store.actions.setSizeW(props.sizeW ?? 54);
store.actions.setSizeH(props.sizeH ?? 86);
store.actions.setSizeW(props.sizeW ?? DECK_DEFAULTS.SIZE_W);
store.actions.setSizeH(props.sizeH ?? DECK_DEFAULTS.SIZE_H);
}
// 解析 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);
store.actions.setGridW(w);
store.actions.setGridH(h);
} else {
store.actions.setGridW(props.gridW ?? 5);
store.actions.setGridH(props.gridH ?? 8);
store.actions.setGridW(props.gridW ?? DECK_DEFAULTS.GRID_W);
store.actions.setGridH(props.gridH ?? DECK_DEFAULTS.GRID_H);
}
// 解析 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));
} 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));
} 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 数据
store.actions.loadCardsFromPath(
resolvedSrc,
csvPath,
(props.layers as string) || "",
(props.backLayers as string) || "",
);
store.actions.loadCardsFromPath(resolvedSrc, csvPath, frontLayers, backLayers);
// 清理函数
onCleanup(() => {
@@ -138,29 +166,19 @@ customElement<DeckProps>(
</Show>
<div class="flex gap-4">
{/* 内容区域:错误/加载/卡牌预览/空状态 */}
{/* 左侧:CSV 数据编辑 */}
{/*<Show when={store.state.isEditing && !store.state.fixed}>*/}
{/* <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 when={store.state.cards.length > 0 && !store.state.error}>
<CardList store={store} />
</Show>
<DeckContent store={store} isLoading={store.state.isLoading} />
{/* 中间:内容区域(错误/加载/卡牌预览/空状态) */}
<div class="flex-1 min-w-0">
<DeckContent store={store} isLoading={store.state.isLoading} />
</div>
{/* 右侧:属性/图层编辑面板 */}
{/* 右侧:属性/图层编辑面板(标签页切换) */}
<Show when={store.state.isEditing && !store.state.fixed}>
<div class="flex-1">
<LayerEditorPanel store={store} />
</div>
<EditorTabs store={store} />
</Show>
</div>
</div>
+26 -4
View File
@@ -4,21 +4,43 @@ export interface CardData {
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 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;
y1: number;
x2: number;
y2: number;
orientation?: "n" | "s" | "e" | "w";
fontSize?: number;
align?: "l" | "c" | "r";
align?: Align;
}
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;
x1: number;
y1: number;
@@ -26,7 +48,7 @@ export interface LayerConfig {
y2: number;
orientation?: "n" | "s" | "e" | "w";
fontSize?: number;
align?: "l" | "c" | "r";
align?: Align;
_key?: number;
}
+1 -1
View File
@@ -62,7 +62,7 @@ customElement("md-dice", { key: "" }, (props, { element }) => {
setRollDetail(rollResult.result.plainDetail);
setIsRolled(true);
if (effectiveKey()) {
setDiceResultToUrl(effectiveKey(), rollResult.total);
setDiceResultToUrl(effectiveKey(), rollResult.result.total);
}
};
+4 -7
View File
@@ -1,5 +1,5 @@
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 { extractSection } from "../data-loader";
import { parseMarkdown } from "../markdown";
@@ -27,9 +27,6 @@ customElement("md-embed", { headingBase: 0 }, (props, { element }) => {
const articlePath = articleEl?.getAttribute("data-src") || "";
const resolvedPath = resolvePath(articlePath, filePath);
// 计算嵌入文件的 iconPrefix,用 createMemo 包装避免每个 embed 都重新计算
const iconPrefix = createMemo(() => resolvePath(resolvedPath, "./assets"));
const [content] = createResource(
() => ({ path: resolvedPath, section }),
async ({ path, section }) => {
@@ -45,7 +42,7 @@ customElement("md-embed", { headingBase: 0 }, (props, { element }) => {
});
return (
<div class="md-embed not-prose">
<div class="md-embed">
<Show when={content.loading}>
<div class="text-gray-400 italic">...</div>
</Show>
@@ -57,8 +54,8 @@ customElement("md-embed", { headingBase: 0 }, (props, { element }) => {
<Show when={!content.loading && !content.error && content()}>
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: markdown render */}
<div
class="prose"
innerHTML={parseMarkdown(content()!, iconPrefix())}
class="prose text-black prose-sm"
innerHTML={parseMarkdown(content()!, resolvedPath)}
/>
</Show>
</div>
+214 -174
View File
@@ -1,9 +1,20 @@
import { customElement, noShadowDOM } from 'solid-element';
import { createSignal, For, Show, createEffect, createMemo, createResource } from 'solid-js';
import { marked } from '../markdown';
import { loadCSV, CSV, processVariables, isCSV } from './utils/csv-loader';
import { resolvePath } from './utils/path';
import { areAllLabelsNumeric, weightedRandomIndex } from './utils/weighted-random';
import { customElement, noShadowDOM } from "solid-element";
import {
createSignal,
For,
Show,
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 {
roll?: boolean;
@@ -16,191 +27,220 @@ interface TableRow {
[key: string]: string;
}
customElement('md-table', { roll: false, remix: false }, (props, { element }) => {
noShadowDOM();
const [rows, setRows] = createSignal<CSV<TableRow>>([] as unknown as CSV<TableRow>);
const [activeTab, setActiveTab] = createSignal(0);
const [activeGroup, setActiveGroup] = createSignal<string | null>(null);
const [bodyHtml, setBodyHtml] = createSignal('');
let tabsContainer: HTMLDivElement | undefined;
customElement(
"md-table",
{ roll: false, remix: false },
(props, { element }) => {
noShadowDOM();
const [rows, setRows] = createSignal<CSV<TableRow>>(
[] as unknown as CSV<TableRow>,
);
const [activeTab, setActiveTab] = createSignal(0);
const [activeGroup, setActiveGroup] = createSignal<string | null>(null);
const [bodyHtml, setBodyHtml] = createSignal("");
let tabsContainer: HTMLDivElement | undefined;
// 从 element 的 textContent 获取 CSV 路径或 inline CSV 数据
const rawContent = element?.textContent?.trim() || '';
// 从 element 的 textContent 获取 CSV 路径或 inline CSV 数据
const rawContent = element?.textContent?.trim() || "";
// 隐藏原始文本内容
if (element) {
element.textContent = '';
}
// 从父节点 article 的 data-src 获取当前 markdown 文件完整路径
const articleEl = element?.closest('article[data-src]');
const articlePath = articleEl?.getAttribute('data-src') || '';
// 如果是 inline CSV,直接使用;否则解析相对路径
const contentOrPath = isCSV(rawContent) ? rawContent : resolvePath(articlePath, rawContent);
// 使用 createResource 加载 CSV,自动响应路径变化并避免重复加载
const [csvData] = createResource(() => contentOrPath, loadCSV);
// 当数据加载完成后更新 rows
createEffect(() => {
const data = csvData();
if (data) {
// 将加载的数据赋值给 rowsCSV 类型已经包含 sourcePath 等属性
setRows(data as unknown as CSV<TableRow>);
// 隐藏原始文本内容
if (element) {
element.textContent = "";
}
});
// 检测是否有 group 列
const hasGroup = createMemo(() => {
const allRows = rows();
return allRows.length > 0 && 'group' in allRows[0];
});
// 从父节点 article 的 data-src 获取当前 markdown 文件完整路径
const articleEl = element?.closest("article[data-src]");
const articlePath = articleEl?.getAttribute("data-src") || "";
// 获取所有分组
const groups = createMemo(() => {
if (!hasGroup()) return [];
const allRows = rows();
const groupSet = new Set<string>();
for (const row of allRows) {
if (row.group) {
groupSet.add(row.group);
// 解析引用:inline CSV 直接使用,否则通过 registry 解析(含内联内容 id
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;
},
);
// 当数据加载完成后更新 rows,并为火花表设置 data-spark(供 RevealManager
// 悬停触发 /roll)。data-spark 在渲染时派生,不在扫描时注入。
createEffect(() => {
const content = csvData();
if (!content) return;
setRows(parseCSVString(content) as unknown as CSV<TableRow>);
if (element) {
const meta = parseSparkTableCsv(content);
if (meta) {
element.setAttribute("data-spark", meta.slug);
} else {
element.removeAttribute("data-spark");
}
}
}
return Array.from(groupSet).sort();
});
});
// 根据当前选中的分组过滤行
const filteredRows = createMemo(() => {
const allRows = rows();
const group = activeGroup();
if (!group) return allRows;
return allRows.filter(row => row.group === group);
});
// 检测是否有 group 列
const hasGroup = createMemo(() => {
const allRows = rows();
return allRows.length > 0 && "group" in allRows[0];
});
// 处理 body 内容中的 {{prop}} 语法并解析 markdown
const processBody = (body: string, currentRow: TableRow): string => {
// 使用 marked 解析 markdown
return marked.parse(processVariables(body, currentRow, rows(), filteredRows(), props.remix)) as string;
};
// 获取所有分组
const groups = createMemo(() => {
if (!hasGroup()) return [];
const allRows = rows();
const groupSet = new Set<string>();
for (const row of allRows) {
if (row.group) {
groupSet.add(row.group);
}
}
return Array.from(groupSet).sort();
});
// 更新 body 内容
const updateBodyContent = () => {
const filtered = filteredRows();
if (!csvData.loading && filtered.length > 0) {
const index = Math.min(activeTab(), filtered.length - 1);
const currentRow = filtered[index];
setBodyHtml(processBody(currentRow.body, currentRow));
}
};
// 根据当前选中的分组过滤行
const filteredRows = createMemo(() => {
const allRows = rows();
const group = activeGroup();
if (!group) return allRows;
return allRows.filter((row) => row.group === group);
});
// 监听 activeTab 和 activeGroup 变化并更新内容
createEffect(() => {
activeTab();
activeGroup();
updateBodyContent();
});
// 处理 body 内容中的 {{prop}} 语法并解析 markdown
const processBody = (body: string, currentRow: TableRow): string => {
// 使用 parseMarkdown 统一入口(设置图标 base path 等)
return parseMarkdown(
processVariables(body, currentRow, rows(), filteredRows(), props.remix),
articlePath,
);
};
// 切换分组时重置 tab 索引
const handleGroupChange = (group: string | null) => {
setActiveGroup(group);
setActiveTab(0);
};
// 更新 body 内容
const updateBodyContent = () => {
const filtered = filteredRows();
if (!csvData.loading && filtered.length > 0) {
const index = Math.min(activeTab(), filtered.length - 1);
const currentRow = filtered[index];
setBodyHtml(processBody(currentRow.body, currentRow));
}
};
// 随机切换 tab
const handleRoll = () => {
const filtered = filteredRows();
if (filtered.length === 0) return;
// 检查所有 label 是否都是整数/整数范围格式
const labels = filtered.map(row => row.label);
if (areAllLabelsNumeric(labels)) {
// 使用加权随机
const randomIndex = weightedRandomIndex(labels);
if (randomIndex !== -1) {
// 监听 activeTab 和 activeGroup 变化并更新内容
createEffect(() => {
activeTab();
activeGroup();
updateBodyContent();
});
// 切换分组时重置 tab 索引
const handleGroupChange = (group: string | null) => {
setActiveGroup(group);
setActiveTab(0);
};
// 随机切换 tab
const handleRoll = () => {
const filtered = filteredRows();
if (filtered.length === 0) return;
// 检查所有 label 是否都是整数/整数范围格式
const labels = filtered.map((row) => row.label);
if (areAllLabelsNumeric(labels)) {
// 使用加权随机
const randomIndex = weightedRandomIndex(labels);
if (randomIndex !== -1) {
setActiveTab(randomIndex);
}
} else {
// 使用简单随机
const randomIndex = Math.floor(Math.random() * filtered.length);
setActiveTab(randomIndex);
}
} else {
// 使用简单随机
const randomIndex = Math.floor(Math.random() * filtered.length);
setActiveTab(randomIndex);
}
// 滚动到可视区域
setTimeout(() => {
const activeButton = tabsContainer?.querySelector('.border-blue-600');
activeButton?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
}, 50);
};
return (
<div class="ttrpg-table">
<div class="flex items-center gap-2 border-b border-gray-200">
<div class="flex flex-col overflow-x-auto">
{/* 分组 tabs */}
<Show when={hasGroup()}>
<div class="flex gap-2 border-b border-gray-100 pb-2">
<button
onClick={() => handleGroupChange(null)}
class={`font-medium transition-colors ${
activeGroup() === null
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>
</button>
<For each={groups()}>
{(group) => (
<button
onClick={() => handleGroupChange(group)}
class={`font-medium transition-colors ${
activeGroup() === group
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>
{group}
</button>
)}
</For>
</div>
</Show>
{/* 内容 tabs */}
<div class="flex items-center gap-2">
<Show when={props.roll}>
<button
onClick={handleRoll}
class="text-gray-500 hover:text-gray-700 flex-shrink-0 cursor-pointer"
title="随机切换"
>
🎲
</button>
// 滚动到可视区域
setTimeout(() => {
const activeButton = tabsContainer?.querySelector(".border-blue-600");
activeButton?.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "nearest",
});
}, 50);
};
return (
<div class="ttrpg-table">
<div class="flex items-center gap-2 border-b border-gray-200">
<div class="flex flex-col overflow-x-auto">
{/* 分组 tabs */}
<Show when={hasGroup()}>
<div class="flex gap-2 border-b border-gray-100 pb-2">
<button
onClick={() => handleGroupChange(null)}
class={`font-medium transition-colors ${
activeGroup() === null
? "text-blue-600 border-b-2 border-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
>
</button>
<For each={groups()}>
{(group) => (
<button
onClick={() => handleGroupChange(group)}
class={`font-medium transition-colors ${
activeGroup() === group
? "text-blue-600 border-b-2 border-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
>
{group}
</button>
)}
</For>
</div>
</Show>
<div ref={tabsContainer} class="flex gap-1 overflow-x-auto flex-1 min-w-0">
<For each={filteredRows()}>
{(row, index) => (
<button
onClick={() => setActiveTab(index())}
class={`font-medium transition-colors flex-shrink-0 min-w-[1.6em] cursor-pointer ${
activeTab() === index()
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>
{row.label}
</button>
)}
</For>
{/* 内容 tabs */}
<div class="flex items-center gap-2">
<Show when={props.roll}>
<button
onClick={handleRoll}
class="text-gray-500 hover:text-gray-700 shrink-0 cursor-pointer"
title="随机切换"
>
🎲
</button>
</Show>
<div
ref={tabsContainer}
class="flex gap-1 overflow-x-auto flex-1 min-w-0"
>
<For each={filteredRows()}>
{(row, index) => (
<button
onClick={() => setActiveTab(index())}
class={`font-medium transition-colors shrink-0 min-w-[1.6em] cursor-pointer ${
activeTab() === index()
? "text-blue-600 border-b-2 border-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
>
{row.label}
</button>
)}
</For>
</div>
</div>
</div>
</div>
<div class="p-4 prose max-w-none">
<Show when={!csvData.loading && filteredRows().length > 0}>
<div innerHTML={bodyHtml()} />
</Show>
</div>
</div>
<div class="p-4 prose max-w-none">
<Show when={!csvData.loading && filteredRows().length > 0}>
<div innerHTML={bodyHtml()} />
</Show>
</div>
</div>
);
});
);
},
);
-216
View File
@@ -1,216 +0,0 @@
import {
createSignal,
onMount,
onCleanup,
Show, createEffect,
} from "solid-js";
import * as THREE from "three";
import { ThreeMFLoader } from "three/addons/loaders/3MFLoader.js";
export interface TokenViewerProps {
url: string | null;
}
export default function MdTokenViewer(props: TokenViewerProps) {
const [viewerRef, setViewerRef] = createSignal<HTMLDivElement | null>(null);
const [isLoaded, setIsLoaded] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
let scene: THREE.Scene | null = null;
let camera: THREE.PerspectiveCamera | null = null;
let renderer: THREE.WebGLRenderer | null = null;
let mesh: THREE.Mesh | null = null;
let group: THREE.Group | null = null;
let animationId: number | null = null;
let isDragging = false;
let previousMousePosition = { x: 0, y: 0 };
// 加载 3MF 用于预览
const load3mf = async (url: string) => {
const viewerEl = viewerRef();
if (!viewerEl) return;
// 清理旧的场景
if (group) {
scene?.remove(group);
group.traverse((obj) => {
if ((obj as THREE.Mesh).isMesh) {
const meshObj = obj as THREE.Mesh;
meshObj.geometry.dispose();
(meshObj.material as THREE.Material).dispose();
}
});
group = null;
mesh = null;
}
try {
const loader = new ThreeMFLoader();
const object = await loader.loadAsync(url);
// 3MF 文件可能返回一个 Group,包含多个 Mesh
group = object instanceof THREE.Group ? object : new THREE.Group().add(object);
group.rotateX(Math.PI);
// 为每个 mesh 启用原始颜色
group.traverse((child) => {
if ((child as THREE.Mesh).isMesh) {
const childMesh = child as THREE.Mesh;
const material = childMesh.material as THREE.MeshStandardMaterial;
// 保留原始顶点颜色或材质颜色
if (childMesh.geometry.attributes.color) {
material.vertexColors = true;
}
// 确保材质是标准材质以支持光照
if (!(childMesh.material instanceof THREE.MeshStandardMaterial)) {
childMesh.material = new THREE.MeshStandardMaterial({
color: material.color,
metalness: 0.3,
roughness: 0.7,
});
}
}
});
scene?.add(group);
mesh = group.children[0] as THREE.Mesh;
// 调整相机
if (camera && scene) {
const box = new THREE.Box3().setFromObject(group);
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
camera.position.set(maxDim * 2, maxDim * 2, maxDim * 2);
camera.lookAt(0, 0, 0);
}
setIsLoaded(true);
} catch (e) {
console.error("加载 3MF 预览失败:", e);
setError(e instanceof Error ? e.message : "加载模型失败");
}
};
// 初始化 Three.js 场景
onMount(() => {
const viewerEl = viewerRef();
if (!viewerEl) return;
// 创建场景
scene = new THREE.Scene();
scene.background = new THREE.Color(0xf3f4f6);
// 创建相机
camera = new THREE.PerspectiveCamera(
45,
viewerEl.clientWidth / viewerEl.clientHeight,
0.1,
1000
);
camera.position.set(100, 100, 100);
// 创建渲染器
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(viewerEl.clientWidth, viewerEl.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
viewerEl.appendChild(renderer.domElement);
// 添加灯光
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(50, 100, 50);
directionalLight.castShadow = true;
scene.add(directionalLight);
// 添加坐标轴辅助
const axesHelper = new THREE.AxesHelper(10);
scene.add(axesHelper);
// 鼠标控制
const handleMouseDown = (e: MouseEvent) => {
isDragging = true;
previousMousePosition = { x: e.clientX, y: e.clientY };
};
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging || !group) return;
const deltaX = e.clientX - previousMousePosition.x;
const deltaY = e.clientY - previousMousePosition.y;
group.rotation.y += deltaX * 0.01;
group.rotation.x += deltaY * 0.01;
previousMousePosition = { x: e.clientX, y: e.clientY };
};
const handleMouseUp = () => {
isDragging = false;
};
viewerEl.addEventListener("mousedown", handleMouseDown);
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
// 动画循环
const animate = () => {
if (scene && camera && renderer && group) {
if (!isDragging) {
group.rotation.y += 0.005; // 自动旋转
}
renderer.render(scene, camera);
}
animationId = requestAnimationFrame(animate);
};
animate();
// 清理函数
onCleanup(() => {
if (animationId) cancelAnimationFrame(animationId);
viewerEl.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
if (renderer) {
renderer.dispose();
viewerEl.removeChild(renderer.domElement);
}
if (scene) scene.clear();
});
});
createEffect(() => {
if (props.url) {
load3mf(props.url);
}
});
return (
<div
ref={setViewerRef}
class="relative border rounded-lg overflow-hidden bg-gray-50 aspect-square"
style={{ "min-height": "200px" }}
>
<div class="absolute top-2 left-2 z-10 bg-black/50 text-white text-xs px-2 py-1 rounded">
</div>
<Show when={error()}>
<div class="absolute inset-0 flex items-center justify-center bg-black/50 text-white">
{error()}
</div>
</Show>
<Show when={!isLoaded() && !error()}>
<div class="absolute inset-0 flex items-center justify-center text-gray-500">
<div class="animate-spin inline-block w-6 h-6 border-2 border-current border-t-transparent rounded-full mr-2" />
...
</div>
</Show>
</div>
);
}
-313
View File
@@ -1,313 +0,0 @@
import { customElement, noShadowDOM } from "solid-element";
import {
Show,
For,
createResource,
createMemo,
createSignal,
onCleanup,
} from "solid-js";
import { resolvePath } from "./utils/path";
import { traceImage, type TracedLayer } from "./utils/image-tracer";
import { generate3MF, type ExtrusionSettings } from "./utils/3mf-generator";
import MdTokenViewer from "./md-token-viewer";
export interface TokenProps {
size?: number; // 模型整体尺寸 (mm), 默认 50
defaultThickness?: number; // 默认图层厚度 (mm), 默认 2
}
interface LayerSettings {
id: string;
name: string;
enabled: boolean;
thickness: number;
color: string;
}
customElement("md-token", { size: 50, defaultThickness: 2 }, (props, { element }) => {
noShadowDOM();
const [showEditor, setShowEditor] = createSignal(false);
const [layers, setLayers] = createSignal<LayerSettings[]>([]);
const [modelUrl, setModelUrl] = createSignal<string | null>(null);
const [isGenerating, setIsGenerating] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
// 从 element 的 textContent 获取图片路径
const rawSrc = element?.textContent?.trim() || "";
// 隐藏原始文本内容
if (element) {
element.textContent = "";
}
// 从父节点 article 的 data-src 获取当前 markdown 文件完整路径
const articleEl = element?.closest("article[data-src]");
const articlePath = articleEl?.getAttribute("data-src") || "";
// 解析相对路径
const resolvedSrc = resolvePath(articlePath, rawSrc);
// 加载图片
const loadImage = (src: string): Promise<HTMLImageElement> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
};
const [image] = createResource(resolvedSrc, loadImage);
// 图像加载完成后进行矢量追踪
const [traceResult] = createResource(
() => image(),
async (img) => {
if (!img) return null;
try {
const traced = await traceImage(img);
return traced;
} catch (e) {
setError(e instanceof Error ? e.message : "矢量追踪失败");
return null;
}
}
);
// 初始化图层设置
createMemo(() => {
const result = traceResult();
if (result && result.layers.length > 0) {
const layerSettings: LayerSettings[] = result.layers.map((layer, index) => ({
id: layer.id,
name: layer.name || `图层 ${index + 1}`,
enabled: true,
thickness: props.defaultThickness || 2,
color: `#${layer.color.getHexString()}` || `hsl(${(index * 60) % 360}, 70%, 50%)`,
}));
setLayers(layerSettings);
}
});
// 生成 3MF 模型
const generateModel = async () => {
if (!image()) return;
setIsGenerating(true);
setError(null);
try {
const enabledLayers = layers().filter((l) => l.enabled);
if (enabledLayers.length === 0) {
setError("请至少选择一个图层");
setIsGenerating(false);
return;
}
const settings: ExtrusionSettings = {
size: props.size || 50,
layers: enabledLayers.map((l) => ({
id: l.id,
thickness: l.thickness,
})),
};
const modelBlob = await generate3MF(image()!, traceResult()!, settings);
const url = URL.createObjectURL(modelBlob);
setModelUrl(url);
} catch (e) {
setError(e instanceof Error ? e.message : "生成模型失败");
} finally {
setIsGenerating(false);
}
};
// 更新图层厚度
const updateLayerThickness = (layerId: string, thickness: number) => {
setLayers((prev) =>
prev.map((l) => (l.id === layerId ? { ...l, thickness } : l))
);
};
// 切换图层启用状态
const toggleLayer = (layerId: string) => {
setLayers((prev) =>
prev.map((l) => (l.id === layerId ? { ...l, enabled: !l.enabled } : l))
);
};
// 下载 3MF 文件
const downloadModel = () => {
const url = modelUrl();
if (!url) return;
const a = document.createElement("a");
a.href = url;
a.download = `token-${Date.now()}.3mf`;
a.click();
};
// 清理 URL
onCleanup(() => {
const url = modelUrl();
if (url) {
URL.revokeObjectURL(url);
}
});
const visible = createMemo(() => !image.loading && !!image());
return (
<div class="md-token-component my-4">
<Show when={visible()}>
<div class="flex flex-col gap-4">
{/* 预览区域 */}
<div class="flex flex-wrap gap-4 items-start">
{/* 原始图片预览 */}
<div class="flex-1 min-w-[200px]">
<h4 class="text-sm font-semibold mb-2 text-gray-700"></h4>
<div class="relative border rounded-lg overflow-hidden bg-gray-50">
<img
src={resolvedSrc}
alt="Token source"
class="max-w-full h-auto max-h-[300px] object-contain"
/>
</div>
</div>
{/* 3D 模型预览 */}
<Show when={modelUrl()}>
<div class="flex-1 min-w-[200px]">
<h4 class="text-sm font-semibold mb-2 text-gray-700">
3D
</h4>
<MdTokenViewer url={modelUrl()}/>
</div>
</Show>
</div>
{/* 错误信息 */}
<Show when={error()}>
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded">
{error()}
</div>
</Show>
{/* 控制面板 */}
<div class="border rounded-lg p-4 bg-white shadow-sm">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-800">
🎯 Token
</h3>
<button
onClick={() => setShowEditor(!showEditor())}
class="text-sm text-blue-600 hover:text-blue-800"
>
{showEditor() ? "隐藏设置" : "显示设置"}
</button>
</div>
<Show when={showEditor()}>
<div class="space-y-4">
{/* 整体尺寸设置 */}
<div class="flex items-center gap-4">
<label class="text-sm font-medium text-gray-700 min-w-[100px]">
(mm)
</label>
<input
type="range"
min="10"
max="100"
step="1"
value={props.size || 50}
class="flex-1"
onChange={(e) => {
const newSize = parseInt(e.target.value);
element?.setAttribute("size", newSize.toString());
}}
/>
<span class="text-sm text-gray-600 w-12 text-right">
{props.size || 50}mm
</span>
</div>
{/* 图层列表 */}
<div>
<h4 class="text-sm font-medium text-gray-700 mb-2">
</h4>
<div class="space-y-2 max-h-[300px] overflow-y-auto">
<For each={layers()}>
{(layer) => (
<div class="flex items-center gap-3 p-2 border rounded hover:bg-gray-50">
<input
type="checkbox"
checked={layer.enabled}
onChange={() => toggleLayer(layer.id)}
class="w-4 h-4"
/>
<div
class="w-4 h-4 rounded"
style={{ "background-color": layer.color }}
/>
<span class="flex-1 text-sm text-gray-700">
{layer.name}
</span>
<label class="text-xs text-gray-500">:</label>
<input
type="number"
min="0.5"
max="10"
step="0.5"
value={layer.thickness}
class="w-16 px-2 py-1 border rounded text-sm"
onChange={(e) =>
updateLayerThickness(
layer.id,
parseFloat(e.target.value) || 2
)
}
/>
<span class="text-xs text-gray-500">mm</span>
</div>
)}
</For>
</div>
</div>
{/* 生成按钮 */}
<div class="flex gap-2 pt-2">
<button
onClick={generateModel}
disabled={isGenerating()}
class="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white px-4 py-2 rounded font-medium transition-colors"
>
{isGenerating() ? "生成中..." : "🔄 生成 3D 模型"}
</button>
<Show when={modelUrl()}>
<button
onClick={downloadModel}
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded font-medium transition-colors"
>
📥 3MF
</button>
</Show>
</div>
</div>
</Show>
</div>
</div>
</Show>
{/* 加载状态 */}
<Show when={image.loading}>
<div class="text-center py-8 text-gray-500">
<div class="animate-spin inline-block w-6 h-6 border-2 border-current border-t-transparent rounded-full mr-2" />
...
</div>
</Show>
</div>
);
});
+223 -386
View File
@@ -1,20 +1,22 @@
/**
* Journal Stream Client Store
* Journal Stream Client Store (Worker Proxy)
*
* Reactive state for a single session's message stream. Manages MQTT
* connection, local message log, per-sender sequence tracking, and the
* revealed-paths set (populated by the link reducer).
* This is the main-thread side of the journal stream. It spawns a Shared
* Worker that owns the MQTT connection. All state flows from the worker
* to this store via Comlink callbacks.
*
* Session lifecycle (create/list/delete) is handled via MQTT retained
* topics ttrpg/$SESSIONS for the manifest and ttrpg/{id}/meta per session.
*
* No persistence here that's the CLI server's job via JSONL append.
* The public API is unchanged components still call `useJournalStream()`,
* `sendMessage()`, etc. exactly as before.
*/
import { createStore, produce } from "solid-js/store";
import { createSignal } from "solid-js";
import * as Comlink from "comlink";
import type { StreamMessage } from "../journal/registry";
import { getMessageType, validatePayload } from "../journal/registry";
import { rebuildReactivityFromStore } from "../journal/var-reactivity";
import type { WorkerState, WorkerPatch, SessionManifest } from "../../workers/journal.worker";
import type { JournalWorkerAPI } from "../../workers/journal.worker";
import {
loadPersisted,
saveName,
@@ -32,33 +34,18 @@ import {
export interface JournalStreamState {
sessionId: string | null;
/** Human-readable name for the current session (from manifest) */
sessionName: string | null;
/** Full message log, oldest-first */
messages: StreamMessage[];
/** Last sequence number per sender */
senderSeq: Record<string, number>;
/**
* Paths and sections revealed by link messages.
* Key: normalized path (no .md). Value: set of revealed section slugs.
* An empty set means the whole article is revealed.
* Populated during hydration and live receipt via the type's reducer.
*/
revealedPaths: Record<string, Set<string>>;
/** MQTT connection status */
connected: boolean;
/** Granular connection state for UI indicators */
connectionStatus: "disconnected" | "connecting" | "connected" | "error";
/** Last connection error message, if any */
connectionError: string | null;
/** This client's identity */
myName: string;
/** Role: gm | player | observer. Immutable while connected. */
myRole: "gm" | "player" | "observer";
/** Broker URL, set after connect */
brokerUrl: string | null;
/** Active player list (keyed by player name) */
players: Record<string, { role: string }>;
variables: Record<string, string>;
}
export interface SessionMeta {
@@ -67,8 +54,24 @@ export interface SessionMeta {
players: string[];
}
export interface SessionManifest {
sessions: Record<string, SessionMeta>;
export type { SessionManifest } from "../../workers/journal.worker";
// ---------------------------------------------------------------------------
// Worker connection
// ---------------------------------------------------------------------------
let _workerAPI: Comlink.Remote<JournalWorkerAPI> | null = null;
function getWorkerAPI(): Comlink.Remote<JournalWorkerAPI> {
if (!_workerAPI) {
const worker = new SharedWorker(
new URL("../../workers/journal.worker.ts", import.meta.url),
);
_workerAPI = Comlink.wrap<JournalWorkerAPI>(worker.port);
worker.port.start();
}
return _workerAPI;
}
// ---------------------------------------------------------------------------
@@ -78,7 +81,6 @@ export interface SessionManifest {
const persisted = loadPersisted();
const urlParams = readUrlParams();
// URL params override localStorage if present
const initialName = urlParams.playerName ?? persisted.myName;
const initialSession = urlParams.sessionId ?? persisted.lastSessionId;
@@ -95,9 +97,9 @@ const [state, setState] = createStore<JournalStreamState>({
myRole: (persisted.myRole as "gm" | "player" | "observer") || "gm",
brokerUrl: persisted.brokerUrl,
players: {},
variables: {},
});
// Sync initial URL params if they came from localStorage (not URL)
if (initialName && !urlParams.playerName) syncUrlParam("player", initialName);
if (initialSession && !urlParams.sessionId)
syncUrlParam("session", initialSession);
@@ -110,56 +112,10 @@ const [sessionList, setSessionList] = createSignal<SessionManifest>({
export { sessionList as sessions };
/**
* Change the current player's name. Persisted to localStorage so it
* survives page reloads. Also syncs to URL search param.
*/
export function setMyName(name: string): void {
setState("myName", name);
saveName(name);
syncUrlParam("player", name);
}
/**
* Change the current player's role. Persisted to localStorage.
* Only callable when disconnected.
*/
export function setMyRole(role: "gm" | "player" | "observer"): void {
setState("myRole", role);
saveRole(role);
}
/**
* Set the active session ID and sync to URL. Also resolves the human-readable
* session name from the cached manifest.
*/
export function setSessionId(id: string | null): void {
setState("sessionId", id);
if (id) {
saveSessionId(id);
syncUrlParam("session", id);
// Resolve session name from current manifest
const manifest = sessionList();
const name = manifest.sessions[id]?.name ?? null;
setState("sessionName", name);
} else {
removeUrlParam("session");
setState("sessionName", null);
}
}
// Will hold the MQTT client instance after connect()
let _mqttClient: import("mqtt").MqttClient | null = null;
let _mqttConnected = false;
// ---------------------------------------------------------------------------
// Helpers
// Reducer runner — runs locally in each tab
// ---------------------------------------------------------------------------
function makeMessageId(sender: string, seq: number): string {
return `${sender}-${seq}`;
}
function runReducer(msg: StreamMessage): void {
const def = getMessageType(msg.type);
if (def?.reducer) {
@@ -167,258 +123,195 @@ function runReducer(msg: StreamMessage): void {
}
}
const $SESSIONS = "ttrpg/$SESSIONS";
// ---------------------------------------------------------------------------
// Hydration (initial load from server)
// ---------------------------------------------------------------------------
/**
* Load the full message history from the static server's JSONL file.
* The file is served from the same HTTP origin as the web app.
* Runs all reducers in order.
*/
export async function hydrateFromServer(sessionId: string): Promise<void> {
const response = await fetch(
`/.ttrpg/sessions/${encodeURIComponent(sessionId)}/stream.jsonl`,
);
if (!response.ok) {
if (response.status === 404) {
return; // fresh session, no file yet
}
throw new Error(`Failed to load session: ${response.statusText}`);
/** Replay all messages through reducers to rebuild derived state. */
function replayReducers(): void {
// Reset derived state
setState("revealedPaths", {});
setState("variables", {});
for (const msg of state.messages) {
runReducer(msg);
}
}
const text = await response.text();
const lines = text.split("\n").filter((l) => l.trim());
// ---------------------------------------------------------------------------
// Worker patch handler
// ---------------------------------------------------------------------------
const messages: StreamMessage[] = [];
const senderSeq: Record<string, number> = {};
function handlePatch(patch: WorkerPatch): void {
switch (patch.type) {
case "fullState": {
setState(
produce((s) => {
s.sessionId = patch.state.sessionId;
s.sessionName = patch.state.sessionName;
s.messages = patch.state.messages;
s.senderSeq = patch.state.senderSeq;
s.connected = patch.state.connected;
s.connectionStatus = patch.state.connectionStatus;
s.connectionError = patch.state.connectionError;
s.myName = patch.state.myName;
s.myRole = patch.state.myRole;
s.brokerUrl = patch.state.brokerUrl;
s.players = patch.state.players;
}),
);
// Persist name/role so URL and localStorage stay in sync
if (patch.state.myName) {
saveName(patch.state.myName);
syncUrlParam("player", patch.state.myName);
}
saveRole(patch.state.myRole);
// Rebuild derived state (revealedPaths, variables) by replaying reducers
replayReducers();
// Rebuild local reactivity engine state so tag activations from
// hydrated variables are tracked for subsequent cascade computations.
rebuildReactivityFromStore(state.variables);
break;
}
for (const line of lines) {
try {
const msg: StreamMessage = JSON.parse(line);
messages.push(msg);
senderSeq[msg.sender] = Math.max(senderSeq[msg.sender] ?? 0, msg.seq);
case "message": {
const msg = patch.message;
const existingIdx = state.messages.findIndex((m) => m.id === msg.id);
if (existingIdx !== -1) {
setState("messages", existingIdx, msg);
} else {
setState(
produce((s) => {
s.messages.push(msg);
s.senderSeq[msg.sender] = Math.max(
s.senderSeq[msg.sender] ?? 0,
msg.seq,
);
}),
);
}
runReducer(msg);
} catch {
/* skip corrupt */
break;
}
case "connectionStatus": {
setState("connectionStatus", patch.status);
if (patch.error !== undefined) {
setState("connectionError", patch.error);
}
if (patch.status === "connected") {
setState("connected", true);
} else if (patch.status === "disconnected") {
setState("connected", false);
}
break;
}
case "players": {
setState("players", patch.players);
break;
}
case "sessionName": {
setState("sessionName", patch.name);
break;
}
case "sessionList": {
setSessionList(patch.sessions);
break;
}
}
}
setState(
produce((s) => {
s.messages = messages;
s.senderSeq = senderSeq;
s.sessionId = sessionId;
// ---------------------------------------------------------------------------
// Subscribe to worker (called once per tab)
// ---------------------------------------------------------------------------
let _subscribed = false;
async function ensureSubscribed(): Promise<void> {
if (_subscribed) return;
_subscribed = true;
const api = getWorkerAPI();
await api.subscribe(
Comlink.proxy((patch: WorkerPatch) => {
handlePatch(patch);
}),
);
}
// Start subscription eagerly
ensureSubscribed();
// ---------------------------------------------------------------------------
// MQTT Connect
// Public API — same signatures as before
// ---------------------------------------------------------------------------
/**
* Connect to the MQTT broker, subscribe to the session stream, session
* list, and session meta. Must be called after `hydrateFromServer`.
*/
export function setMyName(name: string): void {
setState("myName", name);
saveName(name);
syncUrlParam("player", name);
}
export function setMyRole(role: "gm" | "player" | "observer"): void {
setState("myRole", role);
saveRole(role);
}
export function setSessionId(id: string | null): void {
setState("sessionId", id);
if (id) {
saveSessionId(id);
syncUrlParam("session", id);
} else {
removeUrlParam("session");
setState("sessionName", null);
}
}
export async function connectStream(
sessionId: string,
brokerUrl: string,
): Promise<void> {
const { default: mqtt } = await import("mqtt");
const api = getWorkerAPI();
setState("connectionStatus", "connecting");
setState("connectionError", null);
return new Promise<void>((resolve, reject) => {
const client = mqtt.connect(brokerUrl, {
clientId: `${state.myName}-${state.myRole}-${Date.now()}`,
protocol: brokerUrl.startsWith("wss") ? "wss" : "ws",
reconnectPeriod: 2000,
});
_mqttClient = client;
client.on("connect", () => {
_mqttConnected = true;
setState("connected", true);
setState("connectionStatus", "connected");
setState("connectionError", null);
setState("brokerUrl", brokerUrl);
// Persist connection info for next time
saveBrokerUrl(brokerUrl);
saveSessionId(sessionId);
client.subscribe(`ttrpg/${sessionId}/stream`, { qos: 1 }, (err) => {
if (err) console.error("[stream] stream sub err:", err);
});
client.subscribe($SESSIONS, { qos: 1 }, (err) => {
if (err) console.error("[stream] sessions sub err:", err);
});
client.subscribe(`ttrpg/${sessionId}/meta`, { qos: 1 });
// Presence tracking
client.subscribe(`ttrpg/${sessionId}/presence/+`, { qos: 1 });
// Publish own presence (retained)
const presenceData = JSON.stringify({
name: state.myName,
role: state.myRole,
});
client.publish(
`ttrpg/${sessionId}/presence/${state.myName}`,
presenceData,
{ qos: 1, retain: true },
);
resolve();
});
client.on("error", (err) => {
console.error("[stream] mqtt error:", err);
setState("connectionStatus", "error");
setState("connectionError", err.message);
reject(err);
});
client.on("close", () => {
_mqttConnected = false;
setState("connected", false);
setState("connectionStatus", "disconnected");
});
client.on("message", (topic, payload) => {
const raw = payload.toString();
const parts = topic.split("/");
if (topic === $SESSIONS) {
// Session manifest update
try {
const manifest: SessionManifest = JSON.parse(raw);
setSessionList(manifest);
// Refresh the sessionName if we're in a session now
const currentId = state.sessionId;
if (currentId && manifest.sessions[currentId]) {
setState("sessionName", manifest.sessions[currentId].name);
}
} catch (e) {
console.error("[stream] manifest parse err:", e);
}
return;
}
if (parts.length >= 3 && parts[0] === "ttrpg" && parts[2] === "meta") {
// Session meta update — manifest will be republished by server,
// handled via $SESSIONS subscription above.
return;
}
// Presence: ttrpg/{sessionId}/presence/{playerName}
if (
parts.length >= 4 &&
parts[0] === "ttrpg" &&
parts[2] === "presence"
) {
const playerName = parts[3];
if (raw) {
try {
const presence = JSON.parse(raw);
setState("players", playerName, {
role: presence.role || "player",
});
} catch {
setState("players", playerName, { role: "player" });
}
} else {
// Tombstone — player disconnected
setState(
produce((s) => {
delete s.players[playerName];
}),
);
}
return;
}
if (parts.length >= 3 && parts[0] === "ttrpg" && parts[2] === "stream") {
try {
const msg: StreamMessage = JSON.parse(raw);
receiveMessage(msg);
} catch (e) {
console.error("[stream] malformed message:", e);
}
}
});
});
try {
await api.connect(sessionId, brokerUrl, state.myName, state.myRole);
saveBrokerUrl(brokerUrl);
saveSessionId(sessionId);
} catch (err) {
setState("connectionStatus", "error");
setState(
"connectionError",
err instanceof Error ? err.message : "Connection failed",
);
throw err;
}
}
// ---------------------------------------------------------------------------
// Session lifecycle
// ---------------------------------------------------------------------------
/**
* Create a new session by publishing retained metadata to its meta topic.
* The server picks it up and adds it to the $SESSIONS manifest.
*/
export function createSession(name: string, players: string[] = []): void {
if (!_mqttClient || !_mqttConnected) return;
const id =
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") || "session";
const meta: SessionMeta = { name, created: Date.now(), players };
_mqttClient.publish(`ttrpg/${id}/meta`, JSON.stringify(meta), {
qos: 1,
retain: true,
});
}
/**
* Delete a session: publish tombstone (empty payload) to its meta topic.
*/
export function deleteSession(sessionId: string): void {
if (!_mqttClient || !_mqttConnected) return;
_mqttClient.publish(`ttrpg/${sessionId}/meta`, "", { qos: 1, retain: true });
}
// ---------------------------------------------------------------------------
// Send
// ---------------------------------------------------------------------------
/**
* Publish a message to the stream. Validates the payload against the
* registered Zod schema before sending. Auto-increments the sender's seq.
*/
export function sendMessage<T>(
type: string,
payload: T,
):
{ success: true; msg: StreamMessage<T> } | { success: false; error: string } {
if (!_mqttClient || !_mqttConnected) {
return { success: false, error: "Not connected to stream" };
}
const sessionId = state.sessionId;
if (!sessionId) {
return { success: false, error: "No active session" };
}
| { success: true; msg: StreamMessage<T> }
| { success: false; error: string } {
const api = getWorkerAPI();
// Validate locally first
const validation = validatePayload(type, payload);
if (!validation.success) {
return { success: false, error: validation.error };
}
// Send to worker (which publishes to MQTT)
// The worker will broadcast back via the message patch
const result = api.sendMessage(type, validation.data);
// We can't await the proxy result synchronously, so we optimistically
// return success. The actual message will arrive via the patch callback.
// For the sync API compatibility, we construct a placeholder.
const sender = state.myName;
const seq = (state.senderSeq[sender] ?? 0) + 1;
const id = makeMessageId(sender, seq);
const id = `${sender}-${seq}`;
const msg: StreamMessage<T> = {
id,
@@ -430,116 +323,52 @@ export function sendMessage<T>(
reverted: false,
};
const topic = `ttrpg/${sessionId}/stream`;
_mqttClient.publish(topic, JSON.stringify(msg), { qos: 1 }, (err) => {
if (err) console.error("[stream] publish error:", err);
});
// Optimistic local insert
receiveMessage(msg as StreamMessage);
return { success: true, msg };
}
// ---------------------------------------------------------------------------
// Receive
// ---------------------------------------------------------------------------
function receiveMessage(msg: StreamMessage): void {
const existing = state.messages.find((m) => m.id === msg.id);
if (existing) {
setState(
produce((s) => {
const idx = s.messages.findIndex((m) => m.id === msg.id);
if (idx !== -1) s.messages[idx] = msg;
}),
);
return;
}
setState(
produce((s) => {
s.messages.push(msg);
s.senderSeq[msg.sender] = Math.max(s.senderSeq[msg.sender] ?? 0, msg.seq);
}),
);
runReducer(msg);
}
// ---------------------------------------------------------------------------
// Revert
// ---------------------------------------------------------------------------
/**
* Revert the current sender's latest (highest seq) message.
* Only works if it's still the latest a subsequent message locks it.
*/
export function revertLatest():
{ success: true } | { success: false; error: string } {
if (!_mqttClient || !_mqttConnected) {
return { success: false, error: "Not connected to stream" };
}
const sessionId = state.sessionId;
if (!sessionId) return { success: false, error: "No active session" };
const sender = state.myName;
const latestSeq = state.senderSeq[sender];
if (!latestSeq) return { success: false, error: "No messages to revert" };
const id = makeMessageId(sender, latestSeq);
const original = state.messages.find((m) => m.id === id);
if (!original) return { success: false, error: "Message not found" };
if (original.reverted) return { success: false, error: "Already reverted" };
const reverted: StreamMessage = { ...original, reverted: true };
const topic = `ttrpg/${sessionId}/stream`;
_mqttClient.publish(topic, JSON.stringify(reverted), { qos: 1 }, (err) => {
if (err) console.error("[stream] revert publish error:", err);
});
return { success: true };
| { success: true }
| { success: false; error: string } {
const api = getWorkerAPI();
return api.revertLatest() as unknown as
| { success: true }
| { success: false; error: string };
}
// ---------------------------------------------------------------------------
// Disconnect
// ---------------------------------------------------------------------------
export function disconnectStream(): void {
if (_mqttClient) {
// Clear our presence before disconnecting (tombstone)
const sessionId = state.sessionId;
if (sessionId) {
_mqttClient.publish(`ttrpg/${sessionId}/presence/${state.myName}`, "", {
qos: 1,
retain: true,
});
}
// Force disconnect without reconnect
_mqttClient.end(true, void 0, () => {
// noop
});
_mqttClient = null;
_mqttConnected = false;
}
setState("connected", false);
setState("connectionStatus", "disconnected");
setState("players", {});
// Strip autojoin param so the dialog shows normally on next connect
const api = getWorkerAPI();
api.disconnect();
removeUrlParam("autojoin");
}
// ---------------------------------------------------------------------------
// Derived / helpers
// ---------------------------------------------------------------------------
export function createSession(
name: string,
players: string[] = [],
): string | null {
const api = getWorkerAPI();
// This is sync in the worker but async over Comlink.
// We generate the ID locally using the same algorithm.
const id =
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") || "session";
api.createSession(name, players);
return id;
}
export function deleteSession(sessionId: string): void {
const api = getWorkerAPI();
api.deleteSession(sessionId);
}
export function canRevert(): boolean {
const sender = state.myName;
const seq = state.senderSeq[sender];
if (!seq) return false;
const msg = state.messages.find((m) => m.id === makeMessageId(sender, seq));
const msg = state.messages.find(
(m) => m.id === `${sender}-${seq}`,
);
return msg !== undefined && !msg.reverted;
}
@@ -552,3 +381,11 @@ export function useJournalStream() {
}
export { state as journalStreamState };
/**
* Hydrate from server now handled by the worker during connect().
* Kept for API compatibility; does nothing.
*/
export async function hydrateFromServer(_sessionId: string): Promise<void> {
// Hydration is now handled by the worker during connect()
}

Some files were not shown because too many files have changed in this diff Show More