Compare commits

...

5 Commits

Author SHA1 Message Date
hypercross 5c26fa407d refactor: deck layer editor ui 2026-02-28 12:28:23 +08:00
hypercross f53dc847ca refactor: clean up deck wise font size 2026-02-28 12:20:34 +08:00
hypercross a2ac902129 feat: csv front matter 2026-02-28 12:16:58 +08:00
hypercross 1dca41b36a feat: add layer font size 2026-02-28 11:50:57 +08:00
hypercross 19256b501f feat: yaml tag block 2026-02-28 11:44:42 +08:00
16 changed files with 257 additions and 93 deletions

35
content/deck-test.md Normal file
View File

@ -0,0 +1,35 @@
# yaml/tag 代码块格式测试
## 使用 yaml/tag 语法创建 md-deck
```yaml/tag
tag: md-deck
body: ./sparks.csv
size: 54x86
grid: 5x8
bleed: 1
padding: 2
font-size: 3
```
## 使用 body 字段添加内容
```yaml/tag
tag: tag-box
class: note
body: |
这是一个提示框
```
## 带引号的值
```yaml/tag
tag: tag-alert
type: warning
class: my-alert
body: 这是一个警告信息
```
## 旧的指令语法仍然可用
:md-deck[./sparks.csv]{size="54x86" grid="5x8"}

19
package-lock.json generated
View File

@ -13,6 +13,7 @@
"chokidar": "^5.0.0",
"commander": "^12.1.0",
"csv-parse": "^5.5.6",
"js-yaml": "^4.1.1",
"marked": "^14.1.0",
"marked-directive": "^1.0.7",
"solid-element": "^1.9.1",
@ -2251,6 +2252,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/attributes-parser": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/attributes-parser/-/attributes-parser-2.2.3.tgz",
@ -2669,6 +2676,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",

View File

@ -34,6 +34,7 @@
"chokidar": "^5.0.0",
"commander": "^12.1.0",
"csv-parse": "^5.5.6",
"js-yaml": "^4.1.1",
"marked": "^14.1.0",
"marked-directive": "^1.0.7",
"solid-element": "^1.9.1",

View File

