Compare commits

...

4 Commits

Author SHA1 Message Date
hypercross f1a55bf83e fix: pin editor 2026-02-27 00:05:05 +08:00
hypercross 9a858918fe feat: pin editor 2026-02-26 23:51:27 +08:00
hypercross a00e84da7f chore: todo update 2026-02-26 23:32:25 +08:00
hypercross 3f2e8c786b cleanup: remove pin locator 2026-02-26 23:26:45 +08:00
5 changed files with 232 additions and 15 deletions

View File

@ -24,11 +24,11 @@ cli应当搜索目录下的所有`.md`文件,并为每个文件创建一条路
## ttrpg组件 ## ttrpg组件
`:dice[2d6+d8]`:骰子组件。将内容显示为链接,并在链接前添加一个骰子图标。 `:md-dice[2d6+d8]`:骰子组件。将内容显示为链接,并在链接前添加一个骰子图标。
点击骰子会将链接文本替换为一次骰点结果,再次点击文本会重置为骰点公式。 点击骰子会将链接文本替换为一次骰点结果,再次点击文本会重置为骰点公式。
若提供`{key="blah"}`则会将骰点结果记录在链接中`(?dice-blah=10)`,以允许跳转。 若提供`{key="blah"}`则会将骰点结果记录在链接中`(?dice-blah=10)`,以允许跳转。
:table[./sparks.csv]:表格组件。将内容显示为标签页,并根据`csv`表头显示内容: :md-table[./sparks.csv]:表格组件。将内容显示为标签页,并根据`csv`表头显示内容:
- `label`: 生成为tab label的内容。 - `label`: 生成为tab label的内容。
- `body`生成为tab body的内容。同样使用`marked`解析。 - `body`生成为tab body的内容。同样使用`marked`解析。
@ -39,6 +39,10 @@ cli应当搜索目录下的所有`.md`文件,并为每个文件创建一条路
- `{roll=true}`添加一个骰子标签点击会随机切换到一个tab。 - `{roll=true}`添加一个骰子标签点击会随机切换到一个tab。
- `{remix=true}``body`的内容每次引用`{{prop}}`时,使用随机行的内容,而不是同一行。 - `{remix=true}``body`的内容每次引用`{{prop}}`时,使用随机行的内容,而不是同一行。
:md-pin[A]{x=40 y=40}: 在图片特定位置添加一个标记。
:md-pin-editor: 为图片添加一个pin editor overlay.
## 样式 ## 样式
使用`@tailwindcss/typography`来管理`markdown`样式。 使用`@tailwindcss/typography`来管理`markdown`样式。

View File

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

View File

@ -0,0 +1,216 @@
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('');
}
// 解析 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-pin-editor", { pins: "", fixed: false }, (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());
// 从 props.pins 初始化 pins
onMount(() => {
if (props.pins) {
const parsed = parsePins(props.pins);
if (parsed.length > 0) {
setPins(parsed);
}
}
});
// 添加 pin
const addPin = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isFixed()) return;
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 = findNextUnusedLabel(pins());
setPins([...pins(), { x, y, label }]);
};
// 删除 pin
const removePin = (index: number, e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isFixed()) return;
setPins(pins().filter((_, i) => i !== index));
};
// 复制所有 pin 为 :md-editor-pin 格式
const copyPins = () => {
const pinsStr = formatPins(pins());
const text = `:md-pin-editor[${rawSrc}]{pins="${pinsStr}" fixed}`;
navigator.clipboard.writeText(text).then(() => {
setShowToast(true);
setTimeout(() => setShowToast(false), 2000);
}).catch(err => {
console.error('复制失败:', err);
});
};
const isFixed = () => props.fixed;
return (
<div ref={editorContainer}>
<Show when={visible() && image()}>
{/* 图片容器 */}
<div class="relative" onClick={addPin}>
{/* 显示图片 */}
<img src={resolvedSrc} alt="" class="inset-0" />
{/* 透明遮罩层 */}
<Show when={!isFixed()}>
<div class="absolute inset-0 bg-transparent hover:bg-black/10 transition-colors cursor-crosshair" />
</Show>
<Show when={isFixed()}>
<div class="absolute inset-0 pointer-events-none" />
</Show>
{/* 复制按钮 HUD */}
<Show when={!isFixed()}>
<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>
</Show>
{/* 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
bg-red-500 text-white text-xs font-bold rounded-full w-6 h-6
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'}`}
style={{
left: `${pin.x}%`,
top: `${pin.y}%`
}}
title={isFixed() ? `(${pin.x}, ${pin.y})` : `点击删除 (${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>
);
});

View File

@ -7,8 +7,6 @@ customElement("md-pin", { x: 0, y: 0 }, (props, { element }) => {
const [position, setPosition] = createSignal<{ top: string; left: string }>({ top: "0", left: "0" }); const [position, setPosition] = createSignal<{ top: string; left: string }>({ top: "0", left: "0" });
const [visible, setVisible] = createSignal(false); const [visible, setVisible] = createSignal(false);
const [transformStyle, setTransformStyle] = createSignal<string>(""); const [transformStyle, setTransformStyle] = createSignal<string>("");
const [showToast, setShowToast] = createSignal(false);
const [toastMessage, setToastMessage] = createSignal("");
let pinContainer: HTMLSpanElement | undefined; let pinContainer: HTMLSpanElement | undefined;
let targetImage: HTMLImageElement | undefined; let targetImage: HTMLImageElement | undefined;
let resizeObserver: ResizeObserver | undefined; let resizeObserver: ResizeObserver | undefined;
@ -110,24 +108,18 @@ customElement("md-pin", { x: 0, y: 0 }, (props, { element }) => {
> >
<Show when={visible() && targetImage}> <Show when={visible() && targetImage}>
<span <span
class="md-pin absolute transform -translate-x-1/2 -translate-y-1/2 pointer-events-auto cursor-pointer class="md-pin 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
hover:bg-red-600 hover:scale-110 transition-all z-10" z-10"
style={{ style={{
left: position().left, left: position().left,
top: position().top top: position().top
}} }}
title={label || '点击复制坐标'}
> >
{label || '📍'} {label || '📍'}
</span> </span>
</Show> </Show>
<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">
{toastMessage()}
</div>
</Show>
</div> </div>
); );
}); });

10
todo.md
View File

@ -1,6 +1,10 @@
# todo # todo
## md-pin ## md-pin-editor
- [ ] 创建并注册:md-pin组件。 - [ ] 类似md-pin寻找最近一张图片。
- [ ] 对于:md-pin[A]{x=100 y=200} 而言,其将定位上方最近的一张图片,并在指定坐标位置上显示`A`的pin。 - [ ] 在图片上显示透明遮罩,覆盖整个图片。
- [ ] 点击遮罩添加一个pin位置在点击的位置。
- [ ] 再次点击pin会删除pin。
- [ ] 点击遮罩hud的复制按钮可以将所有pin复制为md-pin文本以回车换行连接。
- [ ] 所有pin按照A B C ... Z AA AB ... 的顺序显示标签。