From 6b3a9153500e442c5845269d589d59d28cd11e61 Mon Sep 17 00:00:00 2001 From: hypercross Date: Tue, 7 Jul 2026 21:44:00 +0800 Subject: [PATCH] refactor: simplify spark button injection logic Update the spark button injection to use `data-spark` slugs on `md-table` elements instead of manual table parsing. This improves reliability by leveraging the slug generated during markdown rendering. --- src/components/stores/reveal.ts | 46 ++++------- src/markdown/table.ts | 133 ++++++++++++++++++-------------- 2 files changed, 91 insertions(+), 88 deletions(-) diff --git a/src/components/stores/reveal.ts b/src/components/stores/reveal.ts index cb02dc6..3ab805a 100644 --- a/src/components/stores/reveal.ts +++ b/src/components/stores/reveal.ts @@ -183,8 +183,6 @@ const LINK_SVG = // ---- Spark button (tables) ---- -const DICE_HEADER_RE = /^d\d+$/i; - function injectSparkButtons( root: Element, normalizedPath: string, @@ -196,40 +194,24 @@ function injectSparkButtons( ); if (pageTables.length === 0) return; - const tables = root.querySelectorAll("table"); - tables.forEach((table) => { - // Find the first header row — check thead first, then first tr - const thead = table.querySelector("thead"); - const firstRow = - thead?.rows[0] ?? - table.querySelector("tbody tr:first-child, tr:first-child"); - if (!firstRow || !(firstRow instanceof HTMLTableRowElement)) return; + // Spark tables are rendered as + const sparkTables = root.querySelectorAll("md-table[data-spark]"); + sparkTables.forEach((el) => { + const colSlug = el.getAttribute("data-spark"); + if (!colSlug) return; - const firstCell = firstRow.cells[0]; - if (!firstCell) return; - const firstHeader = firstCell.textContent?.trim() ?? ""; - if (!DICE_HEADER_RE.test(firstHeader)) return; - - // Collect data headers from the remaining cells - const dataHeaders: string[] = []; - for (let i = 1; i < firstRow.cells.length; i++) { - const text = firstRow.cells[i].textContent?.trim() ?? ""; - if (text) dataHeaders.push(text); - } - - // Match against completions: find the spark table with matching - // notation and data headers (order matters) - const match = pageTables.find( - (st) => - st.notation.toLowerCase() === firstHeader.toLowerCase() && - st.headers.length === dataHeaders.length && - st.headers.every((h, i) => h === dataHeaders[i]), - ); + // Find the matching completions entry by column slug + const match = pageTables.find((st) => { + // st.slug is the combined slug (pageName-columnSlug); + // colSlug is just the column part. Match by checking if + // the combined slug ends with the column slug. + return st.slug.endsWith(`-${colSlug}`); + }); if (!match) return; - // Inject the spark button + // Inject the spark button into the wrapper const btn = createSparkButton(match.slug); - const wrapper = table.parentElement; + const wrapper = el.parentElement; if (wrapper) { wrapper.style.position = "relative"; wrapper.classList.add("group"); diff --git a/src/markdown/table.ts b/src/markdown/table.ts index 52e1101..fc36ae2 100644 --- a/src/markdown/table.ts +++ b/src/markdown/table.ts @@ -1,4 +1,5 @@ import type { MarkedExtension, Tokens } from "marked"; +import Slugger from "github-slugger"; /** * 将表格数据转换为 CSV 格式字符串 @@ -7,67 +8,87 @@ import type { MarkedExtension, Tokens } from "marked"; * @returns CSV 格式字符串 */ function tableToCSV(headers: string[], rows: string[][]): string { - const escapeCell = (cell: string) => { - // 如果单元格包含逗号、换行或引号,需要转义 - if (cell.includes(',') || cell.includes('\n') || cell.includes('"') || cell.includes("#")) { - return `"${cell.replace(/"/g, '""')}"`; - } - return cell; - }; + const escapeCell = (cell: string) => { + // 如果单元格包含逗号、换行或引号,需要转义 + if ( + cell.includes(",") || + cell.includes("\n") || + cell.includes('"') || + cell.includes("#") + ) { + return `"${cell.replace(/"/g, '""')}"`; + } + return cell; + }; - const headerLine = headers.map(escapeCell).join(','); - const dataLines = rows.map(row => row.map(escapeCell).join(',')); + const headerLine = headers.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 { - return { - renderer: { - table(token: Tokens.Table) { - // 检查表头是否包含 md-table-label - const header = token.header; - let roll = ''; - const labelIndex = header.findIndex(cell => { - if(cell.text === 'md-roll-label' || cell.text.match(/(\d+)?d\d+/)){ - roll = ' roll=true'; - 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 `${csvData}\n`; + return { + renderer: { + table(token: Tokens.Table) { + // 检查表头是否包含 md-table-label + const header = token.header; + let roll = ""; + let spark = ""; + const labelIndex = header.findIndex((cell) => { + if (cell.text === "md-roll-label" || cell.text.match(/(\d+)?d\d+/)) { + roll = " roll=true"; + // If this is a spark table (first column is a pure dice formula), + // compute the data-column slug for DOM injection + if (SPARK_DICE_RE.test(cell.text)) { + const slugger = new Slugger(); + const dataHeaders = header + .filter((_, i) => i !== 0) + .map((h) => h.text); + const slug = dataHeaders + .map((h) => slugger.slug(h.toLowerCase())) + .join("-"); + spark = ` data-spark="${slug}"`; } + 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 `${csvData}\n`; + }, + }, + }; }