@ -36,7 +36,7 @@ export function CardLayer(props: CardLayerProps) {
class="absolute flex items-center justify-center text-center prose prose-sm"
style={{
...getLayerStyle(layer, props.dimensions),
'font-size': `${props.dimensions.fontSize}mm`
'font-size': `${layer.fontSize || 3}mm`
}}
innerHTML={renderLayerContent(layer, props.cardData)}
/>

View File

@ -31,7 +31,6 @@ export function PrintPreview(props: PrintPreviewProps) {
gridOriginY: store.state.dimensions?.gridOriginY || 0,
gridAreaWidth: store.state.dimensions?.gridAreaWidth || 56,
gridAreaHeight: store.state.dimensions?.gridAreaHeight || 88,
fontSize: store.state.dimensions?.fontSize || 3,
visibleLayers: visibleLayers(),
dimensions: store.state.dimensions!
};

View File

@ -25,6 +25,13 @@ export function LayerEditorPanel(props: LayerEditorPanelProps) {
}
};
const updateLayerFontSize = (layerProp: string, fontSize?: number) => {
const layer = store.state.layerConfigs.find(l => l.prop === layerProp);
if (layer) {
store.actions.updateLayerConfig(layerProp, { ...layer, fontSize });
}
};
return (
<div class="w-64 flex-shrink-0">
<h3 class="font-bold mb-2 mt-0"></h3>
@ -32,7 +39,7 @@ export function LayerEditorPanel(props: LayerEditorPanelProps) {
<div class="space-y-2">
<For each={store.state.layerConfigs}>
{(layer) => (
<div class="flex flex-col gap-1 p-2 bg-gray-50 rounded">
<div class="flex flex-row flex-wrap gap-1 p-2 bg-gray-50 rounded">
<div class="flex items-center gap-2">
<input
type="checkbox"
@ -42,6 +49,16 @@ export function LayerEditorPanel(props: LayerEditorPanelProps) {
/>
<span class="text-sm flex-1">{layer.prop}</span>
</div>
<button
onClick={() => store.actions.setEditingLayer(store.state.editingLayer === layer.prop ? null : layer.prop)}
class={`text-xs px-2 py-1 rounded cursor-pointer ${
store.state.editingLayer === layer.prop
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
{store.state.editingLayer === layer.prop ? '✓ 框选' : '框选'}
</button>
<div class="flex items-center gap-2">
<select
value={layer.orientation || 'n'}
@ -54,16 +71,21 @@ export function LayerEditorPanel(props: LayerEditorPanelProps) {
)}
</For>
</select>
<button
onClick={() => store.actions.setEditingLayer(store.state.editingLayer === layer.prop ? null : layer.prop)}
class={`text-xs px-2 py-1 rounded cursor-pointer ${
store.state.editingLayer === layer.prop
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
{store.state.editingLayer === layer.prop ? '✓ 框选' : '框选'}
</button>
</div>
<div class="flex items-center gap-2">
<label class="text-xs text-gray-600">/mm</label>
<input
type="number"
value={layer.fontSize || ''}
placeholder="默认"
onChange={(e) => {
const value = e.target.value;
updateLayerFontSize(layer.prop, value ? Number(value) : undefined);
}}
class="w-16 text-xs px-2 py-1 rounded border border-gray-300 bg-white"
step="0.1"
min="0.1"
/>
</div>
</div>
)}

View File

@ -5,7 +5,7 @@ export interface PropertiesEditorPanelProps {
}
/**
*
*
*/
export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) {
const { store } = props;
@ -56,17 +56,7 @@ export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) {
</div>
<div>
<label class="block text-sm font-medium text-gray-700"> (mm)</label>
<input
type="number"
class="w-full border border-gray-300 rounded px-2 py-1 text-sm"
value={store.state.fontSize}
onChange={(e) => store.actions.setFontSize(Number(e.target.value))}
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700"> (mm)</label>
<label class="block text-sm font-medium text-gray-700"> / (mm)</label>
<div class="flex gap-2">
<input
type="number"
@ -75,18 +65,12 @@ export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) {
onChange={(e) => store.actions.setBleed(Number(e.target.value))}
placeholder="出血"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700"> (mm)</label>
<div class="flex gap-2">
<input
type="number"
class="w-full border border-gray-300 rounded px-2 py-1 text-sm"
value={store.state.padding}
onChange={(e) => store.actions.setPadding(Number(e.target.value))}
placeholder="内边距"
type="number"
class="w-full border border-gray-300 rounded px-2 py-1 text-sm"
value={store.state.padding}
onChange={(e) => store.actions.setPadding(Number(e.target.value))}
placeholder="内边距"
/>
</div>
</div>

View File

@ -1,7 +1,7 @@
import { createStore } from 'solid-js/store';
import { calculateDimensions } from './dimensions';
import { loadCSV } from '../../utils/csv-loader';
import { initLayerConfigs } from './layer-parser';
import { initLayerConfigs, formatLayers } from './layer-parser';
import type { CardData, LayerConfig, Dimensions } from '../types';
/**
@ -13,8 +13,7 @@ export const DECK_DEFAULTS = {
GRID_W: 5,
GRID_H: 8,
BLEED: 1,
PADDING: 2,
FONT_SIZE: 3
PADDING: 2
} as const;
export interface DeckState {
@ -25,7 +24,6 @@ export interface DeckState {
gridH: number;
bleed: number;
padding: number;
fontSize: number;
fixed: boolean;
src: string;
rawSrc: string; // 原始 CSV 路径(用于生成代码时保持相对路径)
@ -74,7 +72,6 @@ export interface DeckActions {
setGridH: (grid: number) => void;
setBleed: (bleed: number) => void;
setPadding: (padding: number) => void;
setFontSize: (size: number) => void;
// 数据设置
setCards: (cards: CardData[]) => void;
@ -138,7 +135,6 @@ export function createDeckStore(
gridH: DECK_DEFAULTS.GRID_H,
bleed: DECK_DEFAULTS.BLEED,
padding: DECK_DEFAULTS.PADDING,
fontSize: DECK_DEFAULTS.FONT_SIZE,
fixed: false,
src: initialSrc,
rawSrc: initialSrc,
@ -169,8 +165,7 @@ export function createDeckStore(
gridW: state.gridW,
gridH: state.gridH,
bleed: state.bleed,
padding: state.padding,
fontSize: state.fontSize
padding: state.padding
});
setState({ dimensions: dims });
};
@ -199,10 +194,6 @@ export function createDeckStore(
setState({ padding });
updateDimensions();
};
const setFontSize = (size: number) => {
setState({ fontSize: size });
updateDimensions();
};
const setCards = (cards: CardData[]) => setState({ cards, activeTab: 0 });
const setActiveTab = (index: number) => setState({ activeTab: index });
@ -277,11 +268,8 @@ export function createDeckStore(
const clearError = () => setState({ error: null });
const generateCode = () => {
const layersStr = state.layerConfigs
.filter(l => l.visible)
.map(l => `${l.prop}:${l.x1},${l.y1}-${l.x2},${l.y2}${l.orientation && l.orientation !== 'n' ? `${l.orientation}` : ''}`)
.join(' ');
return `:md-deck[${state.rawSrc || state.src}]{size="${state.sizeW}x${state.sizeH}" grid="${state.gridW}x${state.gridH}" bleed="${state.bleed}" padding="${state.padding}" font-size="${state.fontSize}" layers="${layersStr}"}`;
const layersStr = formatLayers(state.layerConfigs);
return `:md-deck[${state.rawSrc || state.src}]{size="${state.sizeW}x${state.sizeH}" grid="${state.gridW}x${state.gridH}" bleed="${state.bleed}" padding="${state.padding}" layers="${layersStr}"}`;
};
const copyCode = async () => {
@ -326,7 +314,6 @@ export function createDeckStore(
setGridH,
setBleed,
setPadding,
setFontSize,
setCards,
setActiveTab,
updateCardData,

View File

@ -7,7 +7,6 @@ export interface DimensionOptions {
padding: number;
gridW: number;
gridH: number;
fontSize?: number;
}
/**
@ -41,7 +40,6 @@ export function calculateDimensions(options: DimensionOptions): Dimensions {
gridH: options.gridH,
gridOriginX,
gridOriginY,
fontSize: options.fontSize ?? 3
};
}

View File

@ -1,13 +1,16 @@
import type { Layer, LayerConfig } from '../types';
/**
* layers "body:1,7-5,8 title:1,1-5,1" "body:1,7-5,8,s title:1,1-5,1,e"
* layers
* body:1,7-5,8 title:1,1-4,1f6.6s
* f[fontSize]
*/
export function parseLayers(layersStr: string): Layer[] {
if (!layersStr) return [];
const layers: Layer[] = [];
const regex = /(\w+):(\d+),(\d+)-(\d+),(\d+)([nsew])?/g;
// 匹配prop:x1,y1-x2,y2[ffontSize][direction]
const regex = /(\w+):(\d+),(\d+)-(\d+),(\d+)(?:f([\d.]+))?([nsew])?/g;
let match;
while ((match = regex.exec(layersStr)) !== null) {
@ -17,7 +20,8 @@ export function parseLayers(layersStr: string): Layer[] {
y1: parseInt(match[3]),
x2: parseInt(match[4]),
y2: parseInt(match[5]),
orientation: match[6] as 'n' | 's' | 'e' | 'w' | undefined
fontSize: match[7] ? parseFloat(match[7]) : undefined,
orientation: match[8] as 'n' | 's' | 'e' | 'w' | undefined
});
}
@ -30,7 +34,16 @@ export function parseLayers(layersStr: string): Layer[] {
export function formatLayers(layers: LayerConfig[]): string {
return layers
.filter(l => l.visible)
.map(l => `${l.prop}:${l.x1},${l.y1}-${l.x2},${l.y2}${l.orientation && l.orientation !== 'n' ? `${l.orientation}` : ''}`)
.map(l => {
let str = `${l.prop}:${l.x1},${l.y1}-${l.x2},${l.y2}`;
if (l.fontSize) {
str += `f${l.fontSize}`;
}
if (l.orientation && l.orientation !== 'n') {
str += l.orientation;
}
return str;
})
.join(' ');
}
@ -53,7 +66,8 @@ export function initLayerConfigs(
y1: existing?.y1 || 1,
x2: existing?.x2 || 2,
y2: existing?.y2 || 2,
orientation: existing?.orientation
orientation: existing?.orientation,
fontSize: existing?.fontSize
};
});
}

View File

@ -29,7 +29,6 @@ export interface ExportOptions {
gridOriginY: number;
gridAreaWidth: number;
gridAreaHeight: number;
fontSize: number;
visibleLayers: LayerConfig[];
dimensions: Dimensions;
}

View File

@ -16,7 +16,6 @@ interface DeckProps {
gridH?: number;
bleed?: number | string;
padding?: number | string;
fontSize?: number;
layers?: string;
fixed?: boolean | string;
}
@ -30,7 +29,6 @@ customElement<DeckProps>('md-deck', {
gridH: 8,
bleed: 1,
padding: 2,
fontSize: 3,
layers: '',
fixed: false
}, (props, { element }) => {
@ -87,12 +85,6 @@ customElement<DeckProps>('md-deck', {
store.actions.setPadding(props.padding ?? 2);
}
if (typeof props.fontSize === 'string') {
store.actions.setFontSize(Number(props.fontSize));
} else {
store.actions.setFontSize(props.fontSize ?? 3);
}
// 加载 CSV 数据
store.actions.loadCardsFromPath(resolvedSrc, csvPath, (props.layers as string) || '');

View File

@ -9,6 +9,7 @@ export interface Layer {
x2: number;
y2: number;
orientation?: 'n' | 's' | 'e' | 'w';
fontSize?: number;
}
export interface LayerConfig {
@ -19,6 +20,7 @@ export interface LayerConfig {
x2: number;
y2: number;
orientation?: 'n' | 's' | 'e' | 'w';
fontSize?: number;
}
export interface Dimensions {
@ -32,7 +34,6 @@ export interface Dimensions {
gridH: number;
gridOriginX: number;
gridOriginY: number;
fontSize: number;
}
export interface SelectionState {

View File

@ -2,7 +2,7 @@ import { customElement, noShadowDOM } from 'solid-element';
import { createSignal, For, Show, createEffect, createMemo, createResource } from 'solid-js';
import { marked } from '../markdown';
import { resolvePath } from './utils/path';
import { loadCSV } from './utils/csv-loader';
import {loadCSV, CSV, processVariables} from './utils/csv-loader';
export interface TableProps {
roll?: boolean;
@ -17,7 +17,7 @@ interface TableRow {
customElement('md-table', { roll: false, remix: false }, (props, { element }) => {
noShadowDOM();
const [rows, setRows] = createSignal<TableRow[]>([]);
const [rows, setRows] = createSignal<CSV<TableRow>>([]);
const [activeTab, setActiveTab] = createSignal(0);
const [activeGroup, setActiveGroup] = createSignal<string | null>(null);
const [bodyHtml, setBodyHtml] = createSignal('');
@ -78,21 +78,8 @@ customElement('md-table', { roll: false, remix: false }, (props, { element }) =>
// 处理 body 内容中的 {{prop}} 语法并解析 markdown
const processBody = (body: string, currentRow: TableRow): string => {
let processedBody = body;
if (!props.remix) {
// 不启用 remix 时,只替换当前行的引用
processedBody = body.replace(/\{\{(\w+)\}\}/g, (_, key) => currentRow[key] || '');
} else {
// 启用 remix 时,每次引用使用随机行的内容
processedBody = body.replace(/\{\{(\w+)\}\}/g, (_, key) => {
const randomRow = rows()[Math.floor(Math.random() * rows().length)];
return randomRow?.[key] || '';
});
}
// 使用 marked 解析 markdown
return marked.parse(processedBody) as string;
return marked.parse(processVariables(body, currentRow, rows(), filteredRows(), props.remix)) as string;
};
// 更新 body 内容

View File

@ -1,22 +1,61 @@
import { parse } from 'csv-parse/browser/esm/sync';
import yaml from 'js-yaml';
/**
* CSV
*/
const csvCache = new Map<string, Record<string, string>[]>();
/**
* front matter
* @param content front matter
* @returns front matter
*/
function parseFrontMatter(content: string): { frontmatter?: JSONObject; remainingContent: string } {
// 检查是否以 --- 开头
if (!content.trim().startsWith('---')) {
return { remainingContent: content };
}
// 分割内容
const parts = content.split(/(?:^|\n)---\s*\n/);
// 至少需要三个部分空字符串、front matter、剩余内容
if (parts.length < 3) {
return { remainingContent: content };
}
try {
// 解析 YAML front matter
const frontmatterStr = parts[1].trim();
const frontmatter = yaml.load(frontmatterStr) as JSONObject;
// 剩余内容是第三部分及之后的所有内容
const remainingContent = parts.slice(2).join('---\n').trimStart();
return { frontmatter, remainingContent };
} catch (error) {
console.warn('Failed to parse front matter:', error);
return { remainingContent: content };
}
}
/**
* CSV
* @template T Record<string, string>
*/
export async function loadCSV<T = Record<string, string>>(path: string): Promise<T[]> {
export async function loadCSV<T = Record<string, string>>(path: string): Promise<CSV<T>> {
if (csvCache.has(path)) {
return csvCache.get(path)! as T[];
return csvCache.get(path)! as CSV<T>;
}
const response = await fetch(path);
const content = await response.text();
const records = parse(content, {
// 解析 front matter
const { frontmatter, remainingContent } = parseFrontMatter(content);
const records = parse(remainingContent, {
columns: true,
comment: '#',
trim: true,
@ -24,6 +63,36 @@ export async function loadCSV<T = Record<string, string>>(path: string): Promise
});
const result = records as Record<string, string>[];
// 添加 front matter 到结果中
const csvResult = result as CSV<T>;
if (frontmatter) {
csvResult.frontmatter = frontmatter;
}
csvCache.set(path, result);
return result as T[];
return csvResult;
}
type JSONData = JSONArray | JSONObject | string | number | boolean | null;
interface JSONArray extends Array<JSONData> {}
interface JSONObject extends Record<string, JSONData> {}
export type CSV<T> = T[] & {
frontmatter?: JSONObject;
}
export function processVariables<T extends JSONObject> (body: string, currentRow: T, csv: CSV<T>, filtered?: T[], remix?: boolean): string {
const rolled = filtered || csv;
function replaceProp(key: string) {
const row = remix ?
rolled[Math.floor(Math.random() * rolled.length)] :
currentRow;
const frontMatter = csv.frontmatter;
if(key in row) return row[key];
if(frontMatter && key in frontMatter) return frontMatter[key];
return '';
}
return body.replace(/\{\{(\w+)\}\}/g, (_, key) => `${replaceProp(key)}`);
}

View File

@ -1,5 +1,6 @@
import { Marked } from 'marked';
import {createDirectives, presetDirectiveConfigs} from 'marked-directive';
import yaml from 'js-yaml';
// 使用 marked-directive 来支持指令语法
const marked = new Marked().use(createDirectives([
@ -23,7 +24,63 @@ const marked = new Marked().use(createDirectives([
return false;
}
},
]));
]), {
// 自定义代码块渲染器,支持 yaml/tag 格式
extensions: [{
name: 'code-block-yaml-tag',
level: 'block',
start(src: string) {
// 检测 ```yaml/tag 开头的代码块
return src.match(/^```yaml\/tag\s*\n/m)?.index;
},
tokenizer(src: string) {
const rule = /^```yaml\/tag\s*\n([\s\S]*?)\n```/;
const match = rule.exec(src);
if (match) {
const yamlContent = match[1]?.trim() || '';
const props = yaml.load(yamlContent) as Record<string, unknown> || {};
// 提取 tag 名称,默认为 tag-unknown
const tagName = (props.tag as string) || 'tag-unknown';
// 移除 tag 属性,剩下的作为 HTML 属性
const { tag, ...rest } = props;
// 提取 innerText 内容(如果有 body 字段)
let content = '';
if ('body' in rest) {
content = String(rest.body || '');
delete (rest as Record<string, unknown>).body;
}
// 构建属性字符串
const propsStr = Object.entries(rest)
.map(([key, value]) => {
const strValue = String(value);
// 如果值包含空格或特殊字符,添加引号
if (strValue.includes(' ') || strValue.includes('"')) {
return `${key}="${strValue.replace(/"/g, '&quot;')}"`;
}
return `${key}="${strValue}"`;
})
.join(' ');
return {
type: 'code-block-yaml-tag',
raw: match[0],
tagName,
props: propsStr,
content
};
}
},
renderer(token: any) {
// 渲染为自定义 HTML 标签
const propsAttr = token.props ? ` ${token.props}` : '';
return `<${token.tagName}${propsAttr}>${token.content || ''}</${token.tagName}>\n`;
}
}]
});
export function parseMarkdown(content: string): string {
return marked.parse(content.trimStart()) as string;