[js][css] 图片标注

效果

192e307b72dc45ae80b356b54bfc7dea.png

操作示例

甩锅声明

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;
  }

  
  • 思源笔记

    思源笔记是一款隐私优先的个人知识管理系统,支持完全离线使用,同时也支持端到端加密同步。

    融合块、大纲和双向链接,重构你的思维。

    26401 引用 • 109793 回帖 • 2 关注
  • 代码片段

    代码片段分为 CSS 与 JS 两种代码,添加在 [设置 - 外观 - 代码片段] 中,这些代码会在思源笔记加载时自动执行,用于改善笔记的样式或功能。

    用户在该标签下分享代码片段时需在帖子标题前添加 [css] [js] 用于区分代码片段类型。

    204 引用 • 1482 回帖 • 2 关注

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...
  • chuchen

    大佬我好奇的是 你需要什么词让 AI 能读取到思源 api?

    1 回复
  • MasterYS

    还挺有意思的

    Mark 一下

  • Achuan-2 1 评论

    有点强,可以做成插件

    其实很期待思源能增强图片标注体验,如同 pixpin 一样,可以添加文字、矩形、箭头、序号,并且后面还可以修改

    2 回复
    2 操作
    Achuan-2 在 2025-06-24 18:21:43 更新了该回帖
    Achuan-2 在 2025-06-24 18:20:24 更新了该回帖
    👍 好创意,以前只想着用 canvas 实现,没想过也可以 js 实现,然后需要的时候还可以直接保存为图片。
    wilsons
  • onemo

    这个社区文档有 API 介绍,找到需要的发给 AI 就行了。
    思源社区文档

  • onemo

    需要一个大佬出手,我纯靠 AI 没能力维护。

  • Fighter93

    很有意思也很实用,但是这一类代码(功能)建立了太多的监听事件,会一定程度影响程序的运行。

  • cxg318

    求一个能遮盖部分图片的功能,点击能去遮盖。便于回忆学习

  • FFFFFFire

    期待,感觉像截图工具一样标注很实用。

请输入回帖内容 ...