refactor: store based md-pins

This commit is contained in:
hypercross 2026-02-27 13:55:20 +08:00
parent b83e884f6f
commit fdecd0fb6e
2 changed files with 132 additions and 95 deletions

View File

@ -1,78 +1,22 @@
import { customElement, noShadowDOM } from "solid-element"; import { customElement, noShadowDOM } from "solid-element";
import { createSignal, onMount, onCleanup, Show, For, createResource, createMemo } from "solid-js"; import {Show, For, createResource, createMemo, createSignal} from "solid-js";
import { resolvePath } from "./utils/path"; import { resolvePath } from "./utils/path";
import { createPinsStore } from "./stores/pinsStore";
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('');
}
// 解析 pins 字符串 "A:30,40 B:10,30" -> Pin[]
function parsePins(pinsStr: string): Pin[] {
if (!pinsStr) return [];
const pins: Pin[] = [];
const regex = /([A-Z]+):(\d+),(\d+)/g;
let match;
while ((match = regex.exec(pinsStr)) !== null) {
pins.push({
label: match[1],
x: parseInt(match[2]),
y: parseInt(match[3])
});
}
return pins;
}
// 格式化 pins 为字符串 "A:30,40 B:10,30"
function formatPins(pins: Pin[]): string {
return pins.map(pin => `${pin.label}:${pin.x},${pin.y}`).join(' ');
}
// 找到最早未使用的标签
function findNextUnusedLabel(pins: Pin[]): string {
const usedLabels = new Set(pins.map(p => p.label));
let index = 0;
while (true) {
const label = generateLabel(index);
if (!usedLabels.has(label)) {
return label;
}
index++;
if (index > 10000) break; // 安全限制
}
return generateLabel(pins.length);
}
customElement("md-pins", { pins: "", fixed: false }, (props, { element }) => { customElement("md-pins", { pins: "", fixed: false }, (props, { element }) => {
noShadowDOM(); noShadowDOM();
const [pins, setPins] = createSignal<Pin[]>([]);
const [showToast, setShowToast] = createSignal(false); const [showToast, setShowToast] = createSignal(false);
let editorContainer: HTMLDivElement | undefined;
// 创建 store
const store = createPinsStore(props.pins, props.fixed);
// 从 element 的 textContent 获取图片路径 // 从 element 的 textContent 获取图片路径
const rawSrc = element?.textContent?.trim() || ''; const rawSrc = element?.textContent?.trim() || '';
// 设置 rawSrc 到 store
store.setRawSrc(rawSrc);
// 隐藏原始文本内容 // 隐藏原始文本内容
if (element) { if (element) {
element.textContent = ""; element.textContent = "";
@ -98,22 +42,12 @@ customElement("md-pins", { pins: "", fixed: false }, (props, { element }) => {
const [image] = createResource(resolvedSrc, loadImage); const [image] = createResource(resolvedSrc, loadImage);
const visible = createMemo(() => !image.loading && !!image()); const visible = createMemo(() => !image.loading && !!image());
// 从 props.pins 初始化 pins
onMount(() => {
if (props.pins) {
const parsed = parsePins(props.pins);
if (parsed.length > 0) {
setPins(parsed);
}
}
});
// 添加 pin // 添加 pin
const addPin = (e: MouseEvent) => { const addPin = (e: MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (isFixed()) return; if (props.fixed) return;
const imgRect = (e.target as Element).getBoundingClientRect(); const imgRect = (e.target as Element).getBoundingClientRect();
const clickX = ((e.clientX - imgRect.left) / imgRect.width) * 100; const clickX = ((e.clientX - imgRect.left) / imgRect.width) * 100;
@ -121,25 +55,23 @@ customElement("md-pins", { pins: "", fixed: false }, (props, { element }) => {
const x = Math.round(clickX); const x = Math.round(clickX);
const y = Math.round(clickY); const y = Math.round(clickY);
const label = findNextUnusedLabel(pins());
setPins([...pins(), { x, y, label }]); store.addPin(x, y);
}; };
// 删除 pin // 删除 pin
const removePin = (index: number, e: MouseEvent) => { const removePin = (index: number, e: MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (isFixed()) return; if (props.fixed) return;
setPins(pins().filter((_, i) => i !== index)); store.removePin(index);
}; };
// 复制所有 pin 为 :md-editor-pin 格式 // 复制所有 pin 为 :md-editor-pin 格式
const copyPins = () => { const copyPins = () => {
const pinsStr = formatPins(pins()); const text = store.getCopyText();
const text = `:md-pins[${rawSrc}]{pins="${pinsStr}" fixed}`;
navigator.clipboard.writeText(text).then(() => { navigator.clipboard.writeText(text).then(() => {
setShowToast(true); setShowToast(true);
@ -149,26 +81,24 @@ customElement("md-pins", { pins: "", fixed: false }, (props, { element }) => {
}); });
}; };
const isFixed = () => props.fixed;
return ( return (
<div ref={editorContainer}> <div>
<Show when={visible() && image()}> <Show when={visible()}>
{/* 图片容器 */} {/* 图片容器 */}
<div class="relative" onClick={addPin}> <div class="relative" onClick={addPin}>
{/* 显示图片 */} {/* 显示图片 */}
<img src={resolvedSrc} alt="" class="inset-0" /> <img src={resolvedSrc} alt="" class="inset-0" />
{/* 透明遮罩层 */} {/* 透明遮罩层 */}
<Show when={!isFixed()}> <Show when={!props.fixed}>
<div class="absolute inset-0 bg-transparent hover:bg-black/10 transition-colors cursor-crosshair" /> <div class="absolute inset-0 bg-transparent hover:bg-black/10 transition-colors cursor-crosshair" />
</Show> </Show>
<Show when={isFixed()}> <Show when={props.fixed}>
<div class="absolute inset-0 pointer-events-none" /> <div class="absolute inset-0 pointer-events-none" />
</Show> </Show>
{/* 复制按钮 HUD */} {/* 复制按钮 HUD */}
<Show when={!isFixed()}> <Show when={!props.fixed}>
<div class="absolute top-2 right-2 z-20"> <div class="absolute top-2 right-2 z-20">
<button <button
onClick={(e) => { onClick={(e) => {
@ -184,19 +114,19 @@ customElement("md-pins", { pins: "", fixed: false }, (props, { element }) => {
</Show> </Show>
{/* Pin 列表 */} {/* Pin 列表 */}
<For each={pins()}> <For each={store.state.pins}>
{(pin, index) => ( {(pin, index) => (
<span <span
onClick={(e) => removePin(index(), e)} onClick={(e) => removePin(index(), e)}
class={`absolute transform -translate-x-1/2 -translate-y-1/2 pointer-events-auto class={`absolute transform -translate-x-1/2 -translate-y-1/2 pointer-events-auto
bg-red-500 text-white text-xs font-bold rounded-full w-6 h-6 bg-red-500 text-white text-xs font-bold rounded-full w-6 h-6
flex items-center justify-center shadow-lg flex items-center justify-center shadow-lg
${!isFixed() ? 'cursor-pointer hover:bg-red-600 hover:scale-110 transition-all z-10' : 'cursor-default z-10'}`} ${!props.fixed ? 'cursor-pointer hover:bg-red-600 hover:scale-110 transition-all z-10' : 'cursor-default z-10'}`}
style={{ style={{
left: `${pin.x}%`, left: `${pin.x}%`,
top: `${pin.y}%` top: `${pin.y}%`
}} }}
title={isFixed() ? `(${pin.x}, ${pin.y})` : `点击删除 (${pin.x}, ${pin.y})`} title={props.fixed ? `(${pin.x}, ${pin.y})` : `点击删除 (${pin.x}, ${pin.y})`}
> >
{pin.label} {pin.label}
</span> </span>
@ -208,7 +138,7 @@ customElement("md-pins", { pins: "", fixed: false }, (props, { element }) => {
{/* Toast 提示 */} {/* Toast 提示 */}
<Show when={showToast()}> <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"> <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 {store.state.pins.length} pin
</div> </div>
</Show> </Show>
</div> </div>

View File

@ -0,0 +1,107 @@
import { createStore } from "solid-js/store";
export interface Pin {
x: number;
y: number;
label: string;
}
interface PinsState {
pins: Pin[];
rawSrc: string;
isFixed: boolean;
}
// 生成标签 A-Z, AA-ZZ, AAA-ZZZ ...
export 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('');
}
// 解析 pins 字符串 "A:30,40 B:10,30" -> Pin[]
export function parsePins(pinsStr: string): Pin[] {
if (!pinsStr) return [];
const pins: Pin[] = [];
const regex = /([A-Z]+):(\d+),(\d+)/g;
let match;
while ((match = regex.exec(pinsStr)) !== null) {
pins.push({
label: match[1],
x: parseInt(match[2]),
y: parseInt(match[3])
});
}
return pins;
}
// 格式化 pins 为字符串 "A:30,40 B:10,30"
export function formatPins(pins: Pin[]): string {
return pins.map(pin => `${pin.label}:${pin.x},${pin.y}`).join(' ');
}
// 找到最早未使用的标签
export function findNextUnusedLabel(pins: Pin[]): string {
const usedLabels = new Set(pins.map(p => p.label));
let index = 0;
while (true) {
const label = generateLabel(index);
if (!usedLabels.has(label)) {
return label;
}
index++;
if (index > 10000) break; // 安全限制
}
return generateLabel(pins.length);
}
// 创建 store 实例
export function createPinsStore(initialPinsStr: string = "", initialFixed: boolean = false) {
const [state, setState] = createStore<PinsState>({
pins: parsePins(initialPinsStr),
rawSrc: "",
isFixed: initialFixed
});
const setRawSrc = (src: string) => {
setState("rawSrc", src);
};
const addPin = (x: number, y: number) => {
const label = findNextUnusedLabel(state.pins);
setState("pins", [...state.pins, { x, y, label }]);
};
const removePin = (index: number) => {
setState("pins", state.pins.filter((_, i) => i !== index));
};
const formatCurrentPins = () => formatPins(state.pins);
const getCopyText = () => {
const pinsStr = formatCurrentPins();
return `:md-pins[${state.rawSrc}]{pins="${pinsStr}" fixed}`;
};
return {
state,
setState,
setRawSrc,
addPin,
removePin,
formatCurrentPins,
getCopyText
};
}