/**
* TackMark — Visual feedback for Hermes Agent
* Click elements, annotate, and let AI fix code.
*/
import { host, haptic } from '@hermes/plugin-sdk'
import { jsx, jsxs } from 'react/jsx-runtime'
import { useState, useEffect, useCallback, useRef } from 'react'
const ID = 'tackmark'
/**
* 规范化文件路径,防御 MSYS/Git Bash 的路径转换。
* MSYS2 运行时会自动把 Unix 风格路径(/d/Projects/...)与 Windows 路径互相改写,
* 导致 host.request('file.*', { path }) 拿到的路径格式错误、文件找不到。
*
* 支持输入:
* '/c/Users/foo/bar' → 'C:\Users\foo\bar' (MSYS 风格)
* 'C:/Users/foo/bar' → 'C:\Users\foo\bar' (正斜杠 Windows 风格)
* 'C:\Users\foo\bar' → 'C:\Users\foo\bar' (原样)
*
* 注意:只用于文件路径,不要用于 URL 或选择器。
*/
export function normalizeFilePath(input) {
if (typeof input !== 'string' || input.length === 0) return input
// MSYS 风格盘符:/c/... → C:\
input = input.replace(/^\/[a-zA-Z]\//, (m) => m.charAt(1).toUpperCase() + ':\\')
// 统一为正斜杠 → 反斜杠
input = input.replace(/\//g, '\\')
return input
}
// URL 白名单:只允许 http/https,拦截 data:/javascript: 等无网络注入路径
export function isAllowedPreviewUrl(url) {
return typeof url === 'string' && /^https?:\/\/.+/.test(url)
}
// HTML 属性值转义:用于 注入,防属性逃逸 XSS
export function escapeHtmlAttr(value) {
return String(value)
.replace(/&/g, '&').replace(//g, '>')
.replace(/"/g, '"').replace(/'/g, ''')
}
// 选择器生成
function generateSelector(element) {
if (!element) return ''
// 1. 有 id 直接用
if (element.id) return `#${element.id}`
// 2. 找独特的类名组合
if (element.classList && element.classList.length > 0) {
const classes = Array.from(element.classList)
const selector = classes.map(c => `.${c}`).join('')
// 验证是否唯一
try {
if (document.querySelectorAll(selector).length === 1) {
return selector
}
} catch (e) {}
}
// 3. 用 nth-child 路径
const path = []
let current = element
while (current && current !== document.body) {
let selector = current.tagName.toLowerCase()
if (current.id) {
selector = `#${current.id}`
path.unshift(selector)
break
}
if (current.classList && current.classList.length > 0) {
selector += Array.from(current.classList).map(c => `.${c}`).join('')
}
// 计算 nth-child
const parent = current.parentElement
if (parent) {
const siblings = Array.from(parent.children)
const index = siblings.indexOf(current) + 1
selector += `:nth-child(${index})`
}
path.unshift(selector)
current = current.parentElement
}
return path.join(' > ')
}
// 标注输入弹窗组件
function AnnotationPopup({ element, onSubmit, onCancel }) {
const [comment, setComment] = useState('')
const inputRef = useRef(null)
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus()
}
}, [])
const handleSubmit = () => {
if (comment.trim()) {
onSubmit(comment)
}
}
// 弹窗定位:跟随鼠标(popupPosition),以 iframe 可视区为边界钳制
// 边界信息在 PreviewPanel 计算坐标时已随 element.viewportBounds 传入
const pos = element.popupPosition || element.position || { x: 0, y: 0 }
const popupWidth = 260
const popupHeight = 160
const bound = element.viewportBounds
|| { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight }
let left = pos.x + 12
let top = pos.y + 16
// 右侧放不下 → 翻转到左侧;左侧也放不下 → 贴左边界
if (left + popupWidth > bound.right - 4) {
left = pos.x - popupWidth - 12
}
if (left < bound.left + 4) left = bound.left + 4
// 底部放不下 → 翻转到上方;上方也放不下 → 贴顶边界
if (top + popupHeight > bound.bottom - 4) {
top = pos.y - popupHeight - 12
}
if (top < bound.top + 4) top = bound.top + 4
return jsxs('div', {
className: 'fixed z-[9999] rounded-lg shadow-xl p-3 min-w-[250px]',
style: {
top: `${top}px`,
left: `${left}px`,
background: '#1e293b',
color: '#ffffff',
border: '1px solid #334155',
},
onClick: (e) => e.stopPropagation(),
children: [
jsx('div', {
className: 'text-xs mb-2 break-all',
style: { color: '#7dd3fc' },
children: `${element.tag} ${element.classes.map(c => `.${c}`).join('')}`
}),
jsx('input', {
ref: inputRef,
type: 'text',
className: 'w-full px-2 py-1.5 border rounded text-sm mb-2 focus:outline-none focus:ring-2 focus:ring-blue-500',
style: {
background: '#0f172a',
color: '#ffffff',
border: '1px solid #475569',
},
placeholder: '输入标注...',
value: comment,
onChange: (e) => setComment(e.target.value),
onKeyDown: (e) => {
if (e.key === 'Enter') handleSubmit()
if (e.key === 'Escape') onCancel()
}
}),
jsxs('div', {
className: 'flex justify-end gap-2',
children: [
jsx('button', {
className: 'px-2 py-1 text-xs',
style: { color: '#cbd5e1' },
onClick: onCancel,
children: '取消'
}),
jsx('button', {
className: 'px-2 py-1 text-xs rounded',
style: { background: '#22c55e', color: '#ffffff' },
onClick: handleSubmit,
children: '添加'
})
]
})
]
})
}
// 预览面板组件
const DEFAULT_URL = 'http://localhost:8080/tackmark-help.html'
function PreviewPanel() {
// URL 持久化:重载插件/重启应用后自动恢复上次预览的页面
// 自愈:本地存储的URL如果失效(旧版test.html等404),自动回退到说明页
const [url, setUrl] = useState(() => {
try {
const saved = localStorage.getItem('tackmark_last_url')
if (saved) return saved
return DEFAULT_URL
} catch {
return DEFAULT_URL
}
})
const [annotations, setAnnotations] = useState([])
const [isAnnotating, setIsAnnotating] = useState(false)
// ref 镜像:message handler 里读最新标注状态(闭包值会过期)
const isAnnotatingRef = useRef(false)
const [selectedElement, setSelectedElement] = useState(null)
const [showPopup, setShowPopup] = useState(false)
const iframeRef = useRef(null)
// 处理 iframe 消息
useEffect(() => {
const handleMessage = (event) => {
// 只接受来自当前预览 iframe 的消息(防其他窗口/页面伪造消息注入)
const iframe = iframeRef.current
if (!iframe || event.source !== iframe.contentWindow) return
if (event.data?.type === 'tackmark-ready') {
// 页面(重)加载完成:iframe 内标注状态已重置为关闭
// 若父端仍显示"标注中",重新同步,避免点击悬停静默失效
if (isAnnotatingRef.current) {
iframe.contentWindow.postMessage({
type: 'tackmark-toggle-annotation',
enabled: true
}, '*')
}
return
}
if (event.data?.type === 'tackmark-element-selected') {
const element = event.data.element
// 结构校验:损坏/伪造的消息会导致面板崩溃(classes.map)或注入任意 selector
if (!element || typeof element !== 'object'
|| !Array.isArray(element.classes)
|| typeof element.tag !== 'string') return
// 坐标系转换:iframe 内坐标 → 父窗口坐标(fixed 定位基准)
const rect = iframe.getBoundingClientRect()
element.popupPosition = {
x: rect.left + (element.mouse?.x ?? element.position.x),
y: rect.top + (element.mouse?.y ?? element.position.y)
}
// 弹窗边界 = iframe 可视区(弹窗不应超出它)
element.viewportBounds = {
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom
}
setSelectedElement(element)
setShowPopup(true)
}
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [])
// 初始加载默认 URL
useEffect(() => {
loadPage(url)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 标注层脚本——注入到预览页面内执行(srcdoc 继承父窗口源,脚本可用)
const ANNOTATION_SCRIPT = `
(function() {
let isAnnotating = false;
let overlay = null;
let tooltip = null;
function createOverlay() {
overlay = document.createElement('div');
overlay.style.cssText = \`
position: fixed;
pointer-events: none;
z-index: 9998;
border: 2px solid #3b82f6;
background: rgba(59, 130, 246, 0.1);
transition: all 0.15s ease;
\`;
document.body.appendChild(overlay);
tooltip = document.createElement('div');
tooltip.style.cssText = \`
position: fixed;
z-index: 9999;
background: #1e293b;
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
pointer-events: none;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
\`;
document.body.appendChild(tooltip);
}
function updateHighlight(element) {
if (!overlay) return;
const rect = element.getBoundingClientRect();
// zoom(设计稿适配缩放)下 fixed 元素的定位与尺寸会被 zoom 再缩放一遍,
// 这里按 1/z 补偿,使 overlay/tooltip 与实际元素视觉位置精确贴合
const z = parseFloat(document.documentElement.style.zoom) || 1;
overlay.style.top = (rect.top / z) + 'px';
overlay.style.left = (rect.left / z) + 'px';
overlay.style.width = (rect.width / z) + 'px';
overlay.style.height = (rect.height / z) + 'px';
const tag = element.tagName.toLowerCase();
const id = element.id ? '#' + element.id : '';
const classes = Array.from(element.classList).map(c => '.' + c).join('');
tooltip.textContent = tag + id + classes;
tooltip.style.top = (rect.top / z - 28 / z) + 'px';
tooltip.style.left = (rect.left / z) + 'px';
}
function generateSelector(element) {
if (element.id) return '#' + element.id;
if (element.classList && element.classList.length > 0) {
const classes = Array.from(element.classList);
const selector = classes.map(c => '.' + c).join('');
try {
if (document.querySelectorAll(selector).length === 1) return selector;
} catch (e) {}
}
const path = [];
let current = element;
while (current && current !== document.body) {
let selector = current.tagName.toLowerCase();
if (current.id) {
selector = '#' + current.id;
path.unshift(selector);
break;
}
if (current.classList && current.classList.length > 0) {
selector += Array.from(current.classList).map(c => '.' + c).join('');
}
const parent = current.parentElement;
if (parent) {
const siblings = Array.from(parent.children);
const index = siblings.indexOf(current) + 1;
selector += ':nth-child(' + index + ')';
}
path.unshift(selector);
current = current.parentElement;
}
return path.join(' > ');
}
function inspectElement(element) {
const styles = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
return {
tag: element.tagName.toLowerCase(),
classes: Array.from(element.classList || []),
text: element.textContent?.trim().substring(0, 100) || '',
id: element.id || null,
styles: {
display: styles.display,
position: styles.position,
width: styles.width,
height: styles.height,
backgroundColor: styles.backgroundColor,
color: styles.color,
fontSize: styles.fontSize,
fontFamily: styles.fontFamily,
padding: styles.padding,
margin: styles.margin,
borderRadius: styles.borderRadius,
},
position: {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
}
};
}
function handleMouseMove(e) {
if (!isAnnotating) return;
e.preventDefault();
e.stopPropagation();
updateHighlight(e.target);
}
function handleClick(e) {
if (!isAnnotating) return;
e.preventDefault();
e.stopPropagation();
const element = e.target;
const selector = generateSelector(element);
const info = inspectElement(element);
window.parent.postMessage({
type: 'tackmark-element-selected',
element: {
selector,
...info,
mouse: { x: e.clientX, y: e.clientY }
}
}, '*');
}
window.addEventListener('message', (event) => {
if (event.data?.type === 'tackmark-toggle-annotation') {
isAnnotating = event.data.enabled;
if (isAnnotating && !overlay) {
createOverlay();
}
if (!isAnnotating && overlay) {
overlay.style.display = 'none';
tooltip.style.display = 'none';
} else if (isAnnotating && overlay) {
overlay.style.display = 'block';
tooltip.style.display = 'block';
}
}
});
// ---- 设计稿适配:固定尺寸页面(如1080x1920封面)在预览区缩放完整显示 ----
// 测量内容实际尺寸,按视口计算 zoom=min(宽比,高比),仅缩小不放大
let fitW = 0, fitH = 0;
function fitPreview() {
const docEl = document.documentElement;
if (!fitW || !fitH) {
fitW = docEl.scrollWidth;
fitH = docEl.scrollHeight;
}
if (!fitW || !fitH) return;
const scale = Math.min(window.innerWidth / fitW, window.innerHeight / fitH);
const zoom = Math.min(scale, 1);
docEl.style.zoom = zoom < 1 ? String(zoom) : '';
// 缩放后内容不超过视口,隐藏可能的舍入滚动条
document.body.style.overflowX = 'hidden';
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fitPreview);
} else {
fitPreview();
}
window.addEventListener('load', fitPreview);
window.addEventListener('resize', fitPreview);
document.addEventListener('mousemove', handleMouseMove, true);
document.addEventListener('click', handleClick, true);
window.parent.postMessage({ type: 'tackmark-ready' }, '*');
})();
`
// 加载页面:fetch HTML → 注入标注脚本 → srcdoc(跨源 eval 被禁,srcdoc 继承父源可执行)
const loadPage = useCallback(async (targetUrl) => {
const iframe = iframeRef.current
if (!iframe) return
// URL 校验:只允许 http/https,拦截 data:/javascript: 等无网络注入路径
if (!isAllowedPreviewUrl(targetUrl)) {
host.notify({ kind: 'error', message: '仅支持 http/https 地址' })
return
}
try {
// 缓存破坏:带上时间戳参数,确保改文件后刷新立即生效
const cacheBust = targetUrl.includes('?') ? `&_t=${Date.now()}` : `?_t=${Date.now()}`
const res = await fetch(targetUrl + cacheBust)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
let html = await res.text()
// baseUrl 必须 HTML 属性转义,否则用户 URL 可逃逸 注入脚本(同源 XSS)
const baseUrl = escapeHtmlAttr(targetUrl.replace(/[^/]*$/, ''))
const baseTag = ``
const scriptTag = `