mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
feat(ui): render transcript footnotes as navigable endnotes
Local markdown-it footnote plugin: superscript references jump to an ordered endnotes section and back to their exact reference. Note ids are namespaced per transcript message so identical notes stay distinct. No new dependencies.
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
// GFM-style footnote rendering (`[^label]` references + `[^label]: text`
|
||||
// definitions) as a local markdown-it plugin: ordered endnotes, navigable
|
||||
// superscript references, and per-note backlinks, with no external dependency.
|
||||
import type MarkdownIt from "markdown-it";
|
||||
import type StateBlock from "markdown-it/lib/rules_block/state_block.mjs";
|
||||
import type StateCore from "markdown-it/lib/rules_core/state_core.mjs";
|
||||
import type StateInline from "markdown-it/lib/rules_inline/state_inline.mjs";
|
||||
import type Token from "markdown-it/lib/token.mjs";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { escapeMarkdownHtml } from "./markdown-text.ts";
|
||||
|
||||
const FOOTNOTES_ENV_KEY = Symbol("markdownFootnotes");
|
||||
|
||||
type FootnoteRecord = {
|
||||
label: string;
|
||||
/** 1-based display number and anchor suffix, assigned by definition order. */
|
||||
n: number;
|
||||
/** Reference occurrences; each gets its own backlink target id. */
|
||||
count: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type FootnotesEnv = {
|
||||
list: FootnoteRecord[];
|
||||
byLabel: Map<string, FootnoteRecord>;
|
||||
};
|
||||
|
||||
type FootnoteItem = FootnoteRecord & { children: Token[] };
|
||||
|
||||
function footnotesIn(env: unknown): FootnotesEnv | undefined {
|
||||
if (!env || typeof env !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
return (env as Record<symbol, unknown>)[FOOTNOTES_ENV_KEY] as FootnotesEnv | undefined;
|
||||
}
|
||||
|
||||
function requireFootnotes(env: unknown): FootnotesEnv {
|
||||
return (
|
||||
footnotesIn(env) ??
|
||||
((env as Record<symbol, unknown>)[FOOTNOTES_ENV_KEY] = {
|
||||
list: [],
|
||||
byLabel: new Map<string, FootnoteRecord>(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function footnoteDocId(env: unknown): string | undefined {
|
||||
const docId: unknown = (env as { docId?: unknown } | undefined)?.docId;
|
||||
return typeof docId === "string" && docId !== "" ? docId : undefined;
|
||||
}
|
||||
|
||||
/** Anchor namespace keeps identical notes in adjacent transcript messages from
|
||||
* sharing DOM ids; the base36 hash is selector-safe so ids need no escaping. */
|
||||
function footnoteNamespace(env: unknown): string {
|
||||
return `${footnoteDocId(env) ?? ""}-`;
|
||||
}
|
||||
|
||||
function footnoteNoteId(namespace: string, n: number): string {
|
||||
return `fn${namespace}${n}`;
|
||||
}
|
||||
|
||||
function footnoteRefId(namespace: string, n: number, subId: number): string {
|
||||
return `fnref${namespace}${n}${subId > 0 ? `-${subId}` : ""}`;
|
||||
}
|
||||
|
||||
function footnoteDefinitionRule(
|
||||
state: StateBlock,
|
||||
startLine: number,
|
||||
endLine: number,
|
||||
silent: boolean,
|
||||
): boolean {
|
||||
if ((state.sCount[startLine] ?? 0) - state.blkIndent >= 4) {
|
||||
return false;
|
||||
}
|
||||
const start = (state.bMarks[startLine] ?? 0) + (state.tShift[startLine] ?? 0);
|
||||
const max = state.eMarks[startLine] ?? state.src.length;
|
||||
// Shortest definition is "[^x]:".
|
||||
if (
|
||||
start + 4 > max ||
|
||||
state.src.charCodeAt(start) !== 0x5b ||
|
||||
state.src.charCodeAt(start + 1) !== 0x5e
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const close = state.src.indexOf("]", start + 2);
|
||||
if (close < start + 3 || close > max - 2 || state.src.charCodeAt(close + 1) !== 0x3a) {
|
||||
return false;
|
||||
}
|
||||
const label = state.src.slice(start + 2, close);
|
||||
// Labels stay on one line without whitespace or nested brackets; anything
|
||||
// else is prose (or a link reference), not a footnote.
|
||||
if (/[\s[]/.test(label)) {
|
||||
return false;
|
||||
}
|
||||
if (silent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Note body: this line's remainder plus continuation lines indented by at
|
||||
// least four columns; a blank or unindented line ends the note.
|
||||
let cursor = close + 2;
|
||||
while (cursor < max && /\s/.test(state.src.charAt(cursor))) {
|
||||
cursor += 1;
|
||||
}
|
||||
const lines = [state.src.slice(cursor, max)];
|
||||
let line = startLine + 1;
|
||||
while (line < endLine) {
|
||||
const contentStart = (state.bMarks[line] ?? 0) + (state.tShift[line] ?? 0);
|
||||
const contentEnd = state.eMarks[line] ?? contentStart;
|
||||
if (contentStart === contentEnd || (state.sCount[line] ?? 0) < 4) {
|
||||
break;
|
||||
}
|
||||
lines.push(state.src.slice(contentStart, contentEnd));
|
||||
line += 1;
|
||||
}
|
||||
|
||||
const footnotes = requireFootnotes(state.env);
|
||||
const key = label.toLowerCase();
|
||||
// Duplicate labels merge into the first note, matching reference semantics.
|
||||
if (!footnotes.byLabel.has(key)) {
|
||||
const record: FootnoteRecord = {
|
||||
label,
|
||||
n: footnotes.list.length + 1,
|
||||
count: 0,
|
||||
text: lines.join("\n").trim(),
|
||||
};
|
||||
footnotes.list.push(record);
|
||||
footnotes.byLabel.set(key, record);
|
||||
}
|
||||
state.line = line;
|
||||
return true;
|
||||
}
|
||||
|
||||
function footnoteReferenceRule(state: StateInline, silent: boolean): boolean {
|
||||
const pos = state.pos;
|
||||
const src = state.src;
|
||||
if (src.charCodeAt(pos) !== 0x5b || src.charCodeAt(pos + 1) !== 0x5e) {
|
||||
return false;
|
||||
}
|
||||
const close = src.indexOf("]", pos + 2);
|
||||
if (close < pos + 3) {
|
||||
return false;
|
||||
}
|
||||
const label = src.slice(pos + 2, close);
|
||||
if (/[\s[]/.test(label)) {
|
||||
return false;
|
||||
}
|
||||
const record = footnotesIn(state.env)?.byLabel.get(label.toLowerCase());
|
||||
// Undefined labels keep their literal source text instead of a dead anchor.
|
||||
if (!record) {
|
||||
return false;
|
||||
}
|
||||
if (!silent) {
|
||||
const subId = record.count;
|
||||
record.count += 1;
|
||||
const token = state.push("footnote_ref", "", 0);
|
||||
token.meta = { n: record.n, subId };
|
||||
}
|
||||
state.pos = close + 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
function footnotesTailRule(state: StateCore): void {
|
||||
const footnotes = footnotesIn(state.env);
|
||||
if (!footnotes || footnotes.list.length === 0) {
|
||||
return;
|
||||
}
|
||||
const items: FootnoteItem[] = footnotes.list.map((record): FootnoteItem => {
|
||||
const children: Token[] = [];
|
||||
state.md.inline.parse(record.text, state.md, state.env, children);
|
||||
return {
|
||||
label: record.label,
|
||||
n: record.n,
|
||||
count: record.count,
|
||||
text: record.text,
|
||||
children,
|
||||
};
|
||||
});
|
||||
const token = new state.Token("footnotes_block", "", 0);
|
||||
token.block = true;
|
||||
token.meta = { items };
|
||||
state.tokens.push(token);
|
||||
}
|
||||
|
||||
export function installMarkdownFootnotes(markdownParser: MarkdownIt): void {
|
||||
markdownParser.block.ruler.before("reference", "footnote_definition", footnoteDefinitionRule, {
|
||||
alt: ["paragraph", "reference", "blockquote", "list"],
|
||||
});
|
||||
markdownParser.inline.ruler.before("link", "footnote_ref", footnoteReferenceRule);
|
||||
// Definitions may appear after their references, so the endnotes section can
|
||||
// only be built once the whole document has been tokenized.
|
||||
markdownParser.core.ruler.push("footnotes_tail", footnotesTailRule);
|
||||
|
||||
markdownParser.renderer.rules.footnote_ref = (tokens, index, _options, env) => {
|
||||
const meta = tokens[index]?.meta as { n?: unknown; subId?: unknown } | undefined;
|
||||
const n = Number(meta?.n ?? 0);
|
||||
const subId = Number(meta?.subId ?? 0);
|
||||
const namespace = footnoteNamespace(env);
|
||||
return `<a class="footnote-ref" href="#${footnoteNoteId(namespace, n)}" id="${footnoteRefId(namespace, n, subId)}">${n}</a>`;
|
||||
};
|
||||
|
||||
markdownParser.renderer.rules.footnotes_block = (tokens, index, options, env, self) => {
|
||||
const items = (tokens[index]?.meta as { items?: FootnoteItem[] } | undefined)?.items ?? [];
|
||||
if (items.length === 0) {
|
||||
return "";
|
||||
}
|
||||
const namespace = footnoteNamespace(env);
|
||||
const backLink = (n: number, subId: number) =>
|
||||
`<a class="footnote-backref" href="#${footnoteRefId(namespace, n, subId)}" aria-label="${escapeMarkdownHtml(t("common.back"))}">↩</a>`;
|
||||
const listItems = items
|
||||
.map((item) => {
|
||||
const backlinks = Array.from({ length: Math.max(item.count, 1) }, (_, subId) =>
|
||||
backLink(item.n, subId),
|
||||
).join("");
|
||||
return `<li id="${footnoteNoteId(namespace, item.n)}" class="footnote-item"><p>${self.renderInline(item.children, options, env)}${backlinks}</p></li>`;
|
||||
})
|
||||
.join("\n");
|
||||
return `<hr class="footnotes-sep">\n<section class="footnotes">\n<ol class="footnotes-list">\n${listItems}\n</ol>\n</section>\n`;
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
parseMarkdownFileLinkTarget,
|
||||
splitMarkdownFileLineSuffix,
|
||||
} from "./markdown-file-links.ts";
|
||||
import { installMarkdownFootnotes } from "./markdown-footnotes.ts";
|
||||
import type { MarkdownRenderEnv } from "./markdown-render-options.ts";
|
||||
import { installMarkdownSessionLinks, SESSION_LINK_SCAN_RE } from "./markdown-session-links.ts";
|
||||
import { installMarkdownTables } from "./markdown-tables.ts";
|
||||
@@ -152,6 +153,7 @@ export function createMarkdownParser(): MarkdownIt {
|
||||
installAssistantTranscriptRoleMarkdown(markdownParser, escapeMarkdownHtml);
|
||||
installMarkdownDetails(markdownParser);
|
||||
installMarkdownTables(markdownParser);
|
||||
installMarkdownFootnotes(markdownParser);
|
||||
|
||||
// Disable fuzzy link detection to prevent bare filenames like "README.md"
|
||||
// from being auto-linked as "http://README.md". URLs with explicit protocol
|
||||
|
||||
@@ -14,12 +14,29 @@ export type MarkdownRenderOptions = {
|
||||
mode?: MarkdownRenderMode;
|
||||
sessionLinks?: boolean;
|
||||
tableInteractions?: MarkdownTableInteractions;
|
||||
/** Stable per-document identity; namespaces generated anchor ids (footnotes)
|
||||
* so identical notes in adjacent transcript messages stay distinct. */
|
||||
documentId?: string;
|
||||
};
|
||||
|
||||
export type MarkdownRenderEnv = Required<MarkdownRenderOptions> & {
|
||||
export type MarkdownRenderEnv = Required<Omit<MarkdownRenderOptions, "documentId">> & {
|
||||
streamingOpenFence?: boolean;
|
||||
docId?: string;
|
||||
};
|
||||
|
||||
// FNV-1a 32-bit folded to base36: compact and selector-safe for anchor ids.
|
||||
function markdownDocumentId(value: string | undefined): string | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
let hash = 0x811c9dc5;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
export function normalizeMarkdownRenderOptions(
|
||||
options: MarkdownRenderOptions = {},
|
||||
): MarkdownRenderEnv {
|
||||
@@ -34,5 +51,6 @@ export function normalizeMarkdownRenderOptions(
|
||||
mode: options.mode ?? "message",
|
||||
sessionLinks: options.sessionLinks ?? false,
|
||||
tableInteractions: options.tableInteractions ?? "none",
|
||||
docId: markdownDocumentId(options.documentId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -615,6 +615,68 @@ PY
|
||||
});
|
||||
});
|
||||
|
||||
describe("footnotes", () => {
|
||||
it("moves footnotes to navigable endnotes with local backlinks", () => {
|
||||
const fragment = htmlFragment(
|
||||
toSanitizedMarkdownHtml("Claim[^source].\n\n[^source]: Supporting **detail**.", {
|
||||
documentId: "message-1",
|
||||
}),
|
||||
);
|
||||
const reference = fragment.querySelector<HTMLAnchorElement>(".footnote-ref");
|
||||
const note = fragment.querySelector<HTMLElement>(".footnote-item");
|
||||
const backlink = fragment.querySelector<HTMLAnchorElement>(".footnote-backref");
|
||||
|
||||
expect(reference?.textContent).toBe("1");
|
||||
expect(reference?.getAttribute("href")).toBe(`#${note?.id}`);
|
||||
expect(backlink?.getAttribute("href")).toBe(`#${reference?.id}`);
|
||||
expect(backlink?.getAttribute("aria-label")).toBe("Back");
|
||||
// Note bodies render as Markdown, not escaped source.
|
||||
expect(note?.querySelector("strong")?.textContent).toBe("detail");
|
||||
expect(fragment.textContent).not.toContain("[^source]");
|
||||
});
|
||||
|
||||
it("namespaces identical notes from adjacent transcript messages", () => {
|
||||
const markdown = "Claim[^note].\n\n[^note]: Detail.";
|
||||
const first = htmlFragment(toSanitizedMarkdownHtml(markdown, { documentId: "message-1" }));
|
||||
const second = htmlFragment(toSanitizedMarkdownHtml(markdown, { documentId: "message-2" }));
|
||||
|
||||
const firstRef = first.querySelector(".footnote-ref");
|
||||
const secondRef = second.querySelector(".footnote-ref");
|
||||
expect(firstRef?.getAttribute("href")).not.toBe(secondRef?.getAttribute("href"));
|
||||
// Each note resolves inside its own message's section.
|
||||
const firstNote = first.querySelector(firstRef?.getAttribute("href") ?? "");
|
||||
expect(firstNote?.classList.contains("footnote-item")).toBe(true);
|
||||
});
|
||||
|
||||
it("numbers repeated references and backlinks them individually", () => {
|
||||
const fragment = htmlFragment(
|
||||
toSanitizedMarkdownHtml("A[^x] B[^x]\n\n[^x]: Note.", { documentId: "m" }),
|
||||
);
|
||||
const refs = [...fragment.querySelectorAll<HTMLAnchorElement>(".footnote-ref")];
|
||||
const backlinks = [...fragment.querySelectorAll<HTMLAnchorElement>(".footnote-backref")];
|
||||
|
||||
expect(refs.map((ref) => ref.textContent)).toEqual(["1", "1"]);
|
||||
expect(new Set(refs.map((ref) => ref.id)).size).toBe(2);
|
||||
expect(backlinks.length).toBe(2);
|
||||
for (const [index, backlink] of backlinks.entries()) {
|
||||
expect(backlink.getAttribute("href")).toBe(`#${refs[index]?.id}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps undefined footnote references readable as source text", () => {
|
||||
const html = toSanitizedMarkdownHtml("Claim[^missing] with no note.");
|
||||
expect(html).toBe("<p>Claim[^missing] with no note.</p>\n");
|
||||
expect(html).not.toContain("footnote");
|
||||
});
|
||||
|
||||
it("keeps dollar amounts as plain text without math elements", () => {
|
||||
const fragment = htmlFragment(toSanitizedMarkdownHtml("The total is $50 and E = mc^2."));
|
||||
expect(fragment.querySelector("math")).toBeNull();
|
||||
expect(fragment.textContent).toContain("$50");
|
||||
expect(fragment.textContent).toContain("E = mc^2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assistant transcript-role annotations", () => {
|
||||
it("marks parsed role headers without exposing Markdown delimiters", () => {
|
||||
const fragment = htmlFragment(
|
||||
|
||||
@@ -45,6 +45,7 @@ const allowedTags = [
|
||||
"p",
|
||||
"pre",
|
||||
"s",
|
||||
"section",
|
||||
"span",
|
||||
"strong",
|
||||
"summary",
|
||||
@@ -63,6 +64,7 @@ const allowedAttrs = [
|
||||
"class",
|
||||
"disabled",
|
||||
"href",
|
||||
"id",
|
||||
"open",
|
||||
"rel",
|
||||
"target",
|
||||
@@ -450,6 +452,12 @@ function installHooks() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Same-document anchors (footnote references and backlinks) must keep their
|
||||
// fragment behavior; the generic rewrite below would force target="_blank".
|
||||
if (href.startsWith("#")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isHostLocalMarkdownFileHref(href)) {
|
||||
node.removeAttribute("href");
|
||||
return;
|
||||
@@ -551,7 +559,7 @@ export function toSanitizedMarkdownHtml(
|
||||
}
|
||||
const renderInput = isMarkdownBlockArtText(rawInput) ? rawInput : input;
|
||||
const cacheable = input.length <= MARKDOWN_CACHE_MAX_CHARS;
|
||||
const cacheKey = `${i18n.getLocale()}\0${renderOptions.assistantTranscriptRoleHeaders}\0${renderOptions.codeBlockChrome}\0${renderOptions.codeBlockInteraction}\0${renderOptions.fileLinks}\0${renderOptions.interactiveImages}\0${renderOptions.linkFavicons}\0${renderOptions.progressBars}\0${renderOptions.mode}\0${renderOptions.sessionLinks}\0${renderOptions.tableInteractions}\0${renderInput}`;
|
||||
const cacheKey = `${i18n.getLocale()}\0${renderOptions.assistantTranscriptRoleHeaders}\0${renderOptions.codeBlockChrome}\0${renderOptions.codeBlockInteraction}\0${renderOptions.fileLinks}\0${renderOptions.interactiveImages}\0${renderOptions.linkFavicons}\0${renderOptions.progressBars}\0${renderOptions.mode}\0${renderOptions.sessionLinks}\0${renderOptions.tableInteractions}\0${renderOptions.docId ?? ""}\0${renderInput}`;
|
||||
if (cacheable) {
|
||||
const cached = getCachedMarkdown(cacheKey);
|
||||
if (cached !== null) {
|
||||
|
||||
@@ -86,7 +86,12 @@ export function renderChatNotice(item: Extract<ChatItem, { kind: "notice" }>) {
|
||||
${item.text
|
||||
? html`
|
||||
<div class="chat-text chat-notice__body" dir=${detectTextDirection(item.text)}>
|
||||
${unsafeHTML(toSanitizedMarkdownHtml(item.text, { codeBlockChrome: "none" }))}
|
||||
${unsafeHTML(
|
||||
toSanitizedMarkdownHtml(item.text, {
|
||||
codeBlockChrome: "none",
|
||||
documentId: item.key,
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
@@ -303,6 +303,7 @@ export function renderGroupedMessage(
|
||||
sessionLinks: true,
|
||||
tableInteractions: "enabled",
|
||||
linkFavicons: Boolean(opts.fetchLinkFavicon) && !opts.isStreaming,
|
||||
documentId: messageKey,
|
||||
};
|
||||
|
||||
// Detect pure-JSON messages and render as collapsible block
|
||||
@@ -536,6 +537,7 @@ export function renderGroupedMessage(
|
||||
${unsafeHTML(
|
||||
toSanitizedMarkdownHtml(reasoningMarkdown, {
|
||||
codeBlockInteraction: "interactive",
|
||||
documentId: `reasoning:${messageKey}`,
|
||||
}),
|
||||
)}
|
||||
</div>`
|
||||
@@ -610,6 +612,7 @@ export function renderGroupedMessage(
|
||||
${unsafeHTML(
|
||||
toSanitizedMarkdownHtml(reasoningMarkdown, {
|
||||
codeBlockInteraction: "interactive",
|
||||
documentId: `reasoning:${messageKey}`,
|
||||
}),
|
||||
)}
|
||||
</div>`
|
||||
|
||||
@@ -997,6 +997,7 @@ describe("grouped chat rendering", () => {
|
||||
assistantTranscriptRoleHeaders: false,
|
||||
codeBlockChrome: "none",
|
||||
codeBlockInteraction: "static",
|
||||
documentId: "user-message",
|
||||
fileLinks: true,
|
||||
interactiveImages: false,
|
||||
linkFavicons: false,
|
||||
@@ -1121,6 +1122,7 @@ describe("grouped chat rendering", () => {
|
||||
assistantTranscriptRoleHeaders: true,
|
||||
codeBlockChrome: "copy",
|
||||
codeBlockInteraction: "interactive",
|
||||
documentId: "assistant-message",
|
||||
fileLinks: true,
|
||||
interactiveImages: false,
|
||||
linkFavicons: false,
|
||||
@@ -1669,6 +1671,7 @@ describe("grouped chat rendering", () => {
|
||||
assistantTranscriptRoleHeaders: true,
|
||||
codeBlockChrome: "copy",
|
||||
codeBlockInteraction: "interactive",
|
||||
documentId: "stream:1",
|
||||
fileLinks: true,
|
||||
interactiveImages: false,
|
||||
linkFavicons: false,
|
||||
@@ -2061,6 +2064,7 @@ describe("grouped chat rendering", () => {
|
||||
expect(notice?.dataset.chatRowKey).toBe("notice:command");
|
||||
expect(markdownRenderMock).toHaveBeenCalledWith(expect.any(String), {
|
||||
codeBlockChrome: "none",
|
||||
documentId: "notice:command",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -424,7 +424,11 @@ export class ChatSessionRailElement extends OpenClawLightDomElement {
|
||||
${question}
|
||||
</div>
|
||||
<div class="chat-session-rail__answer" dir=${detectTextDirection(answer)}>
|
||||
${unsafeHTML(toSanitizedMarkdownHtml(answer))}
|
||||
${unsafeHTML(
|
||||
toSanitizedMarkdownHtml(answer, {
|
||||
documentId: `rail:${this.sessionKey}:${ts}`,
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
<time class="chat-session-rail__timestamp" datetime=${new Date(ts).toISOString()}>
|
||||
${t("chat.rail.asOf", {
|
||||
|
||||
@@ -146,6 +146,7 @@ function renderMarkdownSidebar(props: MarkdownSidebarProps) {
|
||||
content?.kind === "markdown" && content.content.trim()
|
||||
? toSanitizedMarkdownHtml(content.content, {
|
||||
codeBlockInteraction: "interactive",
|
||||
documentId: `sidebar:${content.fullMessageRequest?.messageId ?? "markdown"}`,
|
||||
fileLinks: true,
|
||||
interactiveImages: props.onOpenImage !== undefined,
|
||||
sessionLinks: true,
|
||||
|
||||
@@ -109,15 +109,15 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.chat-text :where(p, ul, ol, pre, blockquote, table, details, .markdown-table) {
|
||||
.chat-text :where(p, ul, ol, pre, blockquote, table, details, .markdown-table, .footnotes) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Keep every adjacent top-level Markdown block on the same rhythm, including
|
||||
special blocks that otherwise read as part of the preceding block. */
|
||||
.chat-text
|
||||
> :where(p, ul, ol, pre, blockquote, table, details, .markdown-table)
|
||||
+ :where(p, ul, ol, pre, blockquote, table, details, .markdown-table) {
|
||||
> :where(p, ul, ol, pre, blockquote, table, details, .markdown-table, .footnotes)
|
||||
+ :where(p, ul, ol, pre, blockquote, table, details, .markdown-table, .footnotes) {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
@@ -416,6 +416,46 @@
|
||||
font-size: calc(1em + 1px);
|
||||
}
|
||||
|
||||
/* Footnote references read as superscript markers, not links; the endnotes
|
||||
section separates from the prose with a hairline like a printed notes block. */
|
||||
.chat-text :where(.footnote-ref) {
|
||||
margin-inline-start: 0.08em;
|
||||
font-size: 0.72em;
|
||||
line-height: 0;
|
||||
vertical-align: super;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chat-text :where(.footnotes-sep) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:is(.chat-text, .chat-thinking) :where(.footnotes) {
|
||||
padding-top: 0.75em;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
:is(.chat-text, .chat-thinking) :where(.footnotes-list) {
|
||||
margin: 0;
|
||||
padding-inline-start: var(--chat-markdown-indent);
|
||||
}
|
||||
|
||||
:is(.chat-text, .chat-thinking) :where(.footnote-item p) {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
:is(.chat-text, .chat-thinking) :where(.footnote-item + .footnote-item) {
|
||||
margin-top: 0.4em;
|
||||
}
|
||||
|
||||
.chat-text :where(.footnote-backref) {
|
||||
display: inline-block;
|
||||
margin-inline-start: 0.25em;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
:is(.chat-text, .chat-thinking) :where(details) {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
|
||||
Reference in New Issue
Block a user