feat: pin editor

This commit is contained in:
hypercross 2026-02-26 23:51:27 +08:00
parent a00e84da7f
commit 9a858918fe
2 changed files with 152 additions and 0 deletions

View File

@ -3,6 +3,7 @@ import './dice';
import './table';
import './md-link';
import './md-pin';
import './md-pin-editor';
// 导出组件
export { Article } from './Article';

View File

@ -0,0 +1,151 @@
import { customElement, noShadowDOM } from "solid-element";
import { createSignal, onMount, onCleanup, Show, For, createResource, createMemo } from "solid-js";
import { resolvePath } from "../utils/path";
interface Pin {
x: number;
y: number;
label: string;
}
// 生成标签 A-Z, AA-ZZ, AAA-ZZZ ...
function generateLabel(index: number): string {
const labels: string[] = [];
let num = index;
do {
const remainder = num % 26;
labels.unshift(String.fromCharCode(65 + remainder));
num = Math.floor(num / 26) - 1;
} while (num >= 0);
return labels.join('');
}
customElement("md-pin-editor", {}, (props, { element }) => {
noShadowDOM();
const [pins, setPins] = createSignal<Pin[]>([]);
const [showToast, setShowToast] = createSignal(false);
let editorContainer: HTMLDivElement | undefined;
// 从 element 的 textContent 获取图片路径
const rawSrc = element?.textContent?.trim() || '';
// 隐藏原始文本内容
if (element) {
element.textContent = "";
}
// 从父节点 article 的 data-src 获取当前 markdown 文件完整路径
const articleEl = element?.closest('article[data-src]');
const articlePath = articleEl?.getAttribute('data-src') || '';
// 解析相对路径
const resolvedSrc = resolvePath(articlePath, rawSrc);
// 加载图片
const loadImage = (src: string): Promise<HTMLImageElement> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
};
const [image] = createResource(resolvedSrc, loadImage);
const visible = createMemo(() => !image.loading && !!image());
// 添加 pin
const addPin = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const imgRect = (e.target as Element).getBoundingClientRect();
const clickX = ((e.clientX - imgRect.left) / imgRect.width) * 100;
const clickY = ((e.clientY - imgRect.top) / imgRect.height) * 100;
const x = Math.round(clickX);
const y = Math.round(clickY);
const label = generateLabel(pins().length);
setPins([...pins(), { x, y, label }]);
};
// 删除 pin
const removePin = (index: number, e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setPins(pins().filter((_, i) => i !== index));
};
// 复制所有 pin 为 md-pin 文本
const copyPins = () => {
const pinTexts = pins().map(pin => `:md-pin[${pin.label}]{x=${pin.x} y=${pin.y}}`);
const text = pinTexts.join('\n');
navigator.clipboard.writeText(text).then(() => {
setShowToast(true);
setTimeout(() => setShowToast(false), 2000);
}).catch(err => {
console.error('复制失败:', err);
});
};
return (
<div ref={editorContainer}>
<Show when={visible()}>
{/* 图片容器 */}
<div class="relative" onClick={addPin}>
{/* 显示图片 */}
<img src={resolvedSrc} alt="" class="inset-0" />
{/* 透明遮罩层 */}
<div class="absolute inset-0 bg-transparent hover:bg-black/10 transition-colors cursor-crosshair" />
{/* 复制按钮 HUD */}
<div class="absolute top-2 right-2 z-20">
<button
onClick={(e) => {
e.stopPropagation();
copyPins();
}}
class="bg-gray-800 hover:bg-gray-700 text-white px-3 py-1.5 rounded shadow-lg text-sm flex items-center gap-1 transition-colors"
title="复制所有 pin 坐标"
>
📋
</button>
</div>
{/* Pin 列表 */}
<For each={pins()}>
{(pin, index) => (
<span
onClick={(e) => removePin(index(), e)}
class="absolute transform -translate-x-1/2 -translate-y-1/2 pointer-events-auto cursor-pointer
bg-red-500 text-white text-xs font-bold rounded-full w-6 h-6
flex items-center justify-center shadow-lg
hover:bg-red-600 hover:scale-110 transition-all z-10"
style={{
left: `${pin.x}%`,
top: `${pin.y}%`
}}
title={`点击删除 (${pin.x}, ${pin.y})`}
>
{pin.label}
</span>
)}
</For>
</div>
</Show>
{/* Toast 提示 */}
<Show when={showToast()}>
<div class="fixed bottom-4 left-1/2 transform -translate-x-1/2 bg-gray-800 text-white px-4 py-2 rounded shadow-lg text-sm z-50">
{pins().length} pin
</div>
</Show>
</div>
);
});