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).
This commit is contained in:
parent
736bcf4bb2
commit
182d7ff28d
|
|
@ -19,6 +19,7 @@ import {
|
||||||
DataSourceDialog,
|
DataSourceDialog,
|
||||||
RevealManager,
|
RevealManager,
|
||||||
ReactiveStatManager,
|
ReactiveStatManager,
|
||||||
|
CommandLinkManager,
|
||||||
} from "./components";
|
} from "./components";
|
||||||
import { generateToc, type FileNode, type TocNode } from "./data-loader";
|
import { generateToc, type FileNode, type TocNode } from "./data-loader";
|
||||||
import { JournalPanel } from "./components/journal";
|
import { JournalPanel } from "./components/journal";
|
||||||
|
|
@ -158,6 +159,7 @@ const App: Component = () => {
|
||||||
>
|
>
|
||||||
<RevealManager />
|
<RevealManager />
|
||||||
<ReactiveStatManager />
|
<ReactiveStatManager />
|
||||||
|
<CommandLinkManager />
|
||||||
</Article>
|
</Article>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,224 @@
|
||||||
|
/**
|
||||||
|
* CommandLinkManager — mounts as a child of <Article> and intercepts
|
||||||
|
* clicks on command-like links (>roll, >spark, >link, >stat) to send
|
||||||
|
* them as journal stream messages instead of navigating.
|
||||||
|
*
|
||||||
|
* Uses capture-phase event delegation so it fires before the Article
|
||||||
|
* component's own link interception (which would otherwise navigate).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, onCleanup } from "solid-js";
|
||||||
|
import { useArticleDom } from "./Article";
|
||||||
|
import { useJournalStream, sendMessage } from "./stores/journalStream";
|
||||||
|
import { useJournalCompletions } from "./journal/completions";
|
||||||
|
import { parseInput } from "./journal/command-parser";
|
||||||
|
import { resolveRollPayload } from "./journal/types/roll";
|
||||||
|
import { resolveSparkPayload } from "./journal/types/spark";
|
||||||
|
import {
|
||||||
|
resolveStatRoll,
|
||||||
|
resolveTemplateSet,
|
||||||
|
canModifyStat,
|
||||||
|
fullKey,
|
||||||
|
findStatDef,
|
||||||
|
} from "./journal/stat-helpers";
|
||||||
|
|
||||||
|
/** Commands that get intercepted as stream messages. */
|
||||||
|
const COMMAND_PREFIXES = [">roll", ">spark", ">link", ">stat"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the command text from an href.
|
||||||
|
* Percent-encoded spaces (%20) are decoded automatically by decodeURIComponent.
|
||||||
|
*/
|
||||||
|
function hrefToCommand(href: string): string | null {
|
||||||
|
// Exclude external URLs and pure anchors
|
||||||
|
if (/^(https?:|\/\/)/i.test(href)) return null;
|
||||||
|
if (href === "#" || href.startsWith("#!")) return null;
|
||||||
|
|
||||||
|
// Decode percent-encoding (%20 → space, etc.)
|
||||||
|
let cleaned = decodeURIComponent(href);
|
||||||
|
|
||||||
|
// Strip trailing .md extension (markdown links may include it)
|
||||||
|
if (cleaned.endsWith(".md")) {
|
||||||
|
cleaned = cleaned.slice(0, -3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only consider links starting with a command prefix
|
||||||
|
if (!COMMAND_PREFIXES.some((p) => cleaned.startsWith(p))) return null;
|
||||||
|
|
||||||
|
return cleaned.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// We need unwrap helper for sendMessage results
|
||||||
|
function unwrap<R>(r: { success: true; msg: R } | { success: false; error: string }) {
|
||||||
|
return r.success
|
||||||
|
? ({ ok: true, err: undefined } as const)
|
||||||
|
: ({ ok: false, err: r.error } as const);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CommandLinkManager: Component = () => {
|
||||||
|
const contentDom = useArticleDom();
|
||||||
|
const stream = useJournalStream();
|
||||||
|
const comp = useJournalCompletions();
|
||||||
|
|
||||||
|
const isObserver = () => stream.myRole === "observer";
|
||||||
|
const isPlayer = () => stream.myRole === "player";
|
||||||
|
|
||||||
|
// ---- Click interceptor (capture phase) ----
|
||||||
|
|
||||||
|
const onClick = (e: MouseEvent) => {
|
||||||
|
const anchor = (e.target as HTMLElement).closest("a[href]");
|
||||||
|
if (!anchor) return;
|
||||||
|
|
||||||
|
const href = anchor.getAttribute("href");
|
||||||
|
if (!href) return;
|
||||||
|
|
||||||
|
const command = hrefToCommand(href);
|
||||||
|
if (!command) return;
|
||||||
|
|
||||||
|
// This is a command link — intercept it
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
|
||||||
|
dispatchCommand(command);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dom = contentDom();
|
||||||
|
if (dom) {
|
||||||
|
// Use capture phase to beat the Article component's bubble-phase handler
|
||||||
|
dom.addEventListener("click", onClick, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
const d = contentDom();
|
||||||
|
if (d) d.removeEventListener("click", onClick, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Command dispatch ----
|
||||||
|
|
||||||
|
function dispatchCommand(raw: string) {
|
||||||
|
// Convert > prefix to / so parseInput understands it
|
||||||
|
const command = raw.startsWith(">") ? "/" + raw.slice(1) : raw;
|
||||||
|
|
||||||
|
// Observers: everything goes as chat
|
||||||
|
if (isObserver()) {
|
||||||
|
sendMessage("chat", { text: command });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseInput(command);
|
||||||
|
if (parsed.error) return;
|
||||||
|
|
||||||
|
// Players: chat + stat commands only
|
||||||
|
if (isPlayer()) {
|
||||||
|
if (parsed.type === "chat") {
|
||||||
|
sendMessage("chat", { text: raw });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsed.type === "stat") {
|
||||||
|
dispatchStat(parsed.payload as Record<string, unknown>);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GM: all commands
|
||||||
|
if (parsed.type === "roll") {
|
||||||
|
const p = resolveRollPayload(
|
||||||
|
parsed.payload as { notation: string; label?: string },
|
||||||
|
);
|
||||||
|
sendMessage("roll", p);
|
||||||
|
} else if (parsed.type === "spark") {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const key = (parsed.payload as { key: string }).key;
|
||||||
|
const match = comp.data.sparkTables.find((s) => s.slug === key);
|
||||||
|
const csvPath = match?.csvPath ?? "";
|
||||||
|
const remix = match?.remix ?? false;
|
||||||
|
const p = await resolveSparkPayload({ key, csvPath, remix });
|
||||||
|
sendMessage("spark", p);
|
||||||
|
} catch {
|
||||||
|
// silently ignore spark table errors from links
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
} else if (parsed.type === "stat") {
|
||||||
|
dispatchStat(parsed.payload as Record<string, unknown>);
|
||||||
|
} else {
|
||||||
|
sendMessage(parsed.type, parsed.payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve bare key -> full key (copied from JournalInput for standalone use). */
|
||||||
|
function resolveKey(
|
||||||
|
inputKey: string,
|
||||||
|
statDefs: typeof comp.data.stats,
|
||||||
|
playerName: string,
|
||||||
|
): string {
|
||||||
|
if (statDefs.some((d) => fullKey(d, playerName) === inputKey)) return inputKey;
|
||||||
|
const def = statDefs.find((d) => d.key === inputKey);
|
||||||
|
if (def) return fullKey(def, playerName);
|
||||||
|
return inputKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchStat(payload: Record<string, unknown>) {
|
||||||
|
const p = payload as { action?: string; key?: string; value?: string };
|
||||||
|
|
||||||
|
if (p.action === "roll" && p.key) {
|
||||||
|
const resolved = resolveStatRoll(
|
||||||
|
p.key,
|
||||||
|
comp.data.stats,
|
||||||
|
stream.stats,
|
||||||
|
stream.myName,
|
||||||
|
comp.data.statTemplates,
|
||||||
|
);
|
||||||
|
if (resolved.error) return;
|
||||||
|
|
||||||
|
const result = sendMessage("stat", {
|
||||||
|
action: "set",
|
||||||
|
key: resolved.fullKey,
|
||||||
|
value: resolved.value,
|
||||||
|
});
|
||||||
|
if (!unwrap(result).ok) return;
|
||||||
|
|
||||||
|
if (resolved.modifiers) {
|
||||||
|
for (const [mk, mv] of Object.entries(resolved.modifiers)) {
|
||||||
|
sendMessage("stat", { action: "set", key: mk, value: mv });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!p.action || !p.key) return;
|
||||||
|
|
||||||
|
const fk = resolveKey(p.key, comp.data.stats, stream.myName);
|
||||||
|
|
||||||
|
if (!canModifyStat(stream.myRole, stream.myName, fk, comp.data.stats)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendMessage("stat", { action: p.action, key: fk, value: p.value });
|
||||||
|
|
||||||
|
if (p.action === "set" && p.value) {
|
||||||
|
const def = findStatDef(fk, comp.data.stats, stream.myName);
|
||||||
|
if (def) {
|
||||||
|
const modifiers = resolveTemplateSet(
|
||||||
|
def,
|
||||||
|
p.value,
|
||||||
|
comp.data.stats,
|
||||||
|
stream.stats,
|
||||||
|
stream.myName,
|
||||||
|
comp.data.statTemplates,
|
||||||
|
);
|
||||||
|
if (modifiers) {
|
||||||
|
for (const [mk, mv] of Object.entries(modifiers)) {
|
||||||
|
sendMessage("stat", { action: "set", key: mk, value: mv });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null; // renders nothing — purely event-based
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CommandLinkManager;
|
||||||
|
|
@ -15,6 +15,7 @@ import "./md-yarn-spinner";
|
||||||
export { Article } from "./Article";
|
export { Article } from "./Article";
|
||||||
export type { ArticleProps } from "./Article";
|
export type { ArticleProps } from "./Article";
|
||||||
export { RevealManager } from "./RevealManager";
|
export { RevealManager } from "./RevealManager";
|
||||||
|
export { CommandLinkManager } from "./CommandLinkManager";
|
||||||
export { ReactiveStatManager } from "./journal/ReactiveStatManager";
|
export { ReactiveStatManager } from "./journal/ReactiveStatManager";
|
||||||
export { MobileSidebar, DesktopSidebar } from "./Sidebar";
|
export { MobileSidebar, DesktopSidebar } from "./Sidebar";
|
||||||
export type { SidebarProps } from "./Sidebar";
|
export type { SidebarProps } from "./Sidebar";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue