效果
操作示例
甩锅声明
AI 生成,与我无瓜,BUG 自测,仅供娱乐。
使用教程
将下方的 js 和 css 粘贴对应的位置。
点击图片右上角按钮切换开启/关闭标注功能。
开启时可以显示已有标注和新增标注。鼠标悬浮标注框显示文字。标注文字和位置均可二次修改。
关闭则不显示标注文字,右上角显示已有标注数量(鼠标悬浮显示)。
标注框跟随图片缩放。
标注信息写在块的属性-备注中,所以支持搜索。
JS:
// == 图片标注功能 for 思源笔记 ==
// 依赖:思源API,图片需有唯一块ID(blockId),可通过自定义属性或DOM获取
// 日志控制系统
window.__commentLogger = {
// 日志级别: 0=关闭所有, 1=仅错误, 2=错误+警告, 3=全部信息
level: 2,
error: function(message, ...args) {
if (this.level >= 1) console.error('[标注] ' + message, ...args);
},
warn: function(message, ...args) {
if (this.level >= 2) console.warn('[标注] ' + message, ...args);
},
info: function(message, ...args) {
if (this.level >= 3) console.log('[标注] ' + message, ...args);
},
// 切换日志级别
setLevel: function(level) {
this.level = level;
this.info(`日志级别已设置为 ${level}`);
}
};
// 标注模式全局变量
window.__commentMode = false;
// 图片处理计数器,避免过多并发
let pendingImagesCount = 0;
const MAX_CONCURRENT_IMAGES = 5;
const imageQueue = [];
function processImageQueue() {
// 如果队列为空,直接返回
if (imageQueue.length === 0) return;
// 如果当前处理的图片数量未达到上限,处理队列中的图片
while (pendingImagesCount < MAX_CONCURRENT_IMAGES && imageQueue.length > 0) {
const {img, blockId, callback} = imageQueue.shift();
pendingImagesCount++;
// 处理完成后减少计数并检查队列
const onComplete = () => {
pendingImagesCount--;
setTimeout(processImageQueue, 0);
};
// 执行回调,传入完成处理函数
callback(img, blockId, onComplete);
}
}
function initCommentButtons() {
window.__commentLogger.info('开始查找图片...');
// 清空队列,重新开始
imageQueue.length = 0;
const imgs = document.querySelectorAll('span.img[data-type="img"] img');
window.__commentLogger.info(`找到 ${imgs.length} 张图片`);
// 将图片添加到队列
imgs.forEach((img, idx) => {
const blockDiv = img.closest('[data-node-id]');
const blockId = blockDiv?.getAttribute('data-node-id');
if (blockId) {
// 先添加按钮,这个操作很快
addCommentButton(img, blockId);
// 将加载数据和初始化评论模式的操作放到队列中
imageQueue.push({
img,
blockId,
callback: (img, blockId, onComplete) => {
// 自动检测 commentMode
getBlockAttrs(blockId).then(attrs => {
let commentMode = false;
let commentCount = 0;
if (attrs.memo) {
try {
const parsed = JSON.parse(attrs.memo);
if (Array.isArray(parsed)) {
commentMode = false;
commentCount = parsed.length;
} else if (typeof parsed === 'object') {
commentMode = !!parsed.commentMode;
commentCount = Array.isArray(parsed.comments) ? parsed.comments.length : 0;
}
} catch (e) {
console.warn('[标注] 解析memo数据失败', e);
}
}
// 更新标注数量显示
updateCommentCount(img, commentCount);
if (commentMode) {
openCommentMode(img, blockId);
}
onComplete();
}).catch(err => {
console.error('[标注] 获取块属性失败', err);
onComplete();
});
}
});
} else {
window.__commentLogger.warn('未找到图片的块ID', img);
}
});
// 开始处理队列
processImageQueue();
}
// 更新标注数量的函数
function updateCommentCount(img, count) {
const imgSpan = img.closest('span.img[data-type="img"]');
if (!imgSpan) return;
const btn = imgSpan.querySelector('.comment-btn');
if (!btn) return;
const countBadge = btn.querySelector('.comment-count');
if (countBadge) {
if (count > 0) {
countBadge.textContent = count;
countBadge.style.display = '';
} else {
countBadge.style.display = 'none';
}
}
}
function debounce(fn, delay) {
let timer = null;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
const debouncedInitCommentButtons = debounce(initCommentButtons, 300);
// 页面加载后执行一次,initCommentButtons会处理所有图片
window.addEventListener('load', initCommentButtons);
// 只为新出现且未添加按钮的图片添加标注按钮
function addCommentButtonsForNewImages(root = document.body) {
const imgs = root.querySelectorAll('span.img[data-type="img"] img');
let newImagesFound = 0;
imgs.forEach((img) => {
if (!img.parentElement.querySelector('.comment-btn')) {
const blockDiv = img.closest('[data-node-id]');
const blockId = blockDiv?.getAttribute('data-node-id');
if (blockId) {
// 先添加按钮
addCommentButton(img, blockId);
newImagesFound++;
// 将加载数据和初始化评论模式的操作放到队列中
imageQueue.push({
img,
blockId,
callback: (img, blockId, onComplete) => {
// 自动检测 commentMode
getBlockAttrs(blockId).then(attrs => {
let commentMode = false;
let commentCount = 0;
if (attrs.memo) {
try {
const parsed = JSON.parse(attrs.memo);
if (Array.isArray(parsed)) {
commentMode = false;
commentCount = parsed.length;
} else if (typeof parsed === 'object') {
commentMode = !!parsed.commentMode;
commentCount = Array.isArray(parsed.comments) ? parsed.comments.length : 0;
}
} catch (e) {
window.__commentLogger.warn('解析memo数据失败', e);
}
}
// 更新标注数量显示
updateCommentCount(img, commentCount);
if (commentMode) {
setTimeout(() => openCommentMode(img, blockId), 0);
}
onComplete();
}).catch(err => {
window.__commentLogger.error('获取块属性失败', err);
onComplete();
});
}
});
} else {
console.warn('[标注] 新图片未找到块ID', img);
}
}
});
if (newImagesFound > 0) {
window.__commentLogger.info(`发现 ${newImagesFound} 张新图片,已添加到处理队列`);
processImageQueue();
}
}
// 移除重复的load事件监听,避免页面加载时重复处理
// 性能优化:使用节流函数减少DOM变化处理频率
function throttle(func, limit) {
let inThrottle;
let lastFunc;
let lastRan;
return function() {
const context = this;
const args = arguments;
if (!inThrottle) {
func.apply(context, args);
lastRan = Date.now();
inThrottle = true;
} else {
clearTimeout(lastFunc);
lastFunc = setTimeout(function() {
if (Date.now() - lastRan >= limit) {
func.apply(context, args);
lastRan = Date.now();
}
}, limit - (Date.now() - lastRan));
}
};
}
// 收集变更,批量处理
let addedNodes = new Set();
const processAddedNodes = throttle(() => {
if (addedNodes.size === 0) return;
window.__commentLogger.info(`批量处理 ${addedNodes.size} 个新增节点`);
// 将Set转换为数组并清空
const nodesToProcess = Array.from(addedNodes);
addedNodes.clear();
// 检查节点是否仍在DOM中
const validNodes = nodesToProcess.filter(node => {
return document.body.contains(node);
});
// 分批处理有效节点
for (const node of validNodes) {
if (node.nodeType === 1) { // 元素节点
// 直接是图片
if (node.matches && node.matches('span.img[data-type="img"] img')) {
addCommentButtonsForNewImages(node.parentElement);
} else {
// 或其子孙有图片
addCommentButtonsForNewImages(node);
}
}
}
}, 300);
// 监听 DOM 变化,只处理新增节点
// 避免重复创建观察器
if (window.commentObserver) {
window.commentObserver.disconnect();
}
window.commentObserver = new MutationObserver((mutations) => {
let hasNewNodes = false;
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeType === 1) { // 元素节点
addedNodes.add(node);
hasNewNodes = true;
}
});
});
if (hasNewNodes) {
processAddedNodes();
}
});
// 使用更精确的观察选择器,减少不必要的触发
window.commentObserver.observe(document.body, {
childList: true,
subtree: true,
// 仅在属性变化时触发回调
attributeFilter: ['data-node-id', 'class', 'src']
});
function addCommentButton(img, blockId) {
const imgSpan = img.closest('span.img[data-type="img"]');
if (!imgSpan) return;
if (imgSpan.querySelector('.comment-btn')) {
console.log('[标注] 已存在标注按钮,跳过', img);
return;
}
const btn = document.createElement('span');
btn.className = 'protyle-action protyle-icons comment-btn';
btn.style.display = 'none';
// 创建标注图标和数量显示
btn.innerHTML = `
<span class="protyle-icon protyle-icon--only" title="标注">
<svg class="svg"><use xlink:href="#iconQuote"></use></svg>
<span class="comment-count" style="display:none;">0</span>
</span>
`;
const icon = btn.querySelector('.protyle-icon--only');
icon.onclick = (e) => {
e.stopPropagation();
window.__commentMode = !window.__commentMode;
getBlockAttrs(blockId).then(attrs => {
let memoObj = { commentMode: window.__commentMode, comments: [] };
if (attrs.memo) {
try {
const parsed = JSON.parse(attrs.memo);
if (Array.isArray(parsed)) {
memoObj.comments = parsed;
} else if (typeof parsed === 'object') {
memoObj = { ...parsed, commentMode: window.__commentMode };
if (!Array.isArray(memoObj.comments)) memoObj.comments = [];
}
} catch {}
}
setBlockMemo(blockId, JSON.stringify(memoObj)).then(() => {
if (window.__commentMode) {
console.log('[标注] 标注模式开启', blockId);
openCommentMode(img, blockId);
btn.classList.add('comment-btn--active');
} else {
console.log('[标注] 标注模式关闭', blockId);
img.parentElement.querySelectorAll('.comment-rect').forEach(e => e.remove());
img.parentElement.onmousedown = null;
img.onmousedown = null;
btn.classList.remove('comment-btn--active');
}
});
});
};
const moreBtn = imgSpan.querySelector('.protyle-action.protyle-icons');
if (moreBtn) {
moreBtn.parentNode.insertBefore(btn, moreBtn);
} else {
imgSpan.insertBefore(btn, imgSpan.firstChild);
}
imgSpan.addEventListener('mouseenter', () => {
btn.style.display = '';
});
imgSpan.addEventListener('mouseleave', () => {
btn.style.display = 'none';
});
// 挂载 ResizeObserver,图片缩放时自动刷新标注框
if (!img.__commentResizeObserver) {
// 使用WeakMap存储数据,避免直接在DOM元素上存储大量数据
if (!window.__commentResizeData) {
window.__commentResizeData = new WeakMap();
}
// 存储上一次获取的评论数据
const resizeData = {
comments: [],
commentMode: false,
loaded: false,
width: img.width,
height: img.height,
lastUpdated: Date.now()
};
window.__commentResizeData.set(img, resizeData);
// 检测图片尺寸变化的幅度,确定是否需要重新加载数据
function shouldReloadData(img) {
const data = window.__commentResizeData.get(img);
if (!data) return true;
const widthChange = Math.abs(data.width - img.width) / data.width;
const heightChange = Math.abs(data.height - img.height) / data.height;
// 如果尺寸变化超过5%,或者距离上次更新已经超过30秒,重新加载数据
return widthChange > 0.05 ||
heightChange > 0.05 ||
Date.now() - data.lastUpdated > 30000;
}
// 1. 创建即时调整函数
const updateImmediate = () => {
const data = window.__commentResizeData.get(img);
if (!data || !data.loaded || !data.commentMode) return;
// 只对标注框位置进行即时调整,不重新加载数据
const comments = data.comments || [];
// 更新所有现有标注框的尺寸
const existingRects = img.parentElement.querySelectorAll('.comment-rect');
existingRects.forEach((rect, idx) => {
if (idx < comments.length) {
const comment = comments[idx];
rect.style.left = `${comment.x * img.width}px`;
rect.style.top = `${comment.y * img.height}px`;
rect.style.width = `${comment.width * img.width}px`;
rect.style.height = `${comment.height * img.height}px`;
// 同时更新内容框位置
const contentEl = img.parentElement.querySelector(`.comment-content[data-index="${idx+1}"]`);
if (contentEl) {
contentEl.style.left = `${comment.x * img.width + (comment.width * img.width) / 2}px`;
// 使用requestAnimationFrame确保我们可以获取contentEl的高度,并减少reflow
requestAnimationFrame(() => {
const contentHeight = contentEl.offsetHeight;
contentEl.style.top = `${comment.y * img.height - contentHeight + 1}px`;
});
}
}
});
};
// 2. 创建防抖的完整数据加载函数
const updateFullData = debounce(() => {
// 只有在尺寸变化幅度大或长时间未更新时才重新加载数据
if (!shouldReloadData(img)) {
// 仅执行即时调整
updateImmediate();
return;
}
// 如果当前是标注模式,刷新标注框
const blockDiv = img.closest('[data-node-id]');
const blockId = blockDiv?.getAttribute('data-node-id');
if (!blockId) return;
getBlockAttrs(blockId).then(attrs => {
let commentMode = false;
let comments = [];
if (attrs.memo) {
try {
const parsed = JSON.parse(attrs.memo);
if (Array.isArray(parsed)) {
comments = parsed;
commentMode = false;
} else if (typeof parsed === 'object') {
comments = Array.isArray(parsed.comments) ? parsed.comments : [];
commentMode = !!parsed.commentMode;
}
} catch (e) {
console.error('[标注] 解析memo数据失败', e);
}
}
// 更新缓存
const data = window.__commentResizeData.get(img) || {};
data.comments = comments;
data.commentMode = commentMode;
data.loaded = true;
data.width = img.width;
data.height = img.height;
data.lastUpdated = Date.now();
window.__commentResizeData.set(img, data);
if (commentMode) {
// 避免重复初始化:在执行前先移除旧标注框
img.parentElement.querySelectorAll('.comment-rect').forEach(e => e.remove());
img.parentElement.querySelectorAll('.comment-content').forEach(e => e.remove());
// 使用requestAnimationFrame优化性能
requestAnimationFrame(() => {
openCommentMode(img, blockId);
});
} else {
// 非标注模式时移除所有标注框
img.parentElement.querySelectorAll('.comment-rect').forEach(e => e.remove());
img.parentElement.querySelectorAll('.comment-content').forEach(e => e.remove());
}
}).catch(err => {
window.__commentLogger.error('获取数据失败', err);
});
}, 500);
// 使用单个ResizeObserver,根据需要决定调用哪个更新函数
img.__commentResizeObserver = new ResizeObserver(() => {
// 总是执行即时调整
updateImmediate();
// 然后考虑是否需要完整更新
updateFullData();
});
// 监听图片大小变化
img.__commentResizeObserver.observe(img);
}
window.__commentLogger.info('标注按钮已添加到图片上', img);
}
// 使用防抖避免同一图片短时间内多次渲染
const debouncedOpenCommentMode = new Map();
async function openCommentMode(img, blockId) {
window.__commentLogger.info(`开启标注模式,blockId: ${blockId}`);
// 如果这个图片+块ID组合没有专门的防抖函数,创建一个
const key = blockId + '_' + img.src;
if (!debouncedOpenCommentMode.has(key)) {
debouncedOpenCommentMode.set(key, debounce(async (_img, _blockId) => {
// 先清除已有标注框,避免重复渲染
_img.parentElement.querySelectorAll('.comment-rect').forEach(e => e.remove());
// 同时清除已有的标注内容元素
_img.parentElement.querySelectorAll('.comment-content').forEach(e => e.remove());
const attrs = await getBlockAttrs(_blockId);
let comments = [];
let commentMode = false;
if (attrs.memo) {
try {
const parsed = JSON.parse(attrs.memo);
if (Array.isArray(parsed)) {
comments = parsed;
commentMode = false;
} else if (typeof parsed === 'object') {
comments = Array.isArray(parsed.comments) ? parsed.comments : [];
commentMode = !!parsed.commentMode;
}
} catch (e) {
window.__commentLogger.error('标注数据解析失败', e, attrs.memo);
}
}
// 更新标注数量显示
updateCommentCount(_img, comments.length);
// 只负责渲染,不再设置 window.__commentMode
const imgSpan = _img.closest('span.img[data-type="img"]');
if (imgSpan) {
const btn = imgSpan.querySelector('.comment-btn');
if (btn) {
if (commentMode) btn.classList.add('comment-btn--active');
else btn.classList.remove('comment-btn--active');
}
}
if (!commentMode) return; // 只在开启时渲染
// 再次清理,防止异步重复
_img.parentElement.querySelectorAll('.comment-rect').forEach(e => e.remove());
_img.parentElement.querySelectorAll('.comment-content').forEach(e => e.remove());
comments.forEach((c, idx) => renderCommentRect(_img, c, idx + 1, _blockId, comments));
enableDrawRect(_img, _blockId, comments);
}, 100));
}
// 调用防抖函数
debouncedOpenCommentMode.get(key)(img, blockId);
}
// 存储活跃的输入框引用,方便管理
let activeInputPopup = null;
function showCommentInput(x, y, onConfirm, onCancel, initialContent = '') {
// 安全处理输入内容,防止XSS攻击
const safeInitialContent = (initialContent || '').replace(/</g, '<').replace(/>/g, '>');
// 关闭已有输入框
if (activeInputPopup) {
activeInputPopup.remove();
activeInputPopup = null;
}
document.querySelectorAll('.comment-input-popup').forEach(e => e.remove());
// 创建弹窗结构
const popup = document.createElement('div');
popup.className = 'comment-input-popup';
popup.style.left = x + 'px';
popup.style.top = y + 'px';
activeInputPopup = popup;
// 使用文档片段减少DOM操作
const fragment = document.createDocumentFragment();
// 添加标题
const header = document.createElement('div');
header.className = 'comment-input-header';
const title = document.createElement('div');
title.className = 'comment-input-title';
title.textContent = '编辑标注内容';
const closeBtn = document.createElement('div');
closeBtn.className = 'comment-input-close';
closeBtn.innerHTML = '×';
closeBtn.onclick = (e) => {
e.stopPropagation();
closePopup(false);
};
header.appendChild(title);
header.appendChild(closeBtn);
fragment.appendChild(header);
// 文本区域
const textareaContainer = document.createElement('div');
textareaContainer.className = 'comment-input-textarea-container';
const textarea = document.createElement('textarea');
textarea.value = safeInitialContent;
textarea.placeholder = '请输入标注内容...';
textarea.maxLength = 1000; // 限制输入长度,防止恶意输入
textareaContainer.appendChild(textarea);
fragment.appendChild(textareaContainer);
// 按钮区域
const buttonContainer = document.createElement('div');
buttonContainer.className = 'comment-input-buttons';
const cancelButton = document.createElement('button');
cancelButton.className = 'comment-input-cancel';
cancelButton.type = 'button'; // 明确指定按钮类型
cancelButton.textContent = '取消';
cancelButton.onclick = (e) => {
e.stopPropagation();
closePopup(false);
};
const confirmButton = document.createElement('button');
confirmButton.className = 'comment-input-ok';
confirmButton.type = 'button';
confirmButton.textContent = '确定';
confirmButton.onclick = (e) => {
e.stopPropagation();
submitContent();
};
buttonContainer.appendChild(cancelButton);
buttonContainer.appendChild(confirmButton);
fragment.appendChild(buttonContainer);
// 一次性添加所有元素
popup.appendChild(fragment);
document.body.appendChild(popup);
// 调整位置,避免超出可视区域
requestAnimationFrame(() => {
const rect = popup.getBoundingClientRect();
if (rect.right > window.innerWidth) {
popup.style.left = (window.innerWidth - rect.width / 2 - 20) + 'px';
}
if (rect.bottom > window.innerHeight) {
popup.style.top = (window.innerHeight - rect.height - 20) + 'px';
}
if (rect.left < 0) {
popup.style.left = (rect.width / 2 + 20) + 'px';
}
if (rect.top < 0) {
popup.style.top = '20px';
}
textarea.focus();
// 将光标定位到文本末尾
textarea.setSelectionRange(safeInitialContent.length, safeInitialContent.length);
});
// 提交内容函数
function submitContent() {
const val = textarea.value.trim();
if (val) {
// 限制内容长度
const limitedVal = val.substring(0, 1000);
onConfirm(limitedVal);
closePopup(true);
} else {
textarea.focus();
// 提示不能为空
textarea.classList.add('error');
setTimeout(() => {
textarea.classList.remove('error');
}, 1000);
}
}
// 关闭弹窗函数
function closePopup(isConfirmed) {
if (!isConfirmed && onCancel) {
onCancel();
}
popup.remove();
activeInputPopup = null;
// 移除事件监听
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('mousedown', handleOutsideClick);
}
// 键盘事件处理函数
function handleKeyDown(e) {
// Ctrl+Enter 确认
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
submitContent();
}
// Escape 取消
if (e.key === 'Escape') {
e.preventDefault();
closePopup(false);
}
}
// 点击外部关闭弹窗
function handleOutsideClick(e) {
if (popup && !popup.contains(e.target)) {
closePopup(false);
}
}
// 添加事件监听
textarea.addEventListener('keydown', handleKeyDown);
// 延迟添加点击外部关闭的事件,避免创建弹窗的点击立即触发关闭
setTimeout(() => {
document.addEventListener('mousedown', handleOutsideClick);
}, 100);
return popup;
}
function enableDrawRect(img, blockId, comments) {
let isDrawing = false, startX, startY, rectEl;
const container = img.parentElement;
container.onmousedown = null;
img.onmousedown = null;
container.onmousedown = e => {
if (e.target !== img) return;
isDrawing = true;
const rect = img.getBoundingClientRect();
startX = e.clientX - rect.left;
startY = e.clientY - rect.top;
rectEl = document.createElement('div');
rectEl.className = 'comment-rect';
rectEl.style.left = `${startX}px`;
rectEl.style.top = `${startY}px`;
rectEl.style.width = '0px';
rectEl.style.height = '0px';
container.appendChild(rectEl);
window.__commentLogger.info('开始框选', { startX, startY });
function onMouseMove(ev) {
if (!isDrawing) return;
const curX = ev.clientX - rect.left;
const curY = ev.clientY - rect.top;
rectEl.style.width = `${Math.abs(curX - startX)}px`;
rectEl.style.height = `${Math.abs(curY - startY)}px`;
rectEl.style.left = `${Math.min(curX, startX)}px`;
rectEl.style.top = `${Math.min(curY, startY)}px`;
}
function onMouseUp(ev) {
if (!isDrawing) return;
isDrawing = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
const width = Math.abs(ev.clientX - rect.left - startX);
const height = Math.abs(ev.clientY - rect.top - startY);
if (width > 5 && height > 5) {
// 计算弹窗位置:框选区域上方居中
const boxLeft = parseInt(rectEl.style.left);
const boxTop = parseInt(rectEl.style.top);
const boxWidth = parseInt(rectEl.style.width);
const inputX = rect.left + boxLeft + boxWidth / 2;
const inputY = rect.top + boxTop - 10; // 上方10px
showCommentInput(inputX, inputY, (content) => {
const percentX = boxLeft / img.width;
const percentY = boxTop / img.height;
const percentW = boxWidth / img.width;
const percentH = height / img.height;
const comment = {
id: genId(),
x: percentX,
y: percentY,
width: percentW,
height: percentH,
annotation: content,
lastModified: Date.now()
};
comments.push(comment);
window.__commentLogger.info('新增标注', comment);
setBlockMemo(blockId, JSON.stringify({ commentMode: true, comments })).then(() => {
renderCommentRect(img, comment, comments.length, blockId, comments);
// 更新标注数量显示
updateCommentCount(img, comments.length);
});
rectEl.remove(); // 输入完成后移除高亮框
}, () => {
window.__commentLogger.info('取消标注输入');
rectEl.remove(); // 取消时也移除高亮框
});
// 不立即移除 rectEl,高亮保留到输入完成
} else {
window.__commentLogger.info('单击不添加标注');
rectEl.remove();
}
}
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
};
}
function renderCommentRect(img, comment, index, blockId, comments) {
const rect = document.createElement('div');
rect.className = 'comment-rect';
rect.dataset.index = index; // 添加索引标记,方便后续查找
rect.style.left = `${comment.x * img.width}px`;
rect.style.top = `${comment.y * img.height}px`;
rect.style.width = `${comment.width * img.width}px`;
rect.style.height = `${comment.height * img.height}px`;
// 拖动功能
let dragOffsetX = 0, dragOffsetY = 0, isDragging = false;
rect.addEventListener('mousedown', (e) => {
if (e.button !== 0 || e.target.classList.contains('resize-handle')) return;
isDragging = true;
dragOffsetX = e.clientX - rect.offsetLeft;
dragOffsetY = e.clientY - rect.offsetTop;
document.body.style.cursor = 'move';
function onMove(ev) {
if (!isDragging) return;
let newLeft = ev.clientX - dragOffsetX;
let newTop = ev.clientY - dragOffsetY;
newLeft = Math.max(0, Math.min(newLeft, img.width - rect.offsetWidth));
newTop = Math.max(0, Math.min(newTop, img.height - rect.offsetHeight));
rect.style.left = `${newLeft}px`;
rect.style.top = `${newTop}px`;
// 即时更新比例,便于快速响应
comment.x = newLeft / img.width;
comment.y = newTop / img.height;
// 更新内容框位置
updateContentPosition();
}
function onUp(ev) {
if (!isDragging) return;
isDragging = false;
document.body.style.cursor = '';
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
const percentX = parseInt(rect.style.left) / img.width;
const percentY = parseInt(rect.style.top) / img.height;
comment.x = percentX;
comment.y = percentY;
setBlockMemo(blockId, JSON.stringify({ commentMode: true, comments }));
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
// 只保留右下角缩放锚点
const handle = document.createElement('div');
handle.className = 'resize-handle';
handle.addEventListener('mousedown', (e) => {
e.stopPropagation();
e.preventDefault();
let isResizing = true;
const startX = e.clientX;
const startY = e.clientY;
const startLeft = rect.offsetLeft;
const startTop = rect.offsetTop;
const startWidth = rect.offsetWidth;
const startHeight = rect.offsetHeight;
function onResize(ev) {
if (!isResizing) return;
let dx = ev.clientX - startX;
let dy = ev.clientY - startY;
let newWidth = startWidth + dx;
let newHeight = startHeight + dy;
newWidth = Math.max(20, Math.min(newWidth, img.width - startLeft));
newHeight = Math.max(20, Math.min(newHeight, img.height - startTop));
rect.style.width = `${newWidth}px`;
rect.style.height = `${newHeight}px`;
// 更新比例
comment.width = newWidth / img.width;
comment.height = newHeight / img.height;
// 更新content位置
updateContentPosition();
}
function onResizeUp(ev) {
isResizing = false;
document.removeEventListener('mousemove', onResize);
document.removeEventListener('mouseup', onResizeUp);
const percentX = parseInt(rect.style.left) / img.width;
const percentY = parseInt(rect.style.top) / img.height;
const percentW = parseInt(rect.style.width) / img.width;
const percentH = parseInt(rect.style.height) / img.height;
comment.x = percentX;
comment.y = percentY;
comment.width = percentW;
comment.height = percentH;
setBlockMemo(blockId, JSON.stringify({ commentMode: true, comments }));
}
document.addEventListener('mousemove', onResize);
document.addEventListener('mouseup', onResizeUp);
});
rect.appendChild(handle);
// 序号
const idx = document.createElement('div');
idx.className = 'comment-index';
idx.innerText = index;
rect.appendChild(idx);
// 删除按钮
const close = document.createElement('div');
close.className = 'comment-close';
close.innerText = '✕';
close.style.display = 'none';
close.onclick = async () => {
rect.remove();
const idx = comments.findIndex(c => c.id === comment.id);
if (idx > -1) comments.splice(idx, 1);
await setBlockMemo(blockId, JSON.stringify({ commentMode: true, comments }));
// 更新标注数量显示
updateCommentCount(img, comments.length);
openCommentMode(img, blockId);
};
rect.appendChild(close);
// 标注内容 - 添加到img.parentElement而非rect
const content = document.createElement('div');
content.className = 'comment-content';
content.dataset.index = index; // 添加索引标记,与框对应
content.innerText = comment.annotation;
content.style.top = `${parseInt(rect.style.top) - 10}px`; // 初始位置,稍后会更新
content.style.left = `${parseInt(rect.style.left) + parseInt(rect.style.width) / 2}px`;
content.style.display = 'none';
// 添加点击修改标注内容功能
content.addEventListener('click', (e) => {
e.stopPropagation();
// 计算弹窗位置
const rect = content.getBoundingClientRect();
const inputX = rect.left + rect.width / 2;
const inputY = rect.top - 10;
// 将原有内容预填充到输入框中
const currentContent = comment.annotation;
showCommentInput(inputX, inputY, (newContent) => {
comment.annotation = newContent;
content.innerText = newContent;
comment.lastModified = Date.now();
setBlockMemo(blockId, JSON.stringify({ commentMode: true, comments }));
}, () => {
window.__commentLogger.info('取消修改标注内容');
}, currentContent); // 传递当前内容到showCommentInput
});
img.parentElement.appendChild(content);
// 定义更新内容位置的函数
function updateContentPosition() {
const rectTop = parseInt(rect.style.top);
const rectHeight = parseInt(rect.style.height);
const rectLeft = parseInt(rect.style.left);
const rectWidth = parseInt(rect.style.width);
// 确保位置设置正确
if (!content.style.left || !content.style.top) {
content.style.top = `${parseInt(rect.style.top) - 10}px`;
content.style.left = `${parseInt(rect.style.left) + parseInt(rect.style.width) / 2}px`;
}
// 延迟执行,确保能正确计算content高度
setTimeout(() => {
// 强制重新计算高度
const contentHeight = content.offsetHeight;
// 垂直居中于标注框,使下边缘与标注框上边缘重叠1像素
content.style.top = `${rectTop - contentHeight + 1}px`;
content.style.left = `${rectLeft + rectWidth / 2}px`;
// 防止超出图片边界
const imgRect = img.getBoundingClientRect();
const contentRect = content.getBoundingClientRect();
if (contentRect.right > imgRect.right) {
const overflow = contentRect.right - imgRect.right + 5; // 加5px边距
content.style.left = `${parseInt(content.style.left) - overflow}px`;
}
if (contentRect.left < imgRect.left) {
const overflow = imgRect.left - contentRect.left + 5; // 加5px边距
content.style.left = `${parseInt(content.style.left) + overflow}px`;
}
// 处理顶部溢出
if (contentRect.top < imgRect.top) {
// 如果顶部溢出,改为显示在标注框下方
content.style.top = `${rectTop + rectHeight}px`;
}
}, 0);
}
// 首次渲染时更新位置
updateContentPosition();
// 使用WeakMap存储悬浮状态,避免闭包导致的内存泄漏
if (!window.__commentHoverStates) {
window.__commentHoverStates = new WeakMap();
}
// 为当前rect/content对存储悬停状态
const hoverState = { isHovering: false, hoverTimeout: null };
window.__commentHoverStates.set(rect, hoverState);
window.__commentHoverStates.set(content, hoverState);
function showElements() {
// 清除任何挂起的隐藏计时器
if (hoverState.hoverTimeout) {
clearTimeout(hoverState.hoverTimeout);
hoverState.hoverTimeout = null;
}
hoverState.isHovering = true;
content.style.display = '';
close.style.display = '';
// 显示后可能需要重新调整位置
updateContentPosition();
}
function scheduleHide() {
// 设置一个延时来隐藏元素,但保存计时器ID以便可以取消
hoverState.isHovering = false;
// 清除旧计时器
if (hoverState.hoverTimeout) {
clearTimeout(hoverState.hoverTimeout);
}
hoverState.hoverTimeout = setTimeout(() => {
if (!hoverState.isHovering) {
content.style.display = 'none';
close.style.display = 'none';
hoverState.hoverTimeout = null;
}
}, 100);
}
// 悬浮在标注框或文本框时显示内容和关闭按钮
rect.addEventListener('mouseenter', showElements);
content.addEventListener('mouseenter', showElements);
rect.addEventListener('mouseleave', (e) => {
// 检查是否移动到content元素上
const toElement = e.relatedTarget;
if (toElement !== content && !content.contains(toElement)) {
scheduleHide();
}
});
content.addEventListener('mouseleave', (e) => {
// 检查是否移动到rect元素上
const toElement = e.relatedTarget;
if (toElement !== rect && !rect.contains(toElement)) {
scheduleHide();
}
});
img.parentElement.appendChild(rect);
// 移除事件时也需要移除content元素
const originalOnclick = close.onclick;
close.onclick = async () => {
content.remove(); // 移除内容框
// 保留原有的删除行为
if (originalOnclick) await originalOnclick();
};
window.__commentLogger.info('渲染标注框', comment);
}
// 工具函数
// 生成更安全的唯一ID
function genId() {
// 组合时间戳和随机数,提高唯一性
const timestamp = Date.now().toString(36);
const randomPart = Math.random().toString(36).substring(2, 10);
return `${timestamp}-${randomPart}`.toUpperCase();
}
// API
// 简单的内存缓存,减少重复API请求
const blockAttrsCache = new Map();
const cacheTTL = 30000; // 缓存30秒
async function getBlockAttrs(blockId) {
if (!blockId) return {};
// 检查缓存
const now = Date.now();
const cacheItem = blockAttrsCache.get(blockId);
if (cacheItem && now - cacheItem.timestamp < cacheTTL) {
return cacheItem.data;
}
try {
window.__commentLogger.info('获取块属性', blockId);
const res = await fetch('/api/attr/getBlockAttrs', {
method: 'POST',
body: JSON.stringify({ id: blockId }),
headers: { 'Content-Type': 'application/json' },
// 添加超时
signal: AbortSignal.timeout(5000)
});
if (!res.ok) {
window.__commentLogger.error('获取块属性失败', res.status, res.statusText);
return {};
}
const json = await res.json();
window.__commentLogger.info('块属性返回', json);
// 更新缓存
if (json.data) {
blockAttrsCache.set(blockId, {
data: json.data,
timestamp: now
});
}
return json.data || {};
} catch (err) {
window.__commentLogger.error('获取块属性异常', err);
return {};
}
}
// 用于防止并发写入冲突
const pendingWrites = new Map();
async function setBlockMemo(blockId, memo) {
if (!blockId) {
console.error('[标注] 设置块备注失败: 无效的blockId');
return { code: -1, msg: '无效的blockId' };
}
// 如果已经有相同blockId的写入请求在进行中,则取消
const existingController = pendingWrites.get(blockId);
if (existingController) {
existingController.abort();
pendingWrites.delete(blockId);
}
// 创建新的AbortController
const controller = new AbortController();
pendingWrites.set(blockId, controller);
try {
window.__commentLogger.info('设置块备注', blockId, memo.substring(0, 50) + (memo.length > 50 ? '...' : ''));
// 验证JSON格式,避免写入无效数据
try {
JSON.parse(memo);
} catch (e) {
window.__commentLogger.error('无效的JSON格式', e);
pendingWrites.delete(blockId);
return { code: -1, msg: '无效的JSON格式' };
}
// 更新缓存,提前反映变化
if (blockAttrsCache.has(blockId)) {
const cachedData = blockAttrsCache.get(blockId);
cachedData.data.memo = memo;
cachedData.timestamp = Date.now();
}
const res = await fetch('/api/attr/setBlockAttrs', {
method: 'POST',
body: JSON.stringify({ id: blockId, attrs: { memo } }),
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
// 添加超时
signal: AbortSignal.timeout(5000)
});
if (!res.ok) {
window.__commentLogger.error('设置块备注失败', res.status, res.statusText);
pendingWrites.delete(blockId);
return { code: -1, msg: `请求失败: ${res.status} ${res.statusText}` };
}
const json = await res.json();
window.__commentLogger.info('设置块备注返回', json);
pendingWrites.delete(blockId);
return json;
} catch (err) {
if (err.name !== 'AbortError') {
window.__commentLogger.error('设置块备注异常', err);
}
pendingWrites.delete(blockId);
return { code: -1, msg: err.message };
}
}
CSS:
.comment-btn {
padding: 0px 30px;
}
/* 标注按钮图标相对定位 */
.comment-btn .protyle-icon--only {
position: relative;
display: inline-block;
}
/* 标注数量样式 */
.comment-count {
position: absolute;
top: -5px;
right: -5px;
background-color: #ff5722;
color: white;
border-radius: 50%;
font-size: 12px;
min-width: 16px;
height: 16px;
line-height: 16px;
text-align: center;
font-weight: bold;
padding: 0 3px;
box-sizing: border-box;
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
}
.comment-rect {
position: absolute;
border: 2px solid #2196f3;
background: rgba(33,150,243,0.1);
border-radius: 4px;
z-index: 7;
box-sizing: border-box;
user-select: none;
}
.comment-rect .resize-handle {
position: absolute;
width: 10px;
height: 10px;
background: #2196f3;
cursor: nwse-resize;
z-index: 10;
left: calc(100% - 6px);
top: calc(100% - 6px);
}
.comment-index {
position: absolute; left: -16px; top: -16px;
background: #2196f3; color: #fff; border-radius: 50%;
width: 24px; height: 24px; text-align: center; line-height: 24px;
}
.comment-close {
position: absolute; right: -12px; top: -12px;
background: #f44336; color: #fff; border-radius: 50%;
width: 24px; height: 24px; text-align: center; line-height: 22px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
z-index: 15;
}
.comment-content {
position: absolute;
background: #fff;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
padding: 4px 12px;
font-size: 14px;
word-break: break-word;
white-space: normal;
z-index: 11;
max-width: 25em;
width: max-content;
min-width: 1em;
text-align: left;
cursor: pointer;
transform: translateX(-50%);
}
.comment-input-popup {
position: fixed;
z-index: 9999;
background-color: #fff;
font-size: 15px;
transform: translateX(-50%);
border: none;
border-radius: 10px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 300px;
max-width: 400px;
}
/* 输入框标题部分 */
.comment-input-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.comment-input-title {
font-weight: bold;
font-size: 16px;
color: #333;
}
.comment-input-close {
cursor: pointer;
font-size: 20px;
color: #999;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s;
}
.comment-input-close:hover {
background-color: #f0f0f0;
}
/* 文本区域容器 */
.comment-input-textarea-container {
position: relative;
width: 100%;
}
.comment-input-popup textarea {
width: 100%;
min-height: 80px;
padding: 10px 14px;
border: 1px solid #e0e0e0;
border-radius: 6px;
resize: vertical;
font-family: inherit;
font-size: 14px;
line-height: 1.5;
color: #333;
box-sizing: border-box;
transition: border-color 0.2s;
}
.comment-input-popup textarea:focus {
border-color: #2196f3;
outline: none;
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.1);
}
.comment-input-popup textarea.error {
border-color: #f44336;
box-shadow: 0 0 0 2px rgba(244, 67, 54, 0.1);
}
/* 按钮容器 */
.comment-input-buttons {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}
.comment-input-popup button {
padding: 6px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.comment-input-popup button.comment-input-cancel {
border: 1px solid #e0e0e0;
background-color: #fff;
color: #666;
}
.comment-input-popup button.comment-input-cancel:hover {
background-color: #f5f5f5;
border-color: #d0d0d0;
}
.comment-input-popup button.comment-input-ok {
border: none;
background-color: #2196f3;
color: #fff;
}
.comment-input-popup button.comment-input-ok:hover {
background-color: #0d8aee;
}
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于