diff --git a/src/components/CommandLinkManager.tsx b/src/components/CommandLinkManager.tsx index 4861f03..4218757 100644 --- a/src/components/CommandLinkManager.tsx +++ b/src/components/CommandLinkManager.tsx @@ -1,10 +1,9 @@ /** * CommandLinkManager — mounts as a child of
and intercepts - * clicks on command-like links (>roll, >spark, >link, >stat) to send - * them as journal stream messages instead of navigating. + * clicks on :cmd[] directive spans to send them as journal stream + * messages. * - * Uses capture-phase event delegation so it fires before the Article - * component's own link interception (which would otherwise navigate). + * Uses capture-phase event delegation on [data-cmd] spans. */ import { Component, onCleanup } from "solid-js"; @@ -22,34 +21,9 @@ import { 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: { success: true; msg: R } | { success: false; error: string }) { +function unwrap( + 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); @@ -66,26 +40,20 @@ export const CommandLinkManager: Component = () => { // ---- Click interceptor (capture phase) ---- const onClick = (e: MouseEvent) => { - const anchor = (e.target as HTMLElement).closest("a[href]"); - if (!anchor) return; + const cmdSpan = (e.target as HTMLElement).closest("[data-cmd]"); + if (!cmdSpan) return; - const href = anchor.getAttribute("href"); - if (!href) return; - - const command = hrefToCommand(href); + const command = cmdSpan.getAttribute("data-cmd"); 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); } @@ -96,10 +64,7 @@ export const CommandLinkManager: Component = () => { // ---- Command dispatch ---- - function dispatchCommand(raw: string) { - // Convert > prefix to / so parseInput understands it - const command = raw.startsWith(">") ? "/" + raw.slice(1) : raw; - + function dispatchCommand(command: string) { // Observers: everything goes as chat if (isObserver()) { sendMessage("chat", { text: command }); @@ -112,7 +77,7 @@ export const CommandLinkManager: Component = () => { // Players: chat + stat commands only if (isPlayer()) { if (parsed.type === "chat") { - sendMessage("chat", { text: raw }); + sendMessage("chat", { text: command }); return; } if (parsed.type === "stat") { @@ -138,7 +103,7 @@ export const CommandLinkManager: Component = () => { const p = await resolveSparkPayload({ key, csvPath, remix }); sendMessage("spark", p); } catch { - // silently ignore spark table errors from links + // silently ignore spark table errors from directive clicks } })(); } else if (parsed.type === "stat") { @@ -148,13 +113,14 @@ export const CommandLinkManager: Component = () => { } } - /** Resolve bare key -> full key (copied from JournalInput for standalone use). */ + /** Resolve bare key -> full key. */ function resolveKey( inputKey: string, statDefs: typeof comp.data.stats, playerName: string, ): string { - if (statDefs.some((d) => fullKey(d, playerName) === inputKey)) return inputKey; + 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; @@ -218,7 +184,7 @@ export const CommandLinkManager: Component = () => { } } - return null; // renders nothing — purely event-based + return null; }; export default CommandLinkManager; diff --git a/src/markdown/cmd.ts b/src/markdown/cmd.ts new file mode 100644 index 0000000..157dbe2 --- /dev/null +++ b/src/markdown/cmd.ts @@ -0,0 +1,44 @@ +/** + * marked-directive extension: :cmd[command]{label=Display text} inline syntax. + * + * Renders a clickable span that dispatches the command to the journal stream + * when clicked. The CommandLinkManager component handles the actual dispatch. + * + * Usage in markdown: + * :cmd[roll 1d20+5]{label=Roll initiative} + * :cmd[spark dungeon room-type]{label=Random room} + * :cmd[stat set strength=18]{label=Set strength} + * + * If no label is provided, the command text itself is shown. + */ + +export const cmdDirective = { + level: "inline" as const, + marker: ":", + renderer(token: any) { + // Only handle :cmd[...], not other :directives + if (token.meta.name !== "cmd") return false; + + const command = (token.text || "").trim(); + if (!command) return ""; + + const label = (token.attrs?.label as string) || command; + + return `${escapeHtml(label)}`; + }, +}; + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">"); +} + +function escapeAttr(s: string): string { + return s + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} diff --git a/src/markdown/index.ts b/src/markdown/index.ts index 7e64722..ff38207 100644 --- a/src/markdown/index.ts +++ b/src/markdown/index.ts @@ -7,6 +7,7 @@ import { gfmHeadingId } from "marked-gfm-heading-id"; import markedColumns from "./columns"; import markedCodeBlockYamlTag from "./code-block-yaml-tag"; import { iconDirective, setIconBase } from "./icon"; +import { cmdDirective } from "./cmd"; // 使用 marked-directive 来支持指令语法 const marked = new Marked() @@ -27,6 +28,7 @@ const marked = new Marked() level: "container", }, iconDirective, + cmdDirective, ]), { extensions: [...markedColumns()], diff --git a/src/styles.css b/src/styles.css index bc905d6..212ea19 100644 --- a/src/styles.css +++ b/src/styles.css @@ -149,3 +149,10 @@ icon.big .icon-label-stroke { .concealed .revealed { opacity: unset; } + +/* cmd-link — clickable command spans from :cmd[] directive */ + +.cmd-link { + @apply text-blue-600 underline cursor-pointer; + @apply hover:text-blue-800; +}