Compare commits

..

6 Commits

8 changed files with 366 additions and 46 deletions

View File

@ -145,22 +145,33 @@
```markdown ```markdown
:md-pins[./images/map.png]{pins="A:30,40 B:10,30" fixed} :md-pins[./images/map.png]{pins="A:30,40 B:10,30" fixed}
:md-pins[./images/city-map.png] :md-pins[./images/city-map.png]
:md-pins[./images/dungeon.png]{labelStart="1"}
:md-pins[./images/quest.png]{labelStart="C"}
``` ```
**功能:** **功能:**
- 在图片上显示标记点A-Z, AA-ZZ, AAA-ZZZ... - 在图片上显示标记点A-Z, AA-ZZ, AAA-ZZZ... 或数字
- `fixed` 模式:仅显示标记,不可编辑 - `fixed` 模式:仅显示标记,不可编辑
- 非 fixed 模式:点击图片添加标记,点击标记删除 - 非 fixed 模式:点击图片添加标记,点击标记删除
- 提供复制按钮生成标记代码 - 提供复制按钮生成标记代码
- 支持自定义标签起始值(字母或数字)
**属性:** **属性:**
| 属性 | 类型 | 说明 | | 属性 | 类型 | 默认值 | 说明 |
|------|------|------| |------|------|--------|------|
| `pins` | string | 标记列表,格式 `"A:30,40 B:10,30"` (标签x,y) | | `pins` | string | `""` | 标记列表,格式 `"A:30,40 B:10,30"` (标签x,y) |
| `fixed` | boolean | 是否为固定模式(只读) | | `fixed` | boolean | `false` | 是否为固定模式(只读) |
| `labelStart` | string | `"A"` | 标签起始值,支持字母(如 `"A"`, `"C"`)或数字(如 `"1"`, `"7"` |
**标记标签生成规则:** **标签生成规则:**
| 起始值类型 | 说明 | 示例 |
|------|------|------|
| 字母(默认) | 从起始字母开始,按 A-Z, AA-ZZ, AAA-ZZZ 顺序 | `A` → A, B, C...; `C` → C, D, E... |
| 数字 | 从起始数字开始递增 | `1` → 1, 2, 3...; `7` → 7, 8, 9... |
**字母标签序列(从 A 开始):**
- 0-25: A-Z - 0-25: A-Z
- 26-51: AA-ZZ - 26-51: AA-ZZ
- 52-77: AAA-ZZZ - 52-77: AAA-ZZZ
@ -168,12 +179,24 @@
**示例:** **示例:**
```markdown ```markdown
<!-- 固定标记模式 --> <!-- 固定标记模式 - 字母标签(默认从 A 开始) -->
:md-pins[./images/battle-map.png]{pins="A:25,50 B:75,30 C:50,80" fixed} :md-pins[./images/battle-map.png]{pins="A:25,50 B:75,30 C:50,80" fixed}
<!-- 可编辑模式 - 点击添加标记 --> <!-- 可编辑模式 - 点击添加标记 - 从 A 开始 -->
:md-pins[./images/blank-map.png] :md-pins[./images/blank-map.png]
<!-- 数字标签 - 从 1 开始 -->
:md-pins[./images/dungeon.png]{labelStart="1"}
<!-- 数字标签 - 从 7 开始 -->
:md-pins[./images/cave.png]{labelStart="7"}
<!-- 字母标签 - 从 C 开始 -->
:md-pins[./images/quest-map.png]{labelStart="C"}
<!-- 自定义标签 + 预设标记 -->
:md-pins[./images/map.png]{pins="5:30,40 6:75,30" labelStart="5" fixed}
<!-- 复制生成的格式 --> <!-- 复制生成的格式 -->
:md-pins[./images/map.png]{pins="A:30,40 B:10,30" fixed} :md-pins[./images/map.png]{pins="A:30,40 B:10,30" fixed}
``` ```

View File

@ -3,13 +3,13 @@ import {Show, For, createResource, createMemo, createSignal} from "solid-js";
import { resolvePath } from "./utils/path"; import { resolvePath } from "./utils/path";
import { createPinsStore } from "./stores/pinsStore"; import { createPinsStore } from "./stores/pinsStore";
customElement("md-pins", { pins: "", fixed: false }, (props, { element }) => { customElement("md-pins", { pins: "", fixed: false, labelStart: "A" }, (props, { element }) => {
noShadowDOM(); noShadowDOM();
const [showToast, setShowToast] = createSignal(false); const [showToast, setShowToast] = createSignal(false);
// 创建 store // 创建 store
const store = createPinsStore(props.pins, props.fixed); const store = createPinsStore(props.pins, props.fixed, props.labelStart);
// 从 element 的 textContent 获取图片路径 // 从 element 的 textContent 获取图片路径
const rawSrc = element?.textContent?.trim() || ''; const rawSrc = element?.textContent?.trim() || '';

View File

@ -1,8 +1,9 @@
import { customElement, noShadowDOM } from 'solid-element'; import { customElement, noShadowDOM } from 'solid-element';
import { createSignal, For, Show, createEffect, createMemo, createResource } from 'solid-js'; import { createSignal, For, Show, createEffect, createMemo, createResource } from 'solid-js';
import { marked } from '../markdown'; import { marked } from '../markdown';
import { loadCSV, CSV, processVariables, isCSV } from './utils/csv-loader';
import { resolvePath } from './utils/path'; import { resolvePath } from './utils/path';
import {loadCSV, CSV, processVariables} from './utils/csv-loader'; import { areAllLabelsNumeric, weightedRandomIndex } from './utils/weighted-random';
export interface TableProps { export interface TableProps {
roll?: boolean; roll?: boolean;
@ -23,8 +24,8 @@ customElement('md-table', { roll: false, remix: false }, (props, { element }) =>
const [bodyHtml, setBodyHtml] = createSignal(''); const [bodyHtml, setBodyHtml] = createSignal('');
let tabsContainer: HTMLDivElement | undefined; let tabsContainer: HTMLDivElement | undefined;
// 从 element 的 textContent 获取 CSV 路径 // 从 element 的 textContent 获取 CSV 路径或 inline CSV 数据
const src = element?.textContent?.trim() || ''; const rawContent = element?.textContent?.trim() || '';
// 隐藏原始文本内容 // 隐藏原始文本内容
if (element) { if (element) {
@ -35,11 +36,11 @@ customElement('md-table', { roll: false, remix: false }, (props, { element }) =>
const articleEl = element?.closest('article[data-src]'); const articleEl = element?.closest('article[data-src]');
const articlePath = articleEl?.getAttribute('data-src') || ''; const articlePath = articleEl?.getAttribute('data-src') || '';
// 解析相对路径 // 如果是 inline CSV直接使用否则解析相对路径
const resolvedSrc = resolvePath(articlePath, src); const contentOrPath = isCSV(rawContent) ? rawContent : resolvePath(articlePath, rawContent);
// 使用 createResource 加载 CSV自动响应路径变化并避免重复加载 // 使用 createResource 加载 CSV自动响应路径变化并避免重复加载
const [csvData] = createResource(() => resolvedSrc, loadCSV); const [csvData] = createResource(() => contentOrPath, loadCSV);
// 当数据加载完成后更新 rows // 当数据加载完成后更新 rows
createEffect(() => { createEffect(() => {
@ -108,8 +109,23 @@ customElement('md-table', { roll: false, remix: false }, (props, { element }) =>
// 随机切换 tab // 随机切换 tab
const handleRoll = () => { const handleRoll = () => {
const randomIndex = Math.floor(Math.random() * filteredRows().length); const filtered = filteredRows();
if (filtered.length === 0) return;
// 检查所有 label 是否都是整数/整数范围格式
const labels = filtered.map(row => row.label);
if (areAllLabelsNumeric(labels)) {
// 使用加权随机
const randomIndex = weightedRandomIndex(labels);
if (randomIndex !== -1) {
setActiveTab(randomIndex); setActiveTab(randomIndex);
}
} else {
// 使用简单随机
const randomIndex = Math.floor(Math.random() * filtered.length);
setActiveTab(randomIndex);
}
// 滚动到可视区域 // 滚动到可视区域
setTimeout(() => { setTimeout(() => {
const activeButton = tabsContainer?.querySelector('.border-blue-600'); const activeButton = tabsContainer?.querySelector('.border-blue-600');

View File

@ -10,10 +10,31 @@ interface PinsState {
pins: Pin[]; pins: Pin[];
rawSrc: string; rawSrc: string;
isFixed: boolean; isFixed: boolean;
labelStart: string;
} }
// 生成标签 A-Z, AA-ZZ, AAA-ZZZ ... // 判断 labelStart 是否为数字
export function generateLabel(index: number): string { function isNumericLabelStart(start: string): boolean {
return /^\d+$/.test(start);
}
// 生成字母标签 A-Z, AA-ZZ, AAA-ZZZ ... 从指定起始值开始
export function generateAlphaLabel(index: number, startLabel: string = 'A'): string {
const startOffset = alphaLabelToIndex(startLabel);
return indexToAlphaLabel(index + startOffset);
}
// 将字母标签转换为索引 (A=0, B=1, ..., Z=25, AA=26, ...)
export function alphaLabelToIndex(label: string): number {
let index = 0;
for (let i = 0; i < label.length; i++) {
index = index * 26 + (label.charCodeAt(i) - 64);
}
return index - 1;
}
// 将索引转换为字母标签 (0=A, 1=B, ..., 25=Z, 26=AA, ...)
export function indexToAlphaLabel(index: number): string {
const labels: string[] = []; const labels: string[] = [];
let num = index; let num = index;
@ -26,12 +47,18 @@ export function generateLabel(index: number): string {
return labels.join(''); return labels.join('');
} }
// 解析 pins 字符串 "A:30,40 B:10,30" -> Pin[] // 生成数字标签,从指定起始值开始
export function generateNumberLabel(index: number, startNum: number): string {
return String(startNum + index);
}
// 解析 pins 字符串 "A:30,40 B:10,30" 或 "1:30,40 2:10,30" -> Pin[]
export function parsePins(pinsStr: string): Pin[] { export function parsePins(pinsStr: string): Pin[] {
if (!pinsStr) return []; if (!pinsStr) return [];
const pins: Pin[] = []; const pins: Pin[] = [];
const regex = /([A-Z]+):(\d+),(\d+)/g; // 支持任意非空白字符作为标签(不仅限于大写字母)
const regex = /([^:\s]+):(\d+),(\d+)/g;
let match; let match;
while ((match = regex.exec(pinsStr)) !== null) { while ((match = regex.exec(pinsStr)) !== null) {
@ -51,28 +78,52 @@ export function formatPins(pins: Pin[]): string {
} }
// 找到最早未使用的标签 // 找到最早未使用的标签
export function findNextUnusedLabel(pins: Pin[]): string { export function findNextUnusedLabel(pins: Pin[], labelStart: string): string {
const usedLabels = new Set(pins.map(p => p.label)); const usedLabels = new Set(pins.map(p => p.label));
if (isNumericLabelStart(labelStart)) {
// 数字标签模式
const startNum = parseInt(labelStart);
let index = 0; let index = 0;
while (true) { while (true) {
const label = generateLabel(index); const label = generateNumberLabel(index, startNum);
if (!usedLabels.has(label)) { if (!usedLabels.has(label)) {
return label; return label;
} }
index++; index++;
if (index > 10000) break; // 安全限制 if (index > 10000) break; // 安全限制
} }
} else {
// 字母标签模式
let index = 0;
while (true) {
const label = generateAlphaLabel(index, labelStart);
if (!usedLabels.has(label)) {
return label;
}
index++;
if (index > 10000) break; // 安全限制
}
}
return generateLabel(pins.length); // 回退值
if (isNumericLabelStart(labelStart)) {
return generateNumberLabel(pins.length, parseInt(labelStart));
}
return generateAlphaLabel(pins.length, labelStart);
} }
// 创建 store 实例 // 创建 store 实例
export function createPinsStore(initialPinsStr: string = "", initialFixed: boolean = false) { export function createPinsStore(
initialPinsStr: string = "",
initialFixed: boolean = false,
initialLabelStart: string = "A"
) {
const [state, setState] = createStore<PinsState>({ const [state, setState] = createStore<PinsState>({
pins: parsePins(initialPinsStr), pins: parsePins(initialPinsStr),
rawSrc: "", rawSrc: "",
isFixed: initialFixed isFixed: initialFixed,
labelStart: initialLabelStart
}); });
const setRawSrc = (src: string) => { const setRawSrc = (src: string) => {
@ -80,7 +131,7 @@ export function createPinsStore(initialPinsStr: string = "", initialFixed: boole
}; };
const addPin = (x: number, y: number) => { const addPin = (x: number, y: number) => {
const label = findNextUnusedLabel(state.pins); const label = findNextUnusedLabel(state.pins, state.labelStart);
setState("pins", [...state.pins, { x, y, label }]); setState("pins", [...state.pins, { x, y, label }]);
}; };
@ -92,7 +143,8 @@ export function createPinsStore(initialPinsStr: string = "", initialFixed: boole
const getCopyText = () => { const getCopyText = () => {
const pinsStr = formatCurrentPins(); const pinsStr = formatCurrentPins();
return `:md-pins[${state.rawSrc}]{pins="${pinsStr}" fixed}`; const labelStartAttr = state.labelStart !== 'A' ? ` labelStart="${state.labelStart}"` : '';
return `:md-pins[${state.rawSrc}]{pins="${pinsStr}" fixed${labelStartAttr}`;
}; };
return { return {

View File

@ -31,13 +31,87 @@ function parseFrontMatter(content: string): { frontmatter?: JSONObject; remainin
} }
} }
/**
* CSV
* @param str
* @returns CSV true
*/
export function isCSV(str: string): boolean {
const trimmed = str.trim();
// 检查是否以 YAML front matter 开头
if (trimmed.startsWith('---\n') || trimmed.startsWith('---\r\n')) {
return true;
}
// 检查是否包含 CSV 特征:多行且有分隔符
const lines = trimmed.split(/\r?\n/).filter(line => line.trim() !== '');
if (lines.length < 2) {
return false;
}
// 检测常见 CSV 分隔符
const separators = [',', '\t', ';', '|'];
const firstLine = lines[0];
for (const sep of separators) {
if (firstLine.includes(sep)) {
// 检查其他行是否也有相同的分隔符
const hasSeparatorInOtherLines = lines.slice(1).some(line => line.includes(sep));
if (hasSeparatorInOtherLines) {
return true;
}
}
}
return false;
}
/**
* CSV
* @template T Record<string, string>
* @param csvString CSV
* @param sourcePath
* @returns CSV
*/
export function parseCSVString<T = Record<string, string>>(csvString: string, sourcePath: string = 'inline'): CSV<T> {
// 解析 front matter
const { frontmatter, remainingContent } = parseFrontMatter(csvString);
const records = parse(remainingContent, {
columns: true,
comment: '#',
trim: true,
skipEmptyLines: true
});
const result = records as Record<string, string>[];
// 添加 front matter 到结果中
const csvResult = result as CSV<T>;
if (frontmatter) {
csvResult.frontmatter = frontmatter;
for(const each of result){
Object.assign(each, frontmatter);
}
}
csvResult.sourcePath = sourcePath;
return csvResult;
}
/** /**
* CSV * CSV
* @template T Record<string, string> * @template T Record<string, string>
* @param pathOrContent inline CSV
* @returns CSV
*/ */
export async function loadCSV<T = Record<string, string>>(path: string): Promise<CSV<T>> { export async function loadCSV<T = Record<string, string>>(pathOrContent: string): Promise<CSV<T>> {
// 尝试从索引获取 // 检测是否是 inline CSV 数据
const content = await getIndexedData(path); if (isCSV(pathOrContent)) {
return parseCSVString<T>(pathOrContent, 'inline');
}
// 从索引获取文件内容
const content = await getIndexedData(pathOrContent);
// 解析 front matter // 解析 front matter
const { frontmatter, remainingContent } = parseFrontMatter(content); const { frontmatter, remainingContent } = parseFrontMatter(content);
@ -58,7 +132,7 @@ export async function loadCSV<T = Record<string, string>>(path: string): Promise
Object.assign(each, frontmatter); Object.assign(each, frontmatter);
} }
} }
csvResult.sourcePath = path; csvResult.sourcePath = pathOrContent;
return csvResult; return csvResult;
} }
@ -84,5 +158,5 @@ export function processVariables<T extends JSONObject> (body: string, currentRow
return `{{${key}}}`; return `{{${key}}}`;
} }
return body?.replace(/\{\{(\w+)\}\}/g, (_, key) => `${replaceProp(key)}`) || ''; return body?.replace(/\{\{([^}]+)\}\}/g, (_, key) => `${replaceProp(key)}`) || '';
} }

View File

@ -0,0 +1,88 @@
/**
*
* @param label "1" "1-3"
* @returns [min, max] null
*/
export function parseIntegerRange(label: string): [number, number] | null {
const trimmed = label.trim();
// 尝试匹配整数范围格式 (如 "1-3")
const rangeMatch = trimmed.match(/^(\d+)\s*-\s*(\d+)$/);
if (rangeMatch) {
const min = parseInt(rangeMatch[1]!, 10);
const max = parseInt(rangeMatch[2]!, 10);
if (min <= max) {
return [min, max];
}
}
// 尝试匹配单个整数格式 (如 "5")
const intMatch = trimmed.match(/^(\d+)$/);
if (intMatch) {
const num = parseInt(intMatch[1]!, 10);
return [num, num];
}
return null;
}
/**
*
* @param labels
* @returns
*/
export function calculateTotalWeight(labels: string[]): number {
let total = 0;
for (const label of labels) {
const range = parseIntegerRange(label);
if (range) {
const [min, max] = range;
total += max - min + 1;
}
}
return total;
}
/**
*
* @param labels
* @param totalWeight
* @returns -1
*/
export function weightedRandomIndex(labels: string[], totalWeight?: number): number {
const weight = totalWeight ?? calculateTotalWeight(labels);
if (weight === 0) {
return -1;
}
// 生成一个随机权重值 (1 到 totalWeight)
const roll = Math.floor(Math.random() * weight) + 1;
let cumulative = 0;
for (let i = 0; i < labels.length; i++) {
const range = parseIntegerRange(labels[i]!);
if (range) {
const [min, max] = range;
const labelWeight = max - min + 1;
cumulative += labelWeight;
if (roll <= cumulative) {
return i;
}
}
}
// 理论上不会到这里,但为了安全返回最后一个
return labels.length - 1;
}
/**
* label
* @param labels
* @returns label / true
*/
export function areAllLabelsNumeric(labels: string[]): boolean {
if (labels.length === 0) return false;
return labels.every(label => parseIntegerRange(label) !== null);
}

View File

@ -1,8 +1,9 @@
import { Marked } from 'marked'; import { Marked, type MarkedExtension } from 'marked';
import {createDirectives, presetDirectiveConfigs} from 'marked-directive'; import {createDirectives, presetDirectiveConfigs} from 'marked-directive';
import yaml from 'js-yaml'; import yaml from 'js-yaml';
import markedAlert from "marked-alert"; import markedAlert from "marked-alert";
import markedMermaid from "./mermaid"; import markedMermaid from "./mermaid";
import markedTable from "./table";
import {gfmHeadingId} from "marked-gfm-heading-id"; import {gfmHeadingId} from "marked-gfm-heading-id";
let globalIconPrefix: string | undefined = undefined; let globalIconPrefix: string | undefined = undefined;
@ -14,11 +15,13 @@ function overrideIconPrefix(path?: string){
} }
} }
} }
// 使用 marked-directive 来支持指令语法 // 使用 marked-directive 来支持指令语法
const marked = new Marked() const marked = new Marked()
.use(gfmHeadingId()) .use(gfmHeadingId())
.use(markedAlert()) .use(markedAlert())
.use(markedMermaid()) .use(markedMermaid())
.use(markedTable())
.use(createDirectives([ .use(createDirectives([
...presetDirectiveConfigs, ...presetDirectiveConfigs,
{ {

64
src/markdown/table.ts Normal file
View File

@ -0,0 +1,64 @@
import type { MarkedExtension, Tokens } from "marked";
/**
* CSV
* @param headers
* @param rows
* @returns CSV
*/
function tableToCSV(headers: string[], rows: string[][]): string {
const escapeCell = (cell: string) => {
// 如果单元格包含逗号、换行或引号,需要转义
if (cell.includes(',') || cell.includes('\n') || cell.includes('"')) {
return `"${cell.replace(/"/g, '""')}"`;
}
return cell;
};
const headerLine = headers.map(escapeCell).join(',');
const dataLines = rows.map(row => row.map(escapeCell).join(','));
return [headerLine, ...dataLines].join('\n');
}
export default function markedTable(): MarkedExtension {
return {
renderer: {
table(token: Tokens.Table) {
// 检查表头是否包含 md-table-label
const header = token.header;
const labelIndex = header.findIndex(cell => cell.text === 'md-table-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 = header
.filter(cell => cell.text !== 'md-table-label')
.map(cell => cell.text);
// 构建 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 `<md-table>${csvData}</md-table>\n`;
}
}
};
}