效果
操作示例
甩锅声明
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; }
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于