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.
This commit is contained in:
parent
182d7ff28d
commit
42e8971ff7
|
|
@ -1,10 +1,9 @@
|
||||||
/**
|
/**
|
||||||
* CommandLinkManager — mounts as a child of <Article> and intercepts
|
* CommandLinkManager — mounts as a child of <Article> and intercepts
|
||||||
* clicks on command-like links (>roll, >spark, >link, >stat) to send
|
* clicks on :cmd[] directive spans to send them as journal stream
|
||||||
* them as journal stream messages instead of navigating.
|
* messages.
|
||||||
*
|
*
|
||||||
* Uses capture-phase event delegation so it fires before the Article
|
* Uses capture-phase event delegation on [data-cmd] spans.
|
||||||
* component's own link interception (which would otherwise navigate).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, onCleanup } from "solid-js";
|
import { Component, onCleanup } from "solid-js";
|
||||||
|
|
@ -22,34 +21,9 @@ import {
|
||||||
findStatDef,
|
findStatDef,
|
||||||
} from "./journal/stat-helpers";
|
} from "./journal/stat-helpers";
|
||||||
|
|
||||||
/** Commands that get intercepted as stream messages. */
|
function unwrap<R>(
|
||||||
const COMMAND_PREFIXES = [">roll", ">spark", ">link", ">stat"];
|
r: { success: true; msg: R } | { success: false; error: string },
|
||||||
|
) {
|
||||||
/**
|
|
||||||
* 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
|
return r.success
|
||||||
? ({ ok: true, err: undefined } as const)
|
? ({ ok: true, err: undefined } as const)
|
||||||
: ({ ok: false, err: r.error } as const);
|
: ({ ok: false, err: r.error } as const);
|
||||||
|
|
@ -66,26 +40,20 @@ export const CommandLinkManager: Component = () => {
|
||||||
// ---- Click interceptor (capture phase) ----
|
// ---- Click interceptor (capture phase) ----
|
||||||
|
|
||||||
const onClick = (e: MouseEvent) => {
|
const onClick = (e: MouseEvent) => {
|
||||||
const anchor = (e.target as HTMLElement).closest("a[href]");
|
const cmdSpan = (e.target as HTMLElement).closest("[data-cmd]");
|
||||||
if (!anchor) return;
|
if (!cmdSpan) return;
|
||||||
|
|
||||||
const href = anchor.getAttribute("href");
|
const command = cmdSpan.getAttribute("data-cmd");
|
||||||
if (!href) return;
|
|
||||||
|
|
||||||
const command = hrefToCommand(href);
|
|
||||||
if (!command) return;
|
if (!command) return;
|
||||||
|
|
||||||
// This is a command link — intercept it
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.stopImmediatePropagation();
|
e.stopImmediatePropagation();
|
||||||
|
|
||||||
dispatchCommand(command);
|
dispatchCommand(command);
|
||||||
};
|
};
|
||||||
|
|
||||||
const dom = contentDom();
|
const dom = contentDom();
|
||||||
if (dom) {
|
if (dom) {
|
||||||
// Use capture phase to beat the Article component's bubble-phase handler
|
|
||||||
dom.addEventListener("click", onClick, true);
|
dom.addEventListener("click", onClick, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,10 +64,7 @@ export const CommandLinkManager: Component = () => {
|
||||||
|
|
||||||
// ---- Command dispatch ----
|
// ---- Command dispatch ----
|
||||||
|
|
||||||
function dispatchCommand(raw: string) {
|
function dispatchCommand(command: string) {
|
||||||
// Convert > prefix to / so parseInput understands it
|
|
||||||
const command = raw.startsWith(">") ? "/" + raw.slice(1) : raw;
|
|
||||||
|
|
||||||
// Observers: everything goes as chat
|
// Observers: everything goes as chat
|
||||||
if (isObserver()) {
|
if (isObserver()) {
|
||||||
sendMessage("chat", { text: command });
|
sendMessage("chat", { text: command });
|
||||||
|
|
@ -112,7 +77,7 @@ export const CommandLinkManager: Component = () => {
|
||||||
// Players: chat + stat commands only
|
// Players: chat + stat commands only
|
||||||
if (isPlayer()) {
|
if (isPlayer()) {
|
||||||
if (parsed.type === "chat") {
|
if (parsed.type === "chat") {
|
||||||
sendMessage("chat", { text: raw });
|
sendMessage("chat", { text: command });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (parsed.type === "stat") {
|
if (parsed.type === "stat") {
|
||||||
|
|
@ -138,7 +103,7 @@ export const CommandLinkManager: Component = () => {
|
||||||
const p = await resolveSparkPayload({ key, csvPath, remix });
|
const p = await resolveSparkPayload({ key, csvPath, remix });
|
||||||
sendMessage("spark", p);
|
sendMessage("spark", p);
|
||||||
} catch {
|
} catch {
|
||||||
// silently ignore spark table errors from links
|
// silently ignore spark table errors from directive clicks
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
} else if (parsed.type === "stat") {
|
} 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(
|
function resolveKey(
|
||||||
inputKey: string,
|
inputKey: string,
|
||||||
statDefs: typeof comp.data.stats,
|
statDefs: typeof comp.data.stats,
|
||||||
playerName: string,
|
playerName: string,
|
||||||
): 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);
|
const def = statDefs.find((d) => d.key === inputKey);
|
||||||
if (def) return fullKey(def, playerName);
|
if (def) return fullKey(def, playerName);
|
||||||
return inputKey;
|
return inputKey;
|
||||||
|
|
@ -218,7 +184,7 @@ export const CommandLinkManager: Component = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null; // renders nothing — purely event-based
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CommandLinkManager;
|
export default CommandLinkManager;
|
||||||
|
|
|
||||||
|
|
@ -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 `<span class="cmd-link" data-cmd="${escapeAttr(command)}" role="button" tabindex="0">${escapeHtml(label)}</span>`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttr(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import { gfmHeadingId } from "marked-gfm-heading-id";
|
||||||
import markedColumns from "./columns";
|
import markedColumns from "./columns";
|
||||||
import markedCodeBlockYamlTag from "./code-block-yaml-tag";
|
import markedCodeBlockYamlTag from "./code-block-yaml-tag";
|
||||||
import { iconDirective, setIconBase } from "./icon";
|
import { iconDirective, setIconBase } from "./icon";
|
||||||
|
import { cmdDirective } from "./cmd";
|
||||||
|
|
||||||
// 使用 marked-directive 来支持指令语法
|
// 使用 marked-directive 来支持指令语法
|
||||||
const marked = new Marked()
|
const marked = new Marked()
|
||||||
|
|
@ -27,6 +28,7 @@ const marked = new Marked()
|
||||||
level: "container",
|
level: "container",
|
||||||
},
|
},
|
||||||
iconDirective,
|
iconDirective,
|
||||||
|
cmdDirective,
|
||||||
]),
|
]),
|
||||||
{
|
{
|
||||||
extensions: [...markedColumns()],
|
extensions: [...markedColumns()],
|
||||||
|
|
|
||||||
|
|
@ -149,3 +149,10 @@ icon.big .icon-label-stroke {
|
||||||
.concealed .revealed {
|
.concealed .revealed {
|
||||||
opacity: unset;
|
opacity: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* cmd-link — clickable command spans from :cmd[] directive */
|
||||||
|
|
||||||
|
.cmd-link {
|
||||||
|
@apply text-blue-600 underline cursor-pointer;
|
||||||
|
@apply hover:text-blue-800;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue