Compare commits
No commits in common. "e6d74cc3189b3f70035de049c968bf002fcd7cd6" and "3690d13407b46956bbd31450c5a3f2bc25406772" have entirely different histories.
e6d74cc318
...
3690d13407
|
|
@ -1,6 +1,7 @@
|
||||||
import { Component, createEffect, createMemo, createSignal } from "solid-js";
|
import { Component, createEffect, createMemo, createSignal } from "solid-js";
|
||||||
import { useLocation } from "@solidjs/router";
|
import { useLocation } from "@solidjs/router";
|
||||||
import { useJournalStream } from "./components/stores/journalStream";
|
import { useJournalStream } from "./components/stores/journalStream";
|
||||||
|
import { addRevealedClasses } from "./components/stores/reveal";
|
||||||
|
|
||||||
// 导入组件以注册自定义元素
|
// 导入组件以注册自定义元素
|
||||||
import "./components";
|
import "./components";
|
||||||
|
|
@ -10,7 +11,6 @@ import {
|
||||||
DesktopSidebar,
|
DesktopSidebar,
|
||||||
DocDialog,
|
DocDialog,
|
||||||
DataSourceDialog,
|
DataSourceDialog,
|
||||||
RevealManager,
|
|
||||||
} from "./components";
|
} from "./components";
|
||||||
import { generateToc, type FileNode, type TocNode } from "./data-loader";
|
import { generateToc, type FileNode, type TocNode } from "./data-loader";
|
||||||
import { JournalPanel } from "./components/journal";
|
import { JournalPanel } from "./components/journal";
|
||||||
|
|
@ -124,9 +124,8 @@ const App: Component = () => {
|
||||||
<Article
|
<Article
|
||||||
class="prose text-black prose-sm max-w-full flex-1"
|
class="prose text-black prose-sm max-w-full flex-1"
|
||||||
src={currentPath()}
|
src={currentPath()}
|
||||||
>
|
onDom={(dom) => addRevealedClasses(dom, location.pathname)}
|
||||||
<RevealManager />
|
/>
|
||||||
</Article>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<DocDialog isOpen={isDocOpen()} onClose={() => setIsDocOpen(false)} />
|
<DocDialog isOpen={isDocOpen()} onClose={() => setIsDocOpen(false)} />
|
||||||
|
|
|
||||||
|
|
@ -227,17 +227,13 @@ export function createContentServer(
|
||||||
host: string = "0.0.0.0",
|
host: string = "0.0.0.0",
|
||||||
): ContentServer {
|
): ContentServer {
|
||||||
let contentIndex: ContentIndex = {};
|
let contentIndex: ContentIndex = {};
|
||||||
let completionsIndex: CompletionsPayload = {
|
let completionsIndex: CompletionsPayload = { dice: [], links: [] };
|
||||||
dice: [],
|
|
||||||
links: [],
|
|
||||||
sparkTables: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Re-scan completions from current content index (cached) */
|
/** Re-scan completions from current content index (cached) */
|
||||||
function recomputeCompletions(): void {
|
function recomputeCompletions(): void {
|
||||||
completionsIndex = scanCompletions(contentIndex);
|
completionsIndex = scanCompletions(contentIndex);
|
||||||
console.log(
|
console.log(
|
||||||
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length}`,
|
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,22 +4,16 @@
|
||||||
|
|
||||||
import { diceSource } from "./sources/dice.js";
|
import { diceSource } from "./sources/dice.js";
|
||||||
import { linksSource } from "./sources/links.js";
|
import { linksSource } from "./sources/links.js";
|
||||||
import { sparkTablesSource } from "./sources/spark-tables.js";
|
|
||||||
import type { CompletionSource, CompletionsPayload } from "./types.js";
|
import type { CompletionSource, CompletionsPayload } from "./types.js";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
CompletionsPayload,
|
CompletionsPayload,
|
||||||
DiceCompletion,
|
DiceCompletion,
|
||||||
LinkCompletion,
|
LinkCompletion,
|
||||||
SparkTableCompletion,
|
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
/** Registered sources — open for extension */
|
/** Registered sources — open for extension */
|
||||||
const sources: CompletionSource[] = [
|
const sources: CompletionSource[] = [diceSource, linksSource];
|
||||||
diceSource,
|
|
||||||
linksSource,
|
|
||||||
sparkTablesSource,
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan the full content index and return structured completion data.
|
* Scan the full content index and return structured completion data.
|
||||||
|
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
/**
|
|
||||||
* Spark table completion source — extracts spark tables (markdown tables
|
|
||||||
* whose first column header is a dice formula like d6, d20, etc.) from all
|
|
||||||
* .md files.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Slugger from "github-slugger";
|
|
||||||
import type { CompletionSource, SparkTableCompletion } from "../types.js";
|
|
||||||
|
|
||||||
/** Regex: matches a pipe-delimited markdown table row */
|
|
||||||
function splitTableRow(line: string): string[] | null {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed.includes("|")) return null;
|
|
||||||
let inner = trimmed;
|
|
||||||
if (inner.startsWith("|")) inner = inner.slice(1);
|
|
||||||
if (inner.endsWith("|")) inner = inner.slice(0, -1);
|
|
||||||
return inner.split("|").map((c) => c.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
const SEP_RE = /^:?-{3,}:?$/;
|
|
||||||
const DICE_RE = /^d\d+$/i;
|
|
||||||
|
|
||||||
export const sparkTablesSource: CompletionSource = {
|
|
||||||
key: "sparkTables",
|
|
||||||
|
|
||||||
scan(index) {
|
|
||||||
const items: SparkTableCompletion[] = [];
|
|
||||||
|
|
||||||
for (const [filePath, content] of Object.entries(index)) {
|
|
||||||
if (!filePath.endsWith(".md")) continue;
|
|
||||||
|
|
||||||
// Fresh slugger per file so duplicate headers across files don't
|
|
||||||
// pollute each other. (github-slugger is stateful.)
|
|
||||||
const slugger = new Slugger();
|
|
||||||
|
|
||||||
const lines = content.split(/\r?\n/);
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const headerCells = splitTableRow(lines[i]);
|
|
||||||
if (!headerCells || headerCells.length < 2) continue;
|
|
||||||
if (!DICE_RE.test(headerCells[0])) continue;
|
|
||||||
|
|
||||||
// Check separator row
|
|
||||||
if (i + 1 >= lines.length) continue;
|
|
||||||
const sepCells = splitTableRow(lines[i + 1]);
|
|
||||||
if (!sepCells || !sepCells.every((c) => SEP_RE.test(c))) continue;
|
|
||||||
|
|
||||||
// Collect body rows
|
|
||||||
let j = i + 2;
|
|
||||||
while (j < lines.length) {
|
|
||||||
const rowCells = splitTableRow(lines[j]);
|
|
||||||
if (!rowCells) break;
|
|
||||||
j++;
|
|
||||||
}
|
|
||||||
if (j <= i + 2) continue; // No body rows
|
|
||||||
|
|
||||||
// Build slug from data columns
|
|
||||||
const dataHeaders = headerCells.slice(1);
|
|
||||||
const slug = dataHeaders
|
|
||||||
.map((h: string) => slugger.slug(h.toLowerCase()))
|
|
||||||
.join("-");
|
|
||||||
|
|
||||||
const basePath = filePath.replace(/\.md$/, "");
|
|
||||||
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
|
|
||||||
|
|
||||||
// Combined key: pageName-columnSlug (what user types after /spark)
|
|
||||||
const combinedSlug = `${fileName}-${slug}`;
|
|
||||||
|
|
||||||
items.push({
|
|
||||||
label: `${fileName} § ${slug}`,
|
|
||||||
notation: headerCells[0],
|
|
||||||
slug: combinedSlug,
|
|
||||||
filePath: basePath,
|
|
||||||
headers: dataHeaders,
|
|
||||||
});
|
|
||||||
|
|
||||||
i = j - 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -22,25 +22,10 @@ export interface LinkCompletion {
|
||||||
section: string | null;
|
section: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A spark table found in a markdown file */
|
|
||||||
export interface SparkTableCompletion {
|
|
||||||
/** Display label: "file § slug" */
|
|
||||||
label: string;
|
|
||||||
/** Dice notation (e.g. "d6", "d20") parsed from the first column header */
|
|
||||||
notation: string;
|
|
||||||
/** Concatenated slug of data column headers */
|
|
||||||
slug: string;
|
|
||||||
/** File path of the containing .md file */
|
|
||||||
filePath: string;
|
|
||||||
/** Data column headers for display */
|
|
||||||
headers: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Top-level payload served at /__COMPLETIONS.json */
|
/** Top-level payload served at /__COMPLETIONS.json */
|
||||||
export interface CompletionsPayload {
|
export interface CompletionsPayload {
|
||||||
dice: DiceCompletion[];
|
dice: DiceCompletion[];
|
||||||
links: LinkCompletion[];
|
links: LinkCompletion[];
|
||||||
sparkTables: SparkTableCompletion[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,17 @@
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
ParentProps,
|
|
||||||
createContext,
|
|
||||||
useContext,
|
|
||||||
createEffect,
|
createEffect,
|
||||||
onCleanup,
|
onCleanup,
|
||||||
Show,
|
Show,
|
||||||
createResource,
|
createResource,
|
||||||
createMemo,
|
createMemo,
|
||||||
createSignal,
|
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { parseMarkdown } from "../markdown";
|
import { parseMarkdown } from "../markdown";
|
||||||
import { extractSection } from "../data-loader";
|
import { extractSection } from "../data-loader";
|
||||||
import mermaid from "mermaid";
|
import mermaid from "mermaid";
|
||||||
import { getIndexedData } from "../data-loader/file-index";
|
import { getIndexedData } from "../data-loader/file-index";
|
||||||
import { resolvePath } from "./utils/path";
|
import { resolvePath } from "./utils/path";
|
||||||
import { useNavigateWithParams } from "./useNavigateWithParams";
|
import { useLocation } from "@solidjs/router";
|
||||||
|
|
||||||
export interface ArticleProps {
|
export interface ArticleProps {
|
||||||
src: string;
|
src: string;
|
||||||
|
|
@ -25,19 +21,7 @@ export interface ArticleProps {
|
||||||
onError?: (error: Error) => void;
|
onError?: (error: Error) => void;
|
||||||
class?: string; // 额外的 class 用于样式控制
|
class?: string; // 额外的 class 用于样式控制
|
||||||
scrollToHash?: boolean; // 是否自动滚动到 hash
|
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: {
|
async function fetchArticleContent(params: {
|
||||||
|
|
@ -75,13 +59,13 @@ function scrollToHash(hash: string) {
|
||||||
* Article 组件
|
* Article 组件
|
||||||
* 用于将特定 src 位置的 md 文件显示为 markdown 文章
|
* 用于将特定 src 位置的 md 文件显示为 markdown 文章
|
||||||
*/
|
*/
|
||||||
export const Article: Component<ArticleProps & ParentProps> = (props) => {
|
export const Article: Component<ArticleProps> = (props) => {
|
||||||
const navigate = useNavigateWithParams();
|
const location = useLocation();
|
||||||
const [content, { refetch }] = createResource(
|
const [content, { refetch }] = createResource(
|
||||||
() => ({ src: props.src, section: props.section }),
|
() => ({ src: props.src, section: props.section }),
|
||||||
fetchArticleContent,
|
fetchArticleContent,
|
||||||
);
|
);
|
||||||
const [contentDom, setContentDom] = createSignal<HTMLDivElement>();
|
let innerDiv!: HTMLDivElement;
|
||||||
|
|
||||||
// 解析 iconPath,默认为 "./assets",空字符串表示禁用
|
// 解析 iconPath,默认为 "./assets",空字符串表示禁用
|
||||||
const iconPrefix = createMemo(() => {
|
const iconPrefix = createMemo(() => {
|
||||||
|
|
@ -97,33 +81,12 @@ export const Article: Component<ArticleProps & ParentProps> = (props) => {
|
||||||
void mermaid.run();
|
void mermaid.run();
|
||||||
|
|
||||||
// 内容渲染后检查 hash 并滚动
|
// 内容渲染后检查 hash 并滚动
|
||||||
scrollToHash(window.location.hash);
|
scrollToHash(location.hash);
|
||||||
|
|
||||||
|
props.onDom?.(innerDiv);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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();
|
|
||||||
navigate(href);
|
|
||||||
};
|
|
||||||
|
|
||||||
dom.addEventListener("click", onClick);
|
|
||||||
onCleanup(() => dom.removeEventListener("click", onClick));
|
|
||||||
});
|
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
// 清理时清空内容,触发内部组件的销毁
|
// 清理时清空内容,触发内部组件的销毁
|
||||||
});
|
});
|
||||||
|
|
@ -140,14 +103,11 @@ export const Article: Component<ArticleProps & ParentProps> = (props) => {
|
||||||
<div class="text-red-500">加载失败:{content.error?.message}</div>
|
<div class="text-red-500">加载失败:{content.error?.message}</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!content.loading && !content.error && content()}>
|
<Show when={!content.loading && !content.error && content()}>
|
||||||
<ArticleDomCtx.Provider value={contentDom}>
|
<div
|
||||||
<div
|
class="relative"
|
||||||
class="relative"
|
ref={innerDiv}
|
||||||
ref={setContentDom}
|
innerHTML={parseMarkdown(content()!, iconPrefix())}
|
||||||
innerHTML={parseMarkdown(content()!, iconPrefix())}
|
/>
|
||||||
/>
|
|
||||||
{props.children}
|
|
||||||
</ArticleDomCtx.Provider>
|
|
||||||
</Show>
|
</Show>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Component, createMemo, createSignal, Show } from "solid-js";
|
import { Component, createMemo, createSignal, Show } from "solid-js";
|
||||||
import { type FileNode, type TocNode } from "../data-loader";
|
import { type FileNode, type TocNode } from "../data-loader";
|
||||||
import { useNavigateWithParams } from "./useNavigateWithParams";
|
import { useNavigate } from "@solidjs/router";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查当前文件路径是否在文件夹内
|
* 检查当前文件路径是否在文件夹内
|
||||||
|
|
@ -22,7 +22,7 @@ export const FileTreeNode: Component<{
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
isHidden?: (node: FileNode) => boolean;
|
isHidden?: (node: FileNode) => boolean;
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const navigate = useNavigateWithParams();
|
const navigate = useNavigate();
|
||||||
const isDir = !!props.node.children;
|
const isDir = !!props.node.children;
|
||||||
const isActive = createMemo(() => props.currentPath === props.node.path);
|
const isActive = createMemo(() => props.currentPath === props.node.path);
|
||||||
// 默认收起,除非当前文件在该文件夹内
|
// 默认收起,除非当前文件在该文件夹内
|
||||||
|
|
@ -85,7 +85,7 @@ export const HeadingNode: Component<{
|
||||||
depth: number;
|
depth: number;
|
||||||
isHidden?: (node: TocNode) => boolean;
|
isHidden?: (node: TocNode) => boolean;
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const navigate = useNavigateWithParams();
|
const navigate = useNavigate();
|
||||||
const anchor = props.node.id || "";
|
const anchor = props.node.id || "";
|
||||||
const href = `${props.basePath}#${anchor}`;
|
const href = `${props.basePath}#${anchor}`;
|
||||||
const hasChildren = !!props.node.children;
|
const hasChildren = !!props.node.children;
|
||||||
|
|
@ -99,7 +99,6 @@ export const HeadingNode: Component<{
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleClick = (e: MouseEvent) => {
|
const handleClick = (e: MouseEvent) => {
|
||||||
e.preventDefault();
|
|
||||||
navigate(href);
|
navigate(href);
|
||||||
// 滚动到目标元素,考虑导航栏高度偏移
|
// 滚动到目标元素,考虑导航栏高度偏移
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,200 +0,0 @@
|
||||||
/**
|
|
||||||
* 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(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: "/spark",
|
|
||||||
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 + window.scrollY - 6}px`,
|
|
||||||
left: `${btn().rect.left + window.scrollX - 20}px`,
|
|
||||||
}}
|
|
||||||
title={btn().title}
|
|
||||||
innerHTML={btn().svg}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
btn().action();
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => clearTimeout(hideTimer)}
|
|
||||||
onMouseLeave={hide}
|
|
||||||
/>
|
|
||||||
</Portal>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RevealManager;
|
|
||||||
|
|
@ -13,9 +13,7 @@ export interface DocEntry {
|
||||||
|
|
||||||
/** Splits frontmatter and markdown body from a raw .md string. */
|
/** Splits frontmatter and markdown body from a raw .md string. */
|
||||||
function parseFrontmatter(raw: string): Record<string, unknown> | null {
|
function parseFrontmatter(raw: string): Record<string, unknown> | null {
|
||||||
// Handle CRLF line endings by normalizing to LF first
|
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
||||||
const normalized = raw.replace(/\r\n/g, "\n");
|
|
||||||
const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
try {
|
try {
|
||||||
return yaml.load(match[1]) as Record<string, unknown>;
|
return yaml.load(match[1]) as Record<string, unknown>;
|
||||||
|
|
@ -25,8 +23,7 @@ function parseFrontmatter(raw: string): Record<string, unknown> | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBody(raw: string): string {
|
function getBody(raw: string): string {
|
||||||
const normalized = raw.replace(/\r\n/g, "\n");
|
const match = raw.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/);
|
||||||
const match = normalized.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/);
|
|
||||||
return match ? match[1] : raw;
|
return match ? match[1] : raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import "./md-token-viewer";
|
||||||
// 导出组件
|
// 导出组件
|
||||||
export { Article } from "./Article";
|
export { Article } from "./Article";
|
||||||
export type { ArticleProps } from "./Article";
|
export type { ArticleProps } from "./Article";
|
||||||
export { RevealManager } from "./RevealManager";
|
|
||||||
export { MobileSidebar, DesktopSidebar } from "./Sidebar";
|
export { MobileSidebar, DesktopSidebar } from "./Sidebar";
|
||||||
export type { SidebarProps } from "./Sidebar";
|
export type { SidebarProps } from "./Sidebar";
|
||||||
export { FileTreeNode, HeadingNode } from "./FileTree";
|
export { FileTreeNode, HeadingNode } from "./FileTree";
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,9 @@ import {
|
||||||
Match,
|
Match,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { sendMessage, useJournalStream } from "../stores/journalStream";
|
import { sendMessage, useJournalStream } from "../stores/journalStream";
|
||||||
import { actionPrefill, setActionPrefill } from "../stores/reveal";
|
import { linkPrefill, setLinkPrefill } from "../stores/reveal";
|
||||||
import { useJournalCompletions, ensureCompletions } from "./completions";
|
import { useJournalCompletions, ensureCompletions } from "./completions";
|
||||||
import { resolveRollPayload } from "./types/roll";
|
import { resolveRollPayload } from "./types/roll";
|
||||||
import { resolveSparkPayload } from "./types/spark";
|
|
||||||
|
|
||||||
// ---- Helpers ----
|
// ---- Helpers ----
|
||||||
|
|
||||||
|
|
@ -35,7 +34,7 @@ interface CompletionItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ParsedInput {
|
interface ParsedInput {
|
||||||
type: "chat" | "roll" | "spark" | "link";
|
type: "chat" | "roll" | "link";
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -48,13 +47,6 @@ function parseInput(raw: string): ParsedInput {
|
||||||
return { type: "roll", payload: { notation, label: notation } };
|
return { type: "roll", payload: { notation, label: notation } };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (raw.startsWith("/spark ")) {
|
|
||||||
const key = raw.slice("/spark ".length).trim();
|
|
||||||
if (!key)
|
|
||||||
return { type: "spark", payload: {}, error: "Spark table key required" };
|
|
||||||
return { type: "spark", payload: { key } };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (raw.startsWith("/link ")) {
|
if (raw.startsWith("/link ")) {
|
||||||
const arg = raw.slice("/link ".length).trim();
|
const arg = raw.slice("/link ".length).trim();
|
||||||
if (!arg) return { type: "link", payload: {}, error: "Path required" };
|
if (!arg) return { type: "link", payload: {}, error: "Path required" };
|
||||||
|
|
@ -65,8 +57,8 @@ function parseInput(raw: string): ParsedInput {
|
||||||
return { type: "link", payload: { path, section } };
|
return { type: "link", payload: { path, section } };
|
||||||
}
|
}
|
||||||
|
|
||||||
// /roll, /spark, or /link with no space — need to complete, don't send
|
// /roll or /link with no space — need to complete, don't send
|
||||||
if (raw === "/roll" || raw === "/spark" || raw === "/link") {
|
if (raw === "/roll" || raw === "/link") {
|
||||||
return { type: "chat", payload: {}, error: "Complete the command" };
|
return { type: "chat", payload: {}, error: "Complete the command" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,19 +89,19 @@ export const JournalInput: Component = () => {
|
||||||
void ensureCompletions();
|
void ensureCompletions();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for action prefill requests from article buttons (heading /link, spark table roll, etc.)
|
// Listen for /link prefill requests from article headings
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const action = actionPrefill();
|
const prefilled = linkPrefill();
|
||||||
if (action) {
|
if (prefilled) {
|
||||||
setText(`${action.command} ${action.text}`);
|
setText(prefilled);
|
||||||
setActionPrefill(null);
|
setLinkPrefill(null);
|
||||||
textareaRef?.focus();
|
textareaRef?.focus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Send ----
|
// ---- Send ----
|
||||||
|
|
||||||
async function handleSend() {
|
function handleSend() {
|
||||||
const raw = text().trim();
|
const raw = text().trim();
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
|
|
||||||
|
|
@ -150,30 +142,6 @@ export const JournalInput: Component = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GM spark: resolve the spark table roll locally
|
|
||||||
if (parsed.type === "spark") {
|
|
||||||
try {
|
|
||||||
const key = (parsed.payload as { key: string }).key;
|
|
||||||
// Look up filePath from completions data
|
|
||||||
const match = comp.data.sparkTables.find((s) => s.slug === key);
|
|
||||||
const filePath = match?.filePath ?? "";
|
|
||||||
const p = await resolveSparkPayload({ key, filePath });
|
|
||||||
const result = sendMessage("spark", p);
|
|
||||||
if (!result.success) {
|
|
||||||
setError(result.error);
|
|
||||||
} else {
|
|
||||||
setText("");
|
|
||||||
}
|
|
||||||
setSending(false);
|
|
||||||
textareaRef?.focus();
|
|
||||||
return;
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "Failed to roll spark table");
|
|
||||||
setSending(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = sendMessage(parsed.type, parsed.payload);
|
const result = sendMessage(parsed.type, parsed.payload);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setError(result.error);
|
setError(result.error);
|
||||||
|
|
@ -204,7 +172,6 @@ export const JournalInput: Component = () => {
|
||||||
const data = comp.data;
|
const data = comp.data;
|
||||||
const commands = [
|
const commands = [
|
||||||
{ label: "/roll", kind: "command" as const, insertText: "/roll " },
|
{ label: "/roll", kind: "command" as const, insertText: "/roll " },
|
||||||
{ label: "/spark", kind: "command" as const, insertText: "/spark " },
|
|
||||||
{ label: "/link", kind: "command" as const, insertText: "/link " },
|
{ label: "/link", kind: "command" as const, insertText: "/link " },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -239,32 +206,6 @@ export const JournalInput: Component = () => {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// After /spark — show spark table suggestions
|
|
||||||
if (raw.startsWith("/spark ")) {
|
|
||||||
const prefix = raw.slice("/spark ".length).toLowerCase();
|
|
||||||
const matches = data.sparkTables
|
|
||||||
.filter(
|
|
||||||
(s) =>
|
|
||||||
s.slug.toLowerCase().includes(prefix) ||
|
|
||||||
s.label.toLowerCase().includes(prefix),
|
|
||||||
)
|
|
||||||
.slice(0, 8);
|
|
||||||
if (matches.length === 0) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: "No spark tables found",
|
|
||||||
kind: "no-results",
|
|
||||||
insertText: "",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return matches.map((s) => ({
|
|
||||||
label: `${s.filePath} § ${s.slug} (${s.notation})`,
|
|
||||||
kind: "value" as const,
|
|
||||||
insertText: `/spark ${s.slug}`,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// After /link — show article and heading suggestions
|
// After /link — show article and heading suggestions
|
||||||
if (raw.startsWith("/link ")) {
|
if (raw.startsWith("/link ")) {
|
||||||
const prefix = raw.slice("/link ".length).toLowerCase();
|
const prefix = raw.slice("/link ".length).toLowerCase();
|
||||||
|
|
@ -498,7 +439,7 @@ export const JournalInput: Component = () => {
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={
|
placeholder={
|
||||||
isGm()
|
isGm()
|
||||||
? "Type a message, or /roll, /spark, or /link..."
|
? "Type a message, or /roll or /link..."
|
||||||
: "Type a message..."
|
: "Type a message..."
|
||||||
}
|
}
|
||||||
rows={2}
|
rows={2}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
import Slugger from "github-slugger";
|
|
||||||
import { extractHeadings } from "../../data-loader/toc";
|
import { extractHeadings } from "../../data-loader/toc";
|
||||||
import {
|
import {
|
||||||
getPathsByExtension,
|
getPathsByExtension,
|
||||||
|
|
@ -30,18 +29,9 @@ export interface LinkCompletion {
|
||||||
section: string | null;
|
section: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SparkTableCompletion {
|
|
||||||
label: string;
|
|
||||||
notation: string;
|
|
||||||
slug: string;
|
|
||||||
filePath: string;
|
|
||||||
headers: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JournalCompletions {
|
export interface JournalCompletions {
|
||||||
dice: DiceCompletion[];
|
dice: DiceCompletion[];
|
||||||
links: LinkCompletion[];
|
links: LinkCompletion[];
|
||||||
sparkTables: SparkTableCompletion[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CompletionsState =
|
export type CompletionsState =
|
||||||
|
|
@ -66,7 +56,6 @@ async function tryServer(): Promise<JournalCompletions | null> {
|
||||||
return {
|
return {
|
||||||
dice: Array.isArray(data.dice) ? data.dice : [],
|
dice: Array.isArray(data.dice) ? data.dice : [],
|
||||||
links: Array.isArray(data.links) ? data.links : [],
|
links: Array.isArray(data.links) ? data.links : [],
|
||||||
sparkTables: Array.isArray(data.sparkTables) ? data.sparkTables : [],
|
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -79,17 +68,12 @@ async function scanClientSide(): Promise<JournalCompletions> {
|
||||||
const paths = await getPathsByExtension("md");
|
const paths = await getPathsByExtension("md");
|
||||||
const dice: DiceCompletion[] = [];
|
const dice: DiceCompletion[] = [];
|
||||||
const links: LinkCompletion[] = [];
|
const links: LinkCompletion[] = [];
|
||||||
const sparkTables: SparkTableCompletion[] = [];
|
|
||||||
const tagRegex = /<md-dice[^>]*>\s*([\s\S]*?)\s*<\/md-dice>/gi;
|
const tagRegex = /<md-dice[^>]*>\s*([\s\S]*?)\s*<\/md-dice>/gi;
|
||||||
|
|
||||||
for (const filePath of paths) {
|
for (const filePath of paths) {
|
||||||
const content = await getIndexedData(filePath);
|
const content = await getIndexedData(filePath);
|
||||||
if (!content) continue;
|
if (!content) continue;
|
||||||
|
|
||||||
// Fresh slugger per file so duplicate headers across files don't
|
|
||||||
// pollute each other.
|
|
||||||
const slugger = new Slugger();
|
|
||||||
|
|
||||||
// Dice scan
|
// Dice scan
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
tagRegex.lastIndex = 0;
|
tagRegex.lastIndex = 0;
|
||||||
|
|
@ -111,52 +95,9 @@ async function scanClientSide(): Promise<JournalCompletions> {
|
||||||
section: heading.id ?? null,
|
section: heading.id ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spark table scan
|
|
||||||
const sparkLines = content.split(/\r?\n/);
|
|
||||||
for (let i = 0; i < sparkLines.length; i++) {
|
|
||||||
const headerCells = splitTableRow(sparkLines[i]);
|
|
||||||
if (!headerCells || headerCells.length < 2) continue;
|
|
||||||
if (!/^d\d+$/i.test(headerCells[0])) continue;
|
|
||||||
|
|
||||||
if (i + 1 >= sparkLines.length) continue;
|
|
||||||
const sepCells = splitTableRow(sparkLines[i + 1]);
|
|
||||||
if (!sepCells || !sepCells.every((c) => /^:?-{3,}:?$/.test(c))) continue;
|
|
||||||
|
|
||||||
let j = i + 2;
|
|
||||||
while (j < sparkLines.length && splitTableRow(sparkLines[j])) j++;
|
|
||||||
if (j <= i + 2) continue;
|
|
||||||
|
|
||||||
const dataHeaders = headerCells.slice(1);
|
|
||||||
const stSlug = dataHeaders
|
|
||||||
.map((h) => slugger.slug(h.toLowerCase()))
|
|
||||||
.join("-");
|
|
||||||
|
|
||||||
// Combined key: pageName-columnSlug
|
|
||||||
const combinedSlug = `${fileName}-${stSlug}`;
|
|
||||||
|
|
||||||
sparkTables.push({
|
|
||||||
label: `${fileName} § ${stSlug}`,
|
|
||||||
notation: headerCells[0],
|
|
||||||
slug: combinedSlug,
|
|
||||||
filePath: basePath,
|
|
||||||
headers: dataHeaders,
|
|
||||||
});
|
|
||||||
|
|
||||||
i = j - 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { dice, links, sparkTables };
|
return { dice, links };
|
||||||
}
|
|
||||||
|
|
||||||
function splitTableRow(line: string): string[] | null {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed.includes("|")) return null;
|
|
||||||
let inner = trimmed;
|
|
||||||
if (inner.startsWith("|")) inner = inner.slice(1);
|
|
||||||
if (inner.endsWith("|")) inner = inner.slice(0, -1);
|
|
||||||
return inner.split("|").map((c) => c.trim());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------- Init (runs eagerly at import time) -------------------
|
// ------------------- Init (runs eagerly at import time) -------------------
|
||||||
|
|
@ -216,5 +157,5 @@ export function useJournalCompletions(): {
|
||||||
if (s.status === "loaded") {
|
if (s.status === "loaded") {
|
||||||
return { state: s, data: s.data };
|
return { state: s, data: s.data };
|
||||||
}
|
}
|
||||||
return { state: s, data: { dice: [], links: [], sparkTables: [] } };
|
return { state: s, data: { dice: [], links: [] } };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,5 @@
|
||||||
|
|
||||||
import "./chat";
|
import "./chat";
|
||||||
import "./roll";
|
import "./roll";
|
||||||
import "./spark";
|
|
||||||
import "./link";
|
import "./link";
|
||||||
import "./intent";
|
import "./intent";
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component } from "solid-js";
|
import { Component } from "solid-js";
|
||||||
import { useNavigateWithParams } from "../../useNavigateWithParams";
|
import { useNavigate } from "@solidjs/router";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { registerMessageType } from "../registry";
|
import { registerMessageType } from "../registry";
|
||||||
import { journalSetState } from "../../stores/journalStream";
|
import { journalSetState } from "../../stores/journalStream";
|
||||||
|
|
@ -22,15 +22,7 @@ export type LinkPayload = z.infer<typeof schema>;
|
||||||
|
|
||||||
function fileNameFromPath(path: string): string {
|
function fileNameFromPath(path: string): string {
|
||||||
const parts = path.split("/").filter(Boolean);
|
const parts = path.split("/").filter(Boolean);
|
||||||
return decodeURIComponent(parts[parts.length - 1] || path);
|
return parts[parts.length - 1] || path;
|
||||||
}
|
|
||||||
|
|
||||||
/** Encode a path, preserving / separators but encoding each segment */
|
|
||||||
function encodePath(path: string): string {
|
|
||||||
return path
|
|
||||||
.split("/")
|
|
||||||
.map((seg) => encodeURIComponent(seg))
|
|
||||||
.join("/");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Section slug → human-readable title */
|
/** Section slug → human-readable title */
|
||||||
|
|
@ -42,11 +34,11 @@ function slugToTitle(slug: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
const RevealLink: Component<LinkPayload> = (p) => {
|
const RevealLink: Component<LinkPayload> = (p) => {
|
||||||
const navigate = useNavigateWithParams();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const target = () => {
|
const target = () => {
|
||||||
let t = encodePath(p.path);
|
let t = p.path;
|
||||||
if (p.section) t += `#${encodeURIComponent(p.section)}`;
|
if (p.section) t += `#${p.section}`;
|
||||||
return t;
|
return t;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
/**
|
|
||||||
* 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 {
|
|
||||||
findSparkTableByCombinedSlug,
|
|
||||||
rollSparkTable,
|
|
||||||
scanSparkTables,
|
|
||||||
} from "../../utils/spark-table";
|
|
||||||
import { getIndexedData } from "../../../data-loader/file-index";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 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).
|
|
||||||
* `filePath` is the .md file path (without .md extension) from completions.
|
|
||||||
*/
|
|
||||||
export async function resolveSparkPayload(raw: {
|
|
||||||
key: string;
|
|
||||||
filePath: string;
|
|
||||||
}): Promise<SparkPayload> {
|
|
||||||
const mdPath = `/${raw.filePath.replace(/^\//, "")}.md`;
|
|
||||||
const pageName =
|
|
||||||
raw.filePath.replace(/^\//, "").split("/").filter(Boolean).pop() ||
|
|
||||||
raw.filePath;
|
|
||||||
|
|
||||||
let content: string;
|
|
||||||
try {
|
|
||||||
content = await getIndexedData(mdPath);
|
|
||||||
} catch {
|
|
||||||
throw new Error(`Failed to load file: "${mdPath}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta = findSparkTableByCombinedSlug(content, raw.key, pageName);
|
|
||||||
if (!meta) {
|
|
||||||
const available = scanSparkTables(content);
|
|
||||||
const names =
|
|
||||||
available.length > 0
|
|
||||||
? available.map((t) => `${pageName}-${t.slug}`).join(", ")
|
|
||||||
: "(none)";
|
|
||||||
throw new Error(
|
|
||||||
`Spark table "${raw.key}" not found in "${mdPath}". Available: ${names}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sparkResult = rollSparkTable(meta);
|
|
||||||
const firstRoll = rollFormula(meta.notation);
|
|
||||||
|
|
||||||
return {
|
|
||||||
label: raw.key,
|
|
||||||
notation: meta.notation,
|
|
||||||
sparkTable: sparkResult,
|
|
||||||
rollResult: firstRoll.result,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Render
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
registerMessageType<SparkPayload>({
|
|
||||||
type: "spark",
|
|
||||||
label: "Spark Table",
|
|
||||||
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,7 +62,7 @@ customElement("md-dice", { key: "" }, (props, { element }) => {
|
||||||
setRollDetail(rollResult.result.plainDetail);
|
setRollDetail(rollResult.result.plainDetail);
|
||||||
setIsRolled(true);
|
setIsRolled(true);
|
||||||
if (effectiveKey()) {
|
if (effectiveKey()) {
|
||||||
setDiceResultToUrl(effectiveKey(), rollResult.result.total);
|
setDiceResultToUrl(effectiveKey(), rollResult.total);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
/**
|
/**
|
||||||
* DOM reveal logic — applies revealed/concealed classes to article headings
|
* DOM reveal logic — applies revealed/concealed classes to article headings
|
||||||
* for non-GM clients.
|
* for non-GM clients, and injects "send to stream" link buttons for GM.
|
||||||
*
|
*
|
||||||
* All state is read from the journal stream store via `journalStreamState`.
|
* All state is read from the journal stream store via `journalStreamState`.
|
||||||
*
|
|
||||||
* Action buttons (link, spark) are handled by the RevealManager Solid component.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
|
|
@ -44,32 +42,14 @@ export function isPathRevealed(path: string, section?: string): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// actionPrefill signal — bridges article action buttons → JournalInput
|
// linkPrefill signal — bridges article heading buttons → JournalInput
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** A prefill action: what command + text to place in the journal input. */
|
/** Shared signal: when set, JournalInput prefills the textarea with this value. */
|
||||||
export interface PrefillAction {
|
export const [linkPrefill, setLinkPrefill] = createSignal<string | null>(null);
|
||||||
command: string;
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Shared signal: when set, JournalInput prefills the textarea. */
|
|
||||||
export const [actionPrefill, setActionPrefill] =
|
|
||||||
createSignal<PrefillAction | null>(null);
|
|
||||||
|
|
||||||
// Keep the old signal for backward compat; it's a convenience wrapper.
|
|
||||||
/** @deprecated Use actionPrefill instead. */
|
|
||||||
export const linkPrefill = () => {
|
|
||||||
const a = actionPrefill();
|
|
||||||
return a?.command === "/link" ? a.text : null;
|
|
||||||
};
|
|
||||||
/** @deprecated Use setActionPrefill instead. */
|
|
||||||
export function setLinkPrefill(text: string | null) {
|
|
||||||
setActionPrefill(text ? { command: "/link", text } : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// addRevealedClasses — non-GM only: applies revealed/concealed classes
|
// addRevealedClasses — GM injects buttons; non-GM applies revealed/concealed
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface HeadingEntry {
|
interface HeadingEntry {
|
||||||
|
|
@ -93,23 +73,18 @@ interface HeadingEntry {
|
||||||
*/
|
*/
|
||||||
export function addRevealedClasses(root: HTMLDivElement, path: string) {
|
export function addRevealedClasses(root: HTMLDivElement, path: string) {
|
||||||
const state = journalStreamState;
|
const state = journalStreamState;
|
||||||
if (!state.connected) {
|
if (!state.connected) return;
|
||||||
cleanupInjections(root);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GM sees everything — no classes needed
|
|
||||||
if (state.myRole === "gm") {
|
|
||||||
cleanupInjections(root);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = normalizePath(path);
|
const normalized = normalizePath(path);
|
||||||
|
|
||||||
|
// ---- GM mode: inject "send to stream" buttons on headings ----
|
||||||
|
if (state.myRole === "gm") {
|
||||||
|
injectSendButtons(root, normalized);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Non-GM mode: apply revealed/concealed classes ----
|
||||||
const revealed = state.revealedPaths[normalized];
|
const revealed = state.revealedPaths[normalized];
|
||||||
|
|
||||||
// Always clean up previous classes before applying new ones
|
|
||||||
cleanupInjections(root);
|
|
||||||
|
|
||||||
if (!revealed) return;
|
if (!revealed) return;
|
||||||
|
|
||||||
const headings = collectHeadings(root);
|
const headings = collectHeadings(root);
|
||||||
|
|
@ -117,16 +92,47 @@ export function addRevealedClasses(root: HTMLDivElement, path: string) {
|
||||||
applyClasses(root, revealedSet);
|
applyClasses(root, revealedSet);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---- GM helpers ----
|
||||||
// Cleanup — strip previously applied revealed/concealed classes
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function cleanupInjections(root: Element): void {
|
function injectSendButtons(root: Element, normalizedPath: string) {
|
||||||
root
|
const walk = (el: Element) => {
|
||||||
.querySelectorAll(".revealed, .concealed")
|
const tag = el.tagName.toUpperCase();
|
||||||
.forEach((el) => el.classList.remove("revealed", "concealed"));
|
if (HEADING_TAGS.has(tag)) {
|
||||||
|
const headingText = el.id || el.textContent?.trim() || "";
|
||||||
|
if (headingText) {
|
||||||
|
const btn = createSendButton(normalizedPath, headingText);
|
||||||
|
el.insertBefore(btn, el.firstChild);
|
||||||
|
(el as HTMLElement).classList.add("group", "flex", "items-center");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const child of el.children) walk(child);
|
||||||
|
};
|
||||||
|
for (const child of root.children) walk(child);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createSendButton(path: string, headingId: string): HTMLButtonElement {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.className =
|
||||||
|
"inline-flex items-center justify-center w-5 h-5 mr-1 -ml-6 " +
|
||||||
|
"text-gray-300 hover:text-blue-500 hover:bg-blue-50 rounded " +
|
||||||
|
"transition-colors align-middle opacity-0 group-hover:opacity-100 focus:opacity-100";
|
||||||
|
btn.title = "Send /link to stream";
|
||||||
|
btn.innerHTML = LINK_SVG;
|
||||||
|
btn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setLinkPrefill(`/link ${path}#${headingId}`);
|
||||||
|
});
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>";
|
||||||
|
|
||||||
// ---- Non-GM helpers ----
|
// ---- Non-GM helpers ----
|
||||||
|
|
||||||
function collectHeadings(root: Element): HeadingEntry[] {
|
function collectHeadings(root: Element): HeadingEntry[] {
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
/**
|
|
||||||
* useNavigateWithParams — wraps navigate() to preserve the current URL
|
|
||||||
* search params (e.g. ?session=abc&player=alice) when navigating to a
|
|
||||||
* new path.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useNavigate, useLocation } from "@solidjs/router";
|
|
||||||
|
|
||||||
export function useNavigateWithParams() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
return (to: string) => {
|
|
||||||
// Split hash from the path so we can re-attach it after search params
|
|
||||||
const hashIdx = to.indexOf("#");
|
|
||||||
const path = hashIdx >= 0 ? to.slice(0, hashIdx) : to;
|
|
||||||
const hash = hashIdx >= 0 ? to.slice(hashIdx) : "";
|
|
||||||
|
|
||||||
navigate(path + location.search + hash);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,234 +0,0 @@
|
||||||
/**
|
|
||||||
* Spark Table — markdown table where the first column header is a dice
|
|
||||||
* formula (d6, d20, d100, etc.). Rolling a spark table means:
|
|
||||||
*
|
|
||||||
* 1. Roll the dice formula once for each data column (non-dice columns)
|
|
||||||
* 2. Look up the row whose dice-column value matches each roll
|
|
||||||
* 3. Return the rolled values keyed by column slug
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Slugger from "github-slugger";
|
|
||||||
import { rollFormula } from "../md-commander/hooks";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface MarkdownTable {
|
|
||||||
headers: string[];
|
|
||||||
rows: string[][];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SparkTableColumn {
|
|
||||||
header: string;
|
|
||||||
slug: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SparkTableResult {
|
|
||||||
/** The dice notation (e.g. "d6", "d20") */
|
|
||||||
notation: string;
|
|
||||||
/** The roll results keyed by column slug */
|
|
||||||
columns: SparkTableColumn[];
|
|
||||||
/** Source file path */
|
|
||||||
source: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SparkTableMeta {
|
|
||||||
/** Dice notation from the first column header */
|
|
||||||
notation: string;
|
|
||||||
/** Concatenated slug of data columns */
|
|
||||||
slug: string;
|
|
||||||
/** Data column headers (excluding dice column) */
|
|
||||||
dataHeaders: string[];
|
|
||||||
/** Full list of rows */
|
|
||||||
rows: string[][];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Markdown table parser
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse all markdown tables from a markdown string.
|
|
||||||
* Handles both leading/trailing `|` styles and bare styles.
|
|
||||||
*/
|
|
||||||
export function parseMarkdownTables(markdown: string): MarkdownTable[] {
|
|
||||||
const tables: MarkdownTable[] = [];
|
|
||||||
const lines = markdown.split(/\r?\n/);
|
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const headerCells = splitTableRow(lines[i]);
|
|
||||||
if (!headerCells || headerCells.length < 2) continue;
|
|
||||||
|
|
||||||
// Peek at the next line — must be a separator row
|
|
||||||
if (i + 1 >= lines.length) continue;
|
|
||||||
const sepCells = splitTableRow(lines[i + 1]);
|
|
||||||
if (!sepCells || sepCells.length < headerCells.length) continue;
|
|
||||||
if (!sepCells.every((c) => /^:?-{3,}:?$/.test(c))) continue;
|
|
||||||
|
|
||||||
// Valid table header + separator — collect body rows
|
|
||||||
const rows: string[][] = [];
|
|
||||||
let j = i + 2;
|
|
||||||
while (j < lines.length) {
|
|
||||||
const rowCells = splitTableRow(lines[j]);
|
|
||||||
if (!rowCells) break;
|
|
||||||
// Allow rows with fewer cells (unfilled trailing columns)
|
|
||||||
rows.push(rowCells);
|
|
||||||
j++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only include tables with at least one data row
|
|
||||||
if (rows.length > 0) {
|
|
||||||
tables.push({ headers: headerCells, rows });
|
|
||||||
}
|
|
||||||
i = j - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return tables;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Split a pipe-delimited table row, stripping optional leading/trailing `|` */
|
|
||||||
function splitTableRow(line: string): string[] | null {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed.includes("|")) return null;
|
|
||||||
|
|
||||||
// Strip optional leading and trailing `|`
|
|
||||||
let inner = trimmed;
|
|
||||||
if (inner.startsWith("|")) inner = inner.slice(1);
|
|
||||||
if (inner.endsWith("|")) inner = inner.slice(0, -1);
|
|
||||||
|
|
||||||
return inner.split("|").map((c) => c.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Spark table detection & metadata
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const DICE_HEADER_RE = /^d\d+$/i;
|
|
||||||
|
|
||||||
/** Check whether a table is a spark table (first header is a dice formula) */
|
|
||||||
export function isSparkTable(table: MarkdownTable): boolean {
|
|
||||||
if (table.headers.length < 2) return false;
|
|
||||||
return DICE_HEADER_RE.test(table.headers[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate the spark table slug by concatenating slugs of all data column
|
|
||||||
* headers (excluding the dice column).
|
|
||||||
*/
|
|
||||||
export function sparkTableSlug(table: MarkdownTable): string {
|
|
||||||
const slugger = new Slugger();
|
|
||||||
return table.headers
|
|
||||||
.slice(1)
|
|
||||||
.map((h) => slugger.slug(h.toLowerCase()))
|
|
||||||
.join("-");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a spark table in the given markdown content matching `slug`.
|
|
||||||
* Returns null if not found.
|
|
||||||
*/
|
|
||||||
export function findSparkTable(
|
|
||||||
markdown: string,
|
|
||||||
slug: string,
|
|
||||||
): SparkTableMeta | null {
|
|
||||||
const tables = parseMarkdownTables(markdown);
|
|
||||||
for (const table of tables) {
|
|
||||||
if (!isSparkTable(table)) continue;
|
|
||||||
if (sparkTableSlug(table) === slug) {
|
|
||||||
return {
|
|
||||||
notation: table.headers[0],
|
|
||||||
slug,
|
|
||||||
dataHeaders: table.headers.slice(1),
|
|
||||||
rows: table.rows,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a spark table by its combined slug (`pageName-columnSlug`).
|
|
||||||
* Iterates every spark table in the content, constructs the combined slug,
|
|
||||||
* and returns the first match. Avoids the need to guess where the page
|
|
||||||
* name ends and the column slug begins (page names may contain `-`).
|
|
||||||
*/
|
|
||||||
export function findSparkTableByCombinedSlug(
|
|
||||||
markdown: string,
|
|
||||||
combinedSlug: string,
|
|
||||||
pageName: string,
|
|
||||||
): SparkTableMeta | null {
|
|
||||||
const tables = parseMarkdownTables(markdown);
|
|
||||||
for (const table of tables) {
|
|
||||||
if (!isSparkTable(table)) continue;
|
|
||||||
const colSlug = sparkTableSlug(table);
|
|
||||||
const candidate = `${pageName}-${colSlug}`;
|
|
||||||
if (candidate === combinedSlug) {
|
|
||||||
return {
|
|
||||||
notation: table.headers[0],
|
|
||||||
slug: colSlug,
|
|
||||||
dataHeaders: table.headers.slice(1),
|
|
||||||
rows: table.rows,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Scan all spark tables in a markdown file and return their metadata */
|
|
||||||
export function scanSparkTables(
|
|
||||||
markdown: string,
|
|
||||||
): Omit<SparkTableMeta, "rows">[] {
|
|
||||||
const tables = parseMarkdownTables(markdown);
|
|
||||||
return tables.filter(isSparkTable).map((table) => ({
|
|
||||||
notation: table.headers[0],
|
|
||||||
slug: sparkTableSlug(table),
|
|
||||||
dataHeaders: table.headers.slice(1),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Rolling
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Roll a spark table: for each data column, roll the dice formula and look
|
|
||||||
* up the corresponding row value.
|
|
||||||
*/
|
|
||||||
export function rollSparkTable(meta: SparkTableMeta): SparkTableResult {
|
|
||||||
const slugger = new Slugger();
|
|
||||||
const columns: SparkTableColumn[] = [];
|
|
||||||
|
|
||||||
for (let colIdx = 0; colIdx < meta.dataHeaders.length; colIdx++) {
|
|
||||||
const header = meta.dataHeaders[colIdx];
|
|
||||||
const slug = slugger.slug(header.toLowerCase());
|
|
||||||
|
|
||||||
const roll = rollFormula(meta.notation);
|
|
||||||
const rolledValue = roll.result.total;
|
|
||||||
|
|
||||||
// Find the row matching the rolled value — compare as integers,
|
|
||||||
// falling back to string comparison.
|
|
||||||
let value = `(no row for ${rolledValue})`;
|
|
||||||
for (const row of meta.rows) {
|
|
||||||
const diceCell = row[0] ?? "";
|
|
||||||
const cellNum = parseInt(diceCell.trim(), 10);
|
|
||||||
const match =
|
|
||||||
!isNaN(cellNum) && rolledValue === cellNum
|
|
||||||
? true
|
|
||||||
: String(rolledValue) === diceCell.trim();
|
|
||||||
if (match) {
|
|
||||||
value = row[colIdx + 1] ?? "";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
columns.push({ header, slug, value });
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
notation: meta.notation,
|
|
||||||
columns,
|
|
||||||
source: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import type { MarkedExtension, Tokens } from "marked";
|
import type { MarkedExtension, Tokens } from "marked";
|
||||||
import Slugger from "github-slugger";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将表格数据转换为 CSV 格式字符串
|
* 将表格数据转换为 CSV 格式字符串
|
||||||
|
|
@ -8,87 +7,67 @@ import Slugger from "github-slugger";
|
||||||
* @returns CSV 格式字符串
|
* @returns CSV 格式字符串
|
||||||
*/
|
*/
|
||||||
function tableToCSV(headers: string[], rows: string[][]): string {
|
function tableToCSV(headers: string[], rows: string[][]): string {
|
||||||
const escapeCell = (cell: string) => {
|
const escapeCell = (cell: string) => {
|
||||||
// 如果单元格包含逗号、换行或引号,需要转义
|
// 如果单元格包含逗号、换行或引号,需要转义
|
||||||
if (
|
if (cell.includes(',') || cell.includes('\n') || cell.includes('"') || cell.includes("#")) {
|
||||||
cell.includes(",") ||
|
return `"${cell.replace(/"/g, '""')}"`;
|
||||||
cell.includes("\n") ||
|
}
|
||||||
cell.includes('"') ||
|
return cell;
|
||||||
cell.includes("#")
|
};
|
||||||
) {
|
|
||||||
return `"${cell.replace(/"/g, '""')}"`;
|
|
||||||
}
|
|
||||||
return cell;
|
|
||||||
};
|
|
||||||
|
|
||||||
const headerLine = headers.map(escapeCell).join(",");
|
const headerLine = headers.map(escapeCell).join(',');
|
||||||
const dataLines = rows.map((row) => row.map(escapeCell).join(","));
|
const dataLines = rows.map(row => row.map(escapeCell).join(','));
|
||||||
|
|
||||||
return [headerLine, ...dataLines].join("\n");
|
return [headerLine, ...dataLines].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Spark table: first column header is a pure dice formula (d6, d20, etc.) */
|
|
||||||
const SPARK_DICE_RE = /^d\d+$/i;
|
|
||||||
|
|
||||||
export default function markedTable(): MarkedExtension {
|
export default function markedTable(): MarkedExtension {
|
||||||
return {
|
return {
|
||||||
renderer: {
|
renderer: {
|
||||||
table(token: Tokens.Table) {
|
table(token: Tokens.Table) {
|
||||||
// 检查表头是否包含 md-table-label
|
// 检查表头是否包含 md-table-label
|
||||||
const header = token.header;
|
const header = token.header;
|
||||||
let roll = "";
|
let roll = '';
|
||||||
let spark = "";
|
const labelIndex = header.findIndex(cell => {
|
||||||
const labelIndex = header.findIndex((cell) => {
|
if(cell.text === 'md-roll-label' || cell.text.match(/(\d+)?d\d+/)){
|
||||||
if (cell.text === "md-roll-label" || cell.text.match(/(\d+)?d\d+/)) {
|
roll = ' roll=true';
|
||||||
roll = " roll=true";
|
return true;
|
||||||
// If this is a spark table (first column is a pure dice formula),
|
}else if(cell.text === 'md-remix-label'){
|
||||||
// compute the data-column slug for DOM injection
|
roll = ' roll=true remix=true';
|
||||||
if (SPARK_DICE_RE.test(cell.text)) {
|
return true;
|
||||||
const slugger = new Slugger();
|
}
|
||||||
const dataHeaders = header
|
return cell.text === 'md-table-label' || cell.text === 'label';
|
||||||
.filter((_, i) => i !== 0)
|
});
|
||||||
.map((h) => h.text);
|
|
||||||
const slug = dataHeaders
|
// 默认表格渲染 - 使用 marked 默认行为
|
||||||
.map((h) => slugger.slug(h.toLowerCase()))
|
if(labelIndex === -1) return false;
|
||||||
.join("-");
|
|
||||||
spark = ` data-spark="${slug}"`;
|
const headers = token.header.map(cell => cell.text);
|
||||||
|
headers[labelIndex] = 'label';
|
||||||
|
const rows = token.rows.map(row => row.map(cell => cell.text));
|
||||||
|
|
||||||
|
if(header.findIndex(header => header.text === 'body') < 0){
|
||||||
|
// 收集所有非 label 列的表头
|
||||||
|
const bodyColumns = headers
|
||||||
|
.filter(cell => cell !== 'label');
|
||||||
|
|
||||||
|
// 构建 body 列的模板:**列名**:{{列名}}\n\n
|
||||||
|
const bodyTemplate = bodyColumns
|
||||||
|
.map(col => `**${col}**:{{${col}}}`)
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
|
headers.push('body');
|
||||||
|
rows.forEach(row => {
|
||||||
|
row.push(bodyTemplate);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 CSV 数据
|
||||||
|
const csvData = tableToCSV(headers, rows);
|
||||||
|
|
||||||
|
// 渲染为 md-table 组件,内联 CSV 数据
|
||||||
|
return `<md-table ${roll}>${csvData}</md-table>\n`;
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
} else if (cell.text === "md-remix-label") {
|
|
||||||
roll = " roll=true remix=true";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return cell.text === "md-table-label" || cell.text === "label";
|
|
||||||
});
|
|
||||||
|
|
||||||
// 默认表格渲染 - 使用 marked 默认行为
|
|
||||||
if (labelIndex === -1) return false;
|
|
||||||
|
|
||||||
const headers = token.header.map((cell) => cell.text);
|
|
||||||
headers[labelIndex] = "label";
|
|
||||||
const rows = token.rows.map((row) => row.map((cell) => cell.text));
|
|
||||||
|
|
||||||
if (header.findIndex((header) => header.text === "body") < 0) {
|
|
||||||
// 收集所有非 label 列的表头
|
|
||||||
const bodyColumns = headers.filter((cell) => cell !== "label");
|
|
||||||
|
|
||||||
// 构建 body 列的模板:**列名**:{{列名}}\n\n
|
|
||||||
const bodyTemplate = bodyColumns
|
|
||||||
.map((col) => `**${col}**:{{${col}}}`)
|
|
||||||
.join("\n\n");
|
|
||||||
|
|
||||||
headers.push("body");
|
|
||||||
rows.forEach((row) => {
|
|
||||||
row.push(bodyTemplate);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
// 生成 CSV 数据
|
|
||||||
const csvData = tableToCSV(headers, rows);
|
|
||||||
|
|
||||||
// 渲染为 md-table 组件,内联 CSV 数据
|
|
||||||
return `<md-table ${roll}${spark}>${csvData}</md-table>\n`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue