Files
ttrpg-tools/src/components/md-deck/PltPreview.tsx
T
hyper a5021ff5b4 style: reformat PltPreview and fix SVG rendering
- Reformat code to use double quotes and consistent indentation
- Improve SVG path styling for travel paths and borders
- Fix layout nesting and indentation in the component template
- Add missing circle element to card index labels
2026-05-18 22:19:48 +08:00

247 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createSignal, For, Show, createMemo } from "solid-js";
import { Portal } from "solid-js/web";
import { parsePlt, extractCutPaths } from "../../plotcutter/parser";
import { generateTravelPaths, travelPathsToSvg } from "../../plotcutter/layout";
import type { CardPath } from "../../plotcutter/types";
import { calculateCenter, contourToSvgPath } from "../../plotcutter/contour";
export interface PltPreviewProps {
/** PLT 文件内容 */
pltCode: string;
/** 关闭回调 */
onClose: () => void;
}
/**
* 从 PLT 代码解析并生成卡片路径数据
*/
function parsePltToCardPaths(pltCode: string): {
cutPaths: [number, number][][];
cardPaths: CardPath[];
width: number;
height: number;
} {
const parsed = parsePlt(pltCode);
const cutPaths = extractCutPaths(parsed, 5); // 5mm 阈值
// 将解析的路径转换为 CardPath 格式用于显示
const cardPaths: CardPath[] = cutPaths.map((points, index) => {
const center = calculateCenter(points);
const pathD = contourToSvgPath(points);
const startPoint = points[0];
const endPoint = points[points.length - 1];
return {
pageIndex: 0,
cardIndex: index,
points,
centerX: center.x,
centerY: center.y,
pathD,
startPoint,
endPoint,
};
});
return {
cutPaths,
cardPaths,
width: parsed.width || 0,
height: parsed.height || 0,
};
}
/**
* PLT 预览组件 - 基于 PLT 文本解析显示切割路径预览
*
* 所有显示参数(尺寸、路径等)都从 PLT 文本解析获取。
*/
export function PltPreview(props: PltPreviewProps) {
// 解析传入的 PLT 代码
const parsedData = createMemo(() => {
if (!props.pltCode) {
return {
cutPaths: [] as [number, number][][],
cardPaths: [] as CardPath[],
width: 0,
height: 0,
};
}
return parsePltToCardPaths(props.pltCode);
});
// 生成空走路径
const travelPathD = createMemo(() => {
const cardPaths = parsedData().cardPaths;
const height = parsedData().height;
if (cardPaths.length === 0) return "";
const travelPaths = generateTravelPaths(cardPaths, height);
return travelPathsToSvg(travelPaths);
});
const handleDownload = () => {
if (!props.pltCode) {
alert("没有可导出的卡片");
return;
}
const blob = new Blob([props.pltCode], { type: "application/vnd.hp-HPGL" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `deck-plt-${new Date().toISOString().slice(0, 19).replace(/:/g, "-")}.plt`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
return (
<Portal>
<div class="fixed inset-0 bg-black/50 z-[60] overflow-auto">
<div class="min-h-screen py-20 px-4">
{/* 头部控制栏 */}
<div class="fixed top-4 left-1/2 -translate-x-1/2 bg-white shadow-lg rounded-lg px-4 py-3 flex items-center gap-4 z-50">
<h2 class="text-base font-bold m-0">PLT 切割预览</h2>
<div class="flex items-center gap-2 flex-1">
<Show when={parsedData().width > 0}>
<span class="text-sm text-gray-500">
尺寸:{parsedData().width.toFixed(1)}mm ×{" "}
{parsedData().height.toFixed(1)}mm
</span>
</Show>
<button
onClick={handleDownload}
class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded text-sm font-medium cursor-pointer flex items-center gap-1"
disabled={parsedData().cardPaths.length === 0}
>
📥 下载 PLT
</button>
<button
onClick={props.onClose}
class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-3 py-1.5 rounded text-sm font-medium cursor-pointer"
>
关闭
</button>
</div>
</div>
{/* 预览区域 */}
<Show
when={parsedData().width > 0 && parsedData().height > 0}
fallback={
<div class="flex items-center justify-center h-screen text-gray-500">
无法解析 PLT 文件尺寸
</div>
}
>
<div class="flex flex-col items-center gap-8 mt-20">
<svg
class="bg-white shadow-xl"
viewBox={`0 0 ${parsedData().width} ${parsedData().height}`}
style={{
width: `${Math.min(parsedData().width, 800)}mm`,
height: `${(parsedData().height / parsedData().width) * Math.min(parsedData().width, 800)}mm`,
}}
xmlns="http://www.w3.org/2000/svg"
>
{/* 边框 */}
<rect
x="0"
y="0"
width={parsedData().width}
height={parsedData().height}
fill="none"
stroke="#ccc"
stroke-width="0.5"
/>
{/* 空走路径(虚线) */}
<Show when={travelPathD()}>
<path
d={travelPathD()}
fill="none"
stroke="#999"
stroke-width="0.5"
stroke-dasharray="2,2"
/>
</Show>
{/* 切割路径 */}
<For each={parsedData().cardPaths}>
{(path) => {
return (
<g>
{/* 切割路径 */}
<path
d={path.pathD}
fill="none"
stroke="#3b82f6"
stroke-width="0.3"
/>
{/* 动画小球 */}
<circle r="0.8" fill="#ef4444">
<animateMotion
dur="4s"
repeatCount="indefinite"
path={path.pathD}
></animateMotion>
</circle>
{/* 序号标签 */}
<g
transform={`translate(${path.centerX}, ${path.centerY})`}
>
<circle
r="2"
fill="white"
stroke="#3b82f6"
stroke-width="0.1"
/>
<text
text-anchor="middle"
dominant-baseline="middle"
font-size="1.5"
fill="#3b82f6"
font-weight="bold"
>
{path.cardIndex + 1}
</text>
</g>
</g>
);
}}
</For>
</svg>
</div>
</Show>
{/* 图例说明 */}
<div class="fixed bottom-4 left-1/2 -translate-x-1/2 bg-white shadow-lg rounded-lg px-4 py-2 flex items-center gap-4 z-50">
<div class="flex items-center gap-2">
<div
class="w-6 h-0.5"
style={{ "border-bottom": "2px dashed #999" }}
></div>
<span class="text-sm text-gray-600">空走路径</span>
</div>
<div class="flex items-center gap-2">
<div
class="w-6 h-0.5"
style={{ "border-bottom": "2px solid #3b82f6" }}
></div>
<span class="text-sm text-gray-600">切割路径</span>
</div>
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded-full bg-red-500"></div>
<span class="text-sm text-gray-600">刀头</span>
</div>
</div>
</div>
</div>
</Portal>
);
}