2271 lines
117 KiB
JavaScript
2271 lines
117 KiB
JavaScript
// js/annotation_logic.js
|
||
|
||
// 假设以下全局变量由 history_detail.html 或 window 提供:
|
||
// - window.data (包含批注信息)
|
||
// - window.globalCurrentContentIdentifier (字符串, 'ocr' 或 'translation')
|
||
// - window.globalCurrentSelection (对象 {text, range, annotationId?, blockIndex?, subBlockId?, targetElement?, contentIdentifierForSelection?})
|
||
// - window.globalCurrentTargetElement (DOM 元素) - 将被 globalCurrentSelection.targetElement 取代或辅助
|
||
// - window.globalCurrentHighlightStatus (布尔值)
|
||
// - getQueryParam (来自 history_detail.html 的函数)
|
||
// 以及来自 storage.js 的函数:
|
||
// - saveAnnotationToDB, deleteAnnotationFromDB, updateAnnotationInDB, getAnnotationsForDocFromDB
|
||
|
||
var annotationContextMenuElement; // 右键菜单的HTML元素
|
||
|
||
// ========== Phase 2.3: 批注系统 DOM 缓存优化 ==========
|
||
/**
|
||
* 批注系统 DOM 缓存类
|
||
* 缓存 sub-block 元素,避免右键时全文档 querySelectorAll
|
||
*/
|
||
const AnnotationDOMCache = {
|
||
// 缓存的 sub-block 元素数组
|
||
subBlocks: null,
|
||
|
||
// 缓存的 sub-block 映射 (subBlockId -> element)
|
||
subBlockMap: null,
|
||
|
||
// 缓存是否已初始化
|
||
initialized: false,
|
||
|
||
/**
|
||
* 初始化缓存
|
||
* 在内容渲染完成后调用
|
||
*/
|
||
init: function() {
|
||
console.time('[AnnotationCache] 初始化 sub-block 缓存');
|
||
|
||
// 查询所有 sub-block 元素
|
||
this.subBlocks = Array.from(document.querySelectorAll('.sub-block[data-sub-block-id]'));
|
||
|
||
// 创建映射表
|
||
this.subBlockMap = new Map();
|
||
this.subBlocks.forEach(subBlock => {
|
||
const subBlockId = subBlock.dataset.subBlockId;
|
||
if (subBlockId) {
|
||
this.subBlockMap.set(subBlockId, subBlock);
|
||
}
|
||
});
|
||
|
||
this.initialized = true;
|
||
console.timeEnd('[AnnotationCache] 初始化 sub-block 缓存');
|
||
console.log(`[AnnotationCache] 已缓存 ${this.subBlocks.length} 个 sub-block 元素`);
|
||
|
||
return this;
|
||
},
|
||
|
||
/**
|
||
* 获取所有 sub-block 元素(从缓存)
|
||
* 如果缓存未初始化,则动态查询
|
||
*/
|
||
getAllSubBlocks: function() {
|
||
if (!this.initialized) {
|
||
console.warn('[AnnotationCache] 缓存未初始化,执行动态查询');
|
||
return document.querySelectorAll('.sub-block[data-sub-block-id]');
|
||
}
|
||
return this.subBlocks;
|
||
},
|
||
|
||
/**
|
||
* 根据 subBlockId 获取元素
|
||
*/
|
||
getSubBlockById: function(subBlockId) {
|
||
if (!this.initialized) {
|
||
console.warn('[AnnotationCache] 缓存未初始化,执行动态查询');
|
||
return document.querySelector(`.sub-block[data-sub-block-id="${subBlockId}"]`);
|
||
}
|
||
return this.subBlockMap.get(subBlockId) || null;
|
||
},
|
||
|
||
/**
|
||
* 清空缓存
|
||
* 在标签切换或内容重新渲染时调用
|
||
*/
|
||
clear: function() {
|
||
this.subBlocks = null;
|
||
this.subBlockMap = null;
|
||
this.initialized = false;
|
||
console.log('[AnnotationCache] 缓存已清空');
|
||
},
|
||
|
||
/**
|
||
* 重新初始化缓存
|
||
* 在内容更新(如自动分块)后调用
|
||
*/
|
||
refresh: function() {
|
||
console.log('[AnnotationCache] 刷新缓存...');
|
||
this.clear();
|
||
return this.init();
|
||
}
|
||
};
|
||
|
||
// 挂载到全局,方便外部调用
|
||
window.AnnotationDOMCache = AnnotationDOMCache;
|
||
|
||
// 这些全局变量将在 history_detail.html 的主脚本中初始化和管理。
|
||
// 此脚本将使用它们。
|
||
// let globalCurrentSelection = null; // 全局当前选区对象
|
||
// let globalCurrentTargetElement = null; // 全局当前右键菜单目标元素
|
||
// let globalCurrentHighlightStatus = false; // 全局当前高亮状态
|
||
// let globalCurrentContentIdentifier = ''; // 全局当前内容标识符 (例如 'ocr', 'translation'),将由 history_detail.html 中的 showTab 函数设置
|
||
|
||
|
||
function _page_generateUUID() {
|
||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
|
||
return v.toString(16);
|
||
});
|
||
}
|
||
|
||
function escapeRegExp(string) {
|
||
// 更安全地转义所有正则表达式特殊字符
|
||
return string.replace(/[.*+?^${}()|[\\\]\\\\]/g, '\\\\$&');
|
||
}
|
||
|
||
function fuzzyRegFromExact(exact) {
|
||
// 先转义所有正则表达式特殊字符
|
||
let pattern = escapeRegExp(exact);
|
||
// 将所有空白替换为 \\s+,允许跨行、多个空格
|
||
pattern = pattern.replace(/\\\\s+/g, '\\\\s+');
|
||
// 可选:忽略前后空白
|
||
pattern = '\\\\s*' + pattern + '\\\\s*';
|
||
return new RegExp(pattern, 'gi');
|
||
}
|
||
|
||
/**
|
||
* 模糊匹配两个字符串,忽略所有空白和换行
|
||
* @param {string} a 字符串a
|
||
* @param {string} b 字符串b
|
||
* @returns {boolean} 如果匹配则返回true,否则返回false
|
||
*/
|
||
function fuzzyMatch(a, b) {
|
||
const cleanA = String(a).replace(/\\s+/g, '');
|
||
const cleanB = String(b).replace(/\\s+/g, '');
|
||
return cleanA === cleanB;
|
||
}
|
||
|
||
/**
|
||
* 通用函数:检查指定目标是否已被高亮。
|
||
* @param {string} [annotationId=null] - 可选的批注ID。
|
||
* @param {string} contentIdentifier - 当前内容的标识符 ('ocr' 或 'translation')。
|
||
* @param {string} [targetIdentifier=null] - 目标元素的标识符 (blockIndex 或 subBlockId)。
|
||
* @param {'blockIndex'|'subBlockId'} identifierType - 标识符的类型。
|
||
* @returns {boolean} 是否已高亮。
|
||
*/
|
||
function checkIfTargetIsHighlighted(annotationId = null, contentIdentifier, targetIdentifier = null, identifierType) {
|
||
// console.log(`[checkIfTargetIsHighlighted] ID: ${annotationId}, ContentID: ${contentIdentifier}, TargetID: ${targetIdentifier}, Type: ${identifierType}`);
|
||
if (!window.data || !window.data.annotations) {
|
||
return false;
|
||
}
|
||
|
||
let annotation;
|
||
if (annotationId) {
|
||
// ID 优先匹配
|
||
annotation = window.data.annotations.find(ann =>
|
||
ann.targetType === contentIdentifier && ann.id === annotationId
|
||
);
|
||
} else if (targetIdentifier !== null && identifierType) {
|
||
// 通过目标标识符查找,与removeAnnotationFromTarget保持一致的匹配逻辑
|
||
const targetIdStr = String(targetIdentifier).trim();
|
||
|
||
annotation = window.data.annotations.find(ann => {
|
||
// 确保基本条件匹配
|
||
if (ann.targetType !== contentIdentifier) return false;
|
||
if (!ann.target || !Array.isArray(ann.target.selector) || !ann.target.selector[0]) return false;
|
||
|
||
// 获取选择器中的标识符,确保转换为字符串进行比较
|
||
const selectorId = ann.target.selector[0][identifierType];
|
||
if (selectorId === undefined) return false;
|
||
|
||
const selectorIdStr = String(selectorId).trim();
|
||
|
||
// 使用与removeAnnotationFromTarget相同的比较逻辑
|
||
return selectorIdStr === targetIdStr || Math.abs(Number(selectorIdStr) - Number(targetIdStr)) < 0.001;
|
||
});
|
||
}
|
||
|
||
// console.log('[checkIfTargetIsHighlighted] 找到的批注:', annotation, '结果:', !!annotation);
|
||
return !!annotation;
|
||
}
|
||
|
||
/**
|
||
* 通用函数:检查指定目标是否已有批注内容。
|
||
* @param {string} [annotationId=null] - 可选的批注ID。
|
||
* @param {string} contentIdentifier - 当前内容的标识符 ('ocr' 或 'translation')。
|
||
* @param {string} [targetIdentifier=null] - 目标元素的标识符 (blockIndex 或 subBlockId)。
|
||
* @param {'blockIndex'|'subBlockId'} identifierType - 标识符的类型。
|
||
* @returns {boolean} 是否已有批注内容。
|
||
*/
|
||
function checkIfTargetHasNote(annotationId = null, contentIdentifier, targetIdentifier = null, identifierType) {
|
||
// console.log(`[checkIfTargetHasNote] ID: ${annotationId}, ContentID: ${contentIdentifier}, TargetID: ${targetIdentifier}, Type: ${identifierType}`);
|
||
if (!window.data || !window.data.annotations) return false;
|
||
|
||
let annotation;
|
||
if (annotationId) {
|
||
// ID 优先匹配
|
||
annotation = window.data.annotations.find(ann =>
|
||
ann.targetType === contentIdentifier &&
|
||
ann.id === annotationId &&
|
||
ann.body && ann.body.length > 0 && ann.body[0].value && ann.body[0].value.trim() !== ''
|
||
);
|
||
} else if (targetIdentifier !== null && identifierType) {
|
||
// 通过目标标识符查找,与其他函数保持一致的匹配逻辑
|
||
const targetIdStr = String(targetIdentifier).trim();
|
||
|
||
annotation = window.data.annotations.find(ann => {
|
||
// 确保基本条件匹配
|
||
if (ann.targetType !== contentIdentifier) return false;
|
||
if (!ann.target || !Array.isArray(ann.target.selector) || !ann.target.selector[0]) return false;
|
||
|
||
// 获取选择器中的标识符,确保转换为字符串进行比较
|
||
const selectorId = ann.target.selector[0][identifierType];
|
||
if (selectorId === undefined) return false;
|
||
|
||
const selectorIdStr = String(selectorId).trim();
|
||
|
||
// 使用与其他函数相同的比较逻辑
|
||
const idMatch = selectorIdStr === targetIdStr || Math.abs(Number(selectorIdStr) - Number(targetIdStr)) < 0.001;
|
||
|
||
// 还需要检查是否有批注内容
|
||
return idMatch && ann.body && ann.body.length > 0 && ann.body[0].value && ann.body[0].value.trim() !== '';
|
||
});
|
||
}
|
||
|
||
// console.log('[checkIfTargetHasNote] 找到的批注:', annotation, '结果:', !!annotation);
|
||
return !!annotation;
|
||
}
|
||
|
||
/**
|
||
* 根据是否已高亮和是否有批注来更新上下文菜单选项的显示
|
||
* @param {boolean} isHighlighted - 是否已高亮
|
||
* @param {boolean} hasNote - 是否已有批注
|
||
*/
|
||
function updateContextMenuOptions(isHighlighted, hasNote = false, isReadOnlyMode = false) {
|
||
if (!annotationContextMenuElement) return;
|
||
|
||
const highlightOption = annotationContextMenuElement.querySelector('[data-action="highlight-block"]') ||
|
||
annotationContextMenuElement.querySelector('[data-action="highlight-paragraph"]');
|
||
const removeHighlightOption = document.getElementById('remove-highlight-option');
|
||
const addNoteOption = document.getElementById('add-note-option');
|
||
const editNoteOption = document.getElementById('edit-note-option');
|
||
const copyContentOption = document.getElementById('copy-content-option');
|
||
const highlightActionsDivider = document.getElementById('highlight-actions-divider');
|
||
const noteActionsDivider = document.getElementById('note-actions-divider');
|
||
|
||
if (isReadOnlyMode) {
|
||
if (highlightOption) highlightOption.style.display = 'none';
|
||
if (removeHighlightOption) removeHighlightOption.style.display = 'none';
|
||
if (addNoteOption) addNoteOption.style.display = 'none';
|
||
if (editNoteOption) editNoteOption.style.display = 'none';
|
||
if (copyContentOption) copyContentOption.style.display = 'none';
|
||
if (highlightActionsDivider) highlightActionsDivider.style.display = 'none';
|
||
if (noteActionsDivider) noteActionsDivider.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
// 放宽:只要存在非空选区即可高亮,内部会自动映射到子块/跨子块
|
||
let canHighlight = false;
|
||
try {
|
||
const sel = window.getSelection();
|
||
canHighlight = !!(sel && sel.rangeCount && !sel.getRangeAt(0).collapsed);
|
||
} catch { canHighlight = false; }
|
||
if (highlightOption) {
|
||
highlightOption.style.display = canHighlight ? 'block' : 'none';
|
||
try { highlightOption.textContent = '高亮选中内容'; } catch { /* noop */ }
|
||
}
|
||
|
||
if (removeHighlightOption) removeHighlightOption.style.display = isHighlighted ? 'block' : 'none';
|
||
|
||
if (copyContentOption) {
|
||
const sel = window.getSelection();
|
||
const hasRange = sel && sel.rangeCount && !sel.getRangeAt(0).collapsed;
|
||
copyContentOption.style.display = hasRange ? 'block' : 'none';
|
||
}
|
||
|
||
if (isHighlighted) {
|
||
if (addNoteOption) addNoteOption.style.display = hasNote ? 'none' : 'block';
|
||
if (editNoteOption) editNoteOption.style.display = hasNote ? 'block' : 'none';
|
||
} else {
|
||
if (addNoteOption) addNoteOption.style.display = 'none';
|
||
if (editNoteOption) editNoteOption.style.display = 'none';
|
||
}
|
||
|
||
if (highlightActionsDivider) {
|
||
highlightActionsDivider.style.display = isHighlighted ? 'block' : 'none';
|
||
}
|
||
if (noteActionsDivider) {
|
||
const noteOptionsVisible = (addNoteOption && addNoteOption.style.display === 'block') || (editNoteOption && editNoteOption.style.display === 'block');
|
||
noteActionsDivider.style.display = isHighlighted && noteOptionsVisible ? 'block' : 'none';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 显示上下文菜单
|
||
* @param {number} x - x坐标
|
||
* @param {number} y - y坐标
|
||
*/
|
||
function showContextMenu(x, y) {
|
||
if (!annotationContextMenuElement) return;
|
||
annotationContextMenuElement.style.left = x + 'px';
|
||
annotationContextMenuElement.style.top = y + 'px';
|
||
annotationContextMenuElement.classList.remove('context-menu-hidden');
|
||
annotationContextMenuElement.classList.add('context-menu-visible');
|
||
}
|
||
|
||
/**
|
||
* 隐藏上下文菜单并重置相关状态
|
||
*/
|
||
function hideContextMenu() {
|
||
if (!annotationContextMenuElement) return;
|
||
annotationContextMenuElement.classList.remove('context-menu-visible');
|
||
annotationContextMenuElement.classList.add('context-menu-hidden');
|
||
|
||
// 重置由 history_detail.html 管理的全局变量
|
||
window.globalCurrentSelection = null;
|
||
// window.globalCurrentTargetElement = null; // 作用减弱
|
||
window.globalCurrentHighlightStatus = false;
|
||
}
|
||
|
||
/**
|
||
* 通用函数:从数据库中移除指定目标的批注。
|
||
* @param {string} docId - 文档ID。
|
||
* @param {string} [annotationId=null] - 可选的批注ID。
|
||
* @param {string} [targetIdentifier=null] - 目标元素的标识符 (blockIndex 或 subBlockId)。
|
||
* @param {string} contentIdentifier - 内容标识符。
|
||
* @param {'blockIndex'|'subBlockId'} identifierType - 标识符的类型。
|
||
*/
|
||
async function removeAnnotationFromTarget(docId, annotationId = null, targetIdentifier = null, contentIdentifier, identifierType) {
|
||
if (!window.data.annotations) {
|
||
console.warn(`[批注逻辑] removeAnnotationFromTarget: window.data.annotations 未定义。`);
|
||
return;
|
||
}
|
||
if (!annotationId && targetIdentifier === null) {
|
||
console.error(`[批注逻辑] removeAnnotationFromTarget: 需要 annotationId 或 targetIdentifier。`);
|
||
throw new Error('未指定要删除的批注 (无ID或目标标识符)。');
|
||
}
|
||
|
||
// 增强日志:记录所有相关参数
|
||
console.log(`[批注逻辑] removeAnnotationFromTarget 参数: docId=${docId}, annotationId=${annotationId}, targetIdentifier=${targetIdentifier}, contentIdentifier=${contentIdentifier}, identifierType=${identifierType}`);
|
||
|
||
// 记录当前所有批注的数量和类型
|
||
if (window.data.annotations) {
|
||
console.log(`[批注逻辑] 当前批注总数: ${window.data.annotations.length}`);
|
||
const typeCounts = {};
|
||
window.data.annotations.forEach(ann => {
|
||
const type = ann.targetType || 'unknown';
|
||
typeCounts[type] = (typeCounts[type] || 0) + 1;
|
||
});
|
||
console.log(`[批注逻辑] 批注类型统计:`, typeCounts);
|
||
}
|
||
|
||
let annotationsToRemove = [];
|
||
if (annotationId) {
|
||
// 通过ID查找批注
|
||
annotationsToRemove = window.data.annotations.filter(ann => ann.id === annotationId && ann.targetType === contentIdentifier);
|
||
console.log(`[批注逻辑] 通过ID查找批注: ${annotationsToRemove.length}个匹配`);
|
||
} else if (targetIdentifier !== null && identifierType) {
|
||
// 通过目标标识符查找批注,增强类型比较
|
||
const targetIdStr = String(targetIdentifier).trim();
|
||
|
||
annotationsToRemove = window.data.annotations.filter(ann => {
|
||
// 确保基本条件匹配
|
||
if (ann.targetType !== contentIdentifier) return false;
|
||
if (!ann.target || !Array.isArray(ann.target.selector) || !ann.target.selector[0]) return false;
|
||
|
||
// 获取选择器中的标识符,确保转换为字符串进行比较
|
||
const selectorId = ann.target.selector[0][identifierType];
|
||
if (selectorId === undefined) return false;
|
||
|
||
const selectorIdStr = String(selectorId).trim();
|
||
|
||
// 记录详细的比较信息以便调试
|
||
const isMatch = selectorIdStr === targetIdStr;
|
||
if (selectorIdStr === targetIdStr || Math.abs(Number(selectorIdStr) - Number(targetIdStr)) < 0.001) {
|
||
console.log(`[批注逻辑] 找到匹配: ${selectorIdStr} == ${targetIdStr} (${identifierType})`);
|
||
return true;
|
||
}
|
||
return false;
|
||
});
|
||
|
||
console.log(`[批注逻辑] 通过${identifierType}查找批注: ${annotationsToRemove.length}个匹配 (目标值: ${targetIdStr})`);
|
||
|
||
// 如果没有找到匹配,记录所有可能的值以便调试
|
||
if (annotationsToRemove.length === 0) {
|
||
const allValues = window.data.annotations
|
||
.filter(ann => ann.targetType === contentIdentifier && ann.target && ann.target.selector && ann.target.selector[0])
|
||
.map(ann => {
|
||
const val = ann.target.selector[0][identifierType];
|
||
return val !== undefined ? String(val) : 'undefined';
|
||
});
|
||
console.log(`[批注逻辑] 当前所有${identifierType}值:`, allValues);
|
||
}
|
||
}
|
||
|
||
if (annotationsToRemove.length === 0) {
|
||
console.warn(`[批注逻辑] removeAnnotationFromTarget: 未找到要删除的批注。 ID: ${annotationId}, TargetID: ${targetIdentifier}, Type: ${identifierType}`);
|
||
return;
|
||
}
|
||
|
||
console.log(`[批注逻辑] 将删除${annotationsToRemove.length}个批注:`, annotationsToRemove);
|
||
|
||
for (const annotation of annotationsToRemove) {
|
||
try {
|
||
await deleteAnnotationFromDB(annotation.id);
|
||
const index = window.data.annotations.findIndex(ann => ann.id === annotation.id);
|
||
if (index > -1) {
|
||
window.data.annotations.splice(index, 1);
|
||
console.log(`[批注逻辑] 成功从内存中删除批注 ID: ${annotation.id}`);
|
||
} else {
|
||
console.warn(`[批注逻辑] 无法从内存中删除批注 ID: ${annotation.id} (未找到索引)`);
|
||
}
|
||
} catch (error) {
|
||
console.error(`[批注逻辑] removeAnnotationFromTarget: 删除批注失败:`, error);
|
||
throw error;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 通用函数:为现有的已高亮目标添加或更新批注内容。
|
||
* @param {string} noteText - 批注内容。
|
||
* @param {string} docId - 文档ID。
|
||
* @param {string} [annotationId=null] - 可选的批注ID。
|
||
* @param {string} [targetIdentifier=null] - 目标元素的标识符 (blockIndex 或 subBlockId)。
|
||
* @param {string} contentIdentifier - 内容标识符。
|
||
* @param {'blockIndex'|'subBlockId'} identifierType - 标识符的类型。
|
||
*/
|
||
async function addNoteToAnnotation(noteText, docId, annotationId = null, targetIdentifier = null, contentIdentifier, identifierType) {
|
||
if (!window.data.annotations) {
|
||
throw new Error('没有找到批注数据');
|
||
}
|
||
if (!annotationId && targetIdentifier === null) {
|
||
console.error(`[批注逻辑] addNoteToAnnotation: 需要 annotationId 或 targetIdentifier。`);
|
||
throw new Error('未指定要添加批注的目标 (无ID或目标标识符)。');
|
||
}
|
||
|
||
// 增强日志:记录所有相关参数
|
||
console.log(`[批注逻辑] addNoteToAnnotation 参数: docId=${docId}, annotationId=${annotationId}, targetIdentifier=${targetIdentifier}, contentIdentifier=${contentIdentifier}, identifierType=${identifierType}`);
|
||
|
||
let existingAnnotation;
|
||
if (annotationId) {
|
||
// 通过ID查找批注
|
||
existingAnnotation = window.data.annotations.find(ann =>
|
||
ann.id === annotationId &&
|
||
ann.targetType === contentIdentifier &&
|
||
(ann.motivation === 'highlighting' || ann.motivation === 'commenting')
|
||
);
|
||
console.log(`[批注逻辑] 通过ID查找批注进行添加/更新批注: ${existingAnnotation ? '找到' : '未找到'}`);
|
||
} else if (targetIdentifier !== null && identifierType) {
|
||
// 通过目标标识符查找批注,使用与其他函数一致的匹配逻辑
|
||
const targetIdStr = String(targetIdentifier).trim();
|
||
|
||
existingAnnotation = window.data.annotations.find(ann => {
|
||
// 确保基本条件匹配
|
||
if (ann.targetType !== contentIdentifier) return false;
|
||
if (!ann.target || !Array.isArray(ann.target.selector) || !ann.target.selector[0]) return false;
|
||
if (!(ann.motivation === 'highlighting' || ann.motivation === 'commenting')) return false;
|
||
|
||
// 获取选择器中的标识符,确保转换为字符串进行比较
|
||
const selectorId = ann.target.selector[0][identifierType];
|
||
if (selectorId === undefined) return false;
|
||
|
||
const selectorIdStr = String(selectorId).trim();
|
||
|
||
// 使用与其他函数相同的比较逻辑
|
||
return selectorIdStr === targetIdStr || Math.abs(Number(selectorIdStr) - Number(targetIdStr)) < 0.001;
|
||
});
|
||
|
||
console.log(`[批注逻辑] 通过${identifierType}查找批注进行添加/更新批注: ${existingAnnotation ? '找到' : '未找到'} (目标值: ${targetIdStr})`);
|
||
}
|
||
|
||
if (!existingAnnotation) {
|
||
console.warn(`[批注逻辑] addNoteToAnnotation: 未找到对应的高亮批注。 ID: ${annotationId}, TargetID: ${targetIdentifier}, Type: ${identifierType}`);
|
||
throw new Error('未找到对应的高亮批注进行批注操作');
|
||
}
|
||
|
||
existingAnnotation.body = [{
|
||
type: 'TextualBody',
|
||
value: noteText,
|
||
format: 'text/plain',
|
||
purpose: 'commenting'
|
||
}];
|
||
existingAnnotation.modified = new Date().toISOString();
|
||
existingAnnotation.motivation = 'commenting';
|
||
|
||
try {
|
||
await updateAnnotationInDB(existingAnnotation);
|
||
console.log(`[批注逻辑] 成功更新批注 ID: ${existingAnnotation.id}`);
|
||
// 新增:批注内容变动后立即刷新目标元素的title/class
|
||
let targetElement = null;
|
||
if (identifierType === 'subBlockId') {
|
||
const containerId = contentIdentifier + '-content-wrapper';
|
||
const container = document.getElementById(containerId);
|
||
if (container) {
|
||
targetElement = container.querySelector('.sub-block[data-sub-block-id="' + (existingAnnotation.target.selector[0].subBlockId || targetIdentifier) + '"]');
|
||
}
|
||
} else if (identifierType === 'blockIndex') {
|
||
const containerId = contentIdentifier + '-content-wrapper';
|
||
const container = document.getElementById(containerId);
|
||
if (container) {
|
||
targetElement = container.querySelector('[data-block-index="' + (existingAnnotation.target.selector[0].blockIndex || targetIdentifier) + '"]');
|
||
}
|
||
}
|
||
if (targetElement && window.highlightBlockOrSubBlock) {
|
||
window.highlightBlockOrSubBlock(targetElement, existingAnnotation, contentIdentifier, targetIdentifier, identifierType === 'subBlockId' ? 'subBlock' : 'block');
|
||
}
|
||
} catch (error) {
|
||
console.error(`[批注逻辑] addNoteToAnnotation: 更新批注失败:`, error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// 主初始化函数,由 history_detail.html 调用
|
||
function initAnnotationSystem() {
|
||
annotationContextMenuElement = document.getElementById('custom-context-menu');
|
||
if (!annotationContextMenuElement) {
|
||
console.error("未找到批注上下文菜单元素 ('custom-context-menu')!");
|
||
return;
|
||
}
|
||
|
||
// ========== 事件委托:只在 .container 上全局绑定一次 contextmenu ==========
|
||
const mainContainer = document.querySelector('.container');
|
||
if (mainContainer) {
|
||
if (mainContainer._annotationContextMenuBound) return;
|
||
mainContainer._annotationContextMenuBound = true;
|
||
mainContainer.addEventListener('contextmenu', function(event) {
|
||
// 防呆:内容未加载完成时禁止右键
|
||
if (!window.contentReady) {
|
||
alert('请等待内容加载完成后再右键区块。');
|
||
return;
|
||
}
|
||
|
||
// ===== 防重复触发机制 =====
|
||
if (this._contextMenuProcessing) {
|
||
console.log('[跨子块检测] 事件正在处理中,跳过重复触发');
|
||
return;
|
||
}
|
||
this._contextMenuProcessing = true;
|
||
|
||
// 延迟重置标志,避免快速重复触发
|
||
setTimeout(() => {
|
||
this._contextMenuProcessing = false;
|
||
}, 100);
|
||
|
||
// ===== 新增:跨子块选择检测 =====
|
||
console.log('[跨子块检测] 开始检测跨子块选择...');
|
||
|
||
// 使用缓存获取所有子块(Phase 2.3 优化)
|
||
let allSubBlocks = window.AnnotationDOMCache.getAllSubBlocks();
|
||
console.log('[跨子块检测] 页面上的子块总数:', allSubBlocks.length);
|
||
|
||
if (allSubBlocks.length === 0) {
|
||
console.log('[跨子块检测] ⚠️ 页面上没有找到任何子块!内容可能还没有分割。');
|
||
const blocks = document.querySelectorAll('[data-block-index]');
|
||
console.log('[跨子块检测] [data-block-index]元素数量:', blocks.length);
|
||
if (blocks.length > 0 && window.SubBlockSegmenter && typeof window.SubBlockSegmenter.segment === 'function') {
|
||
console.log('[跨子块检测] 触发自动分块(英文/中文标点)');
|
||
blocks.forEach(el => {
|
||
try { window.SubBlockSegmenter.segment(el, el.dataset.blockIndex, true); }
|
||
catch (e) { console.warn('[跨子块检测] 自动分块失败:', e); }
|
||
});
|
||
// 自动分块后刷新缓存
|
||
allSubBlocks = window.AnnotationDOMCache.refresh().getAllSubBlocks();
|
||
console.log('[跨子块检测] 自动分块后 .sub-block数量:', allSubBlocks.length);
|
||
}
|
||
} else {
|
||
console.log('[跨子块检测] 前5个子块ID:', Array.from(allSubBlocks).slice(0, 5).map(sb => sb.dataset.subBlockId));
|
||
}
|
||
|
||
const crossBlockSelection = detectCrossBlockSelection();
|
||
console.log('[跨子块检测] 检测结果:', crossBlockSelection);
|
||
if (crossBlockSelection.isCrossBlock) {
|
||
console.log('[跨子块检测] 检测到跨子块选择,处理跨子块标注');
|
||
event.preventDefault(); // 阻止默认行为
|
||
return handleCrossBlockAnnotation(event, crossBlockSelection);
|
||
} else {
|
||
console.log('[跨子块检测] 未检测到跨子块选择,继续单子块处理');
|
||
}
|
||
|
||
// 只处理 .sub-block 或 [data-block-index] 的右键 (仅在非跨子块情况下)
|
||
let targetSubBlock = event.target.closest('.sub-block[data-sub-block-id]');
|
||
let targetBlock = event.target.closest('[data-block-index]');
|
||
|
||
// 优先:使用当前选区的起点子块作为目标,避免误选到上一段
|
||
try {
|
||
const sel = window.getSelection();
|
||
if (sel && sel.rangeCount) {
|
||
const r = sel.getRangeAt(0);
|
||
if (!r.collapsed) {
|
||
const startEl = r.startContainer.nodeType === Node.TEXT_NODE ? r.startContainer.parentElement : r.startContainer;
|
||
const subFromSelection = startEl && startEl.closest ? startEl.closest('.sub-block[data-sub-block-id]') : null;
|
||
if (subFromSelection) {
|
||
targetSubBlock = subFromSelection;
|
||
targetBlock = subFromSelection.closest('[data-block-index]') || targetBlock;
|
||
} else if (!targetSubBlock) {
|
||
// 若选区存在但所在段落尚未分段,则对该段落强制分段并定位子块
|
||
const blockEl = startEl && startEl.closest ? startEl.closest('[data-block-index]') : null;
|
||
if (blockEl && window.SubBlockSegmenter && typeof window.SubBlockSegmenter.segment === 'function') {
|
||
try {
|
||
// 计算选区在块内的文本偏移
|
||
const getTextOffset = (elementNode, parentBlock) => {
|
||
let offset = 0;
|
||
const walker = document.createTreeWalker(parentBlock, NodeFilter.SHOW_TEXT, null, false);
|
||
let n;
|
||
while ((n = walker.nextNode())) {
|
||
if (n === elementNode || n.parentElement === elementNode) break;
|
||
offset += (n.textContent || '').length;
|
||
}
|
||
return offset;
|
||
};
|
||
const preOffset = getTextOffset(r.startContainer, blockEl);
|
||
window.SubBlockSegmenter.segment(blockEl, blockEl.dataset.blockIndex, true);
|
||
// 在新子块中查找对应的位置
|
||
const subBlocks = blockEl.querySelectorAll('.sub-block[data-sub-block-id]');
|
||
let acc = 0;
|
||
subBlocks.forEach(sb => {
|
||
const L = (sb.textContent || '').length;
|
||
if (targetSubBlock) return;
|
||
if (preOffset >= acc && preOffset < acc + L) targetSubBlock = sb;
|
||
acc += L;
|
||
});
|
||
if (!targetSubBlock) {
|
||
// 仍未定位到具体子块:使用块元素本身(虚拟子块)并兼容渲染器
|
||
if (!blockEl.dataset.subBlockId) {
|
||
blockEl._virtualSubBlockId = blockEl.dataset.blockIndex + '.0';
|
||
blockEl.dataset.subBlockId = blockEl._virtualSubBlockId;
|
||
}
|
||
if (!blockEl.classList.contains('sub-block')) {
|
||
blockEl.classList.add('sub-block');
|
||
}
|
||
targetSubBlock = blockEl;
|
||
}
|
||
if (!targetBlock) targetBlock = blockEl;
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch(e){ /* ignore */ }
|
||
if (!targetSubBlock && !targetBlock) {
|
||
console.log('[单子块检测] 右键目标不是子块或块级元素,忽略');
|
||
return;
|
||
}
|
||
|
||
// 新增:判断是否为只读视图 (分块对比模式)
|
||
const isReadOnlyView = window.currentVisibleTabId === 'chunk-compare';
|
||
if (isReadOnlyView) {
|
||
event.preventDefault();
|
||
hideContextMenu();
|
||
return;
|
||
}
|
||
|
||
let targetElementForAnnotation;
|
||
let identifier, identifierType, blockIndexForContext = null, selectedTextForContext;
|
||
let isOnlySubBlock = false;
|
||
|
||
if (targetSubBlock) {
|
||
targetElementForAnnotation = targetSubBlock;
|
||
identifier = targetSubBlock.dataset.subBlockId;
|
||
identifierType = 'subBlockId';
|
||
if (targetSubBlock.dataset.isOnlySubBlock === "true") {
|
||
isOnlySubBlock = true;
|
||
}
|
||
const parentBlockElement = targetSubBlock.closest('[data-block-index]');
|
||
if (parentBlockElement) {
|
||
blockIndexForContext = parentBlockElement.dataset.blockIndex;
|
||
}
|
||
} else if (targetBlock) {
|
||
targetElementForAnnotation = targetBlock;
|
||
identifier = targetBlock.dataset.blockIndex;
|
||
identifierType = 'blockIndex';
|
||
blockIndexForContext = identifier;
|
||
} else {
|
||
hideContextMenu();
|
||
return;
|
||
}
|
||
|
||
const annotationId = targetElementForAnnotation.dataset.annotationId;
|
||
// 优先采用当前选区文本
|
||
try {
|
||
const sel = window.getSelection();
|
||
if (sel && sel.rangeCount && !sel.getRangeAt(0).collapsed) {
|
||
selectedTextForContext = sel.toString();
|
||
} else {
|
||
selectedTextForContext = targetElementForAnnotation.textContent;
|
||
}
|
||
} catch { selectedTextForContext = targetElementForAnnotation.textContent; }
|
||
|
||
// 选区设置:仅使用用户当前选区(不再强制整块选中)
|
||
let effectiveRange;
|
||
try {
|
||
const sel = window.getSelection();
|
||
if (sel && sel.rangeCount && !sel.getRangeAt(0).collapsed) {
|
||
effectiveRange = sel.getRangeAt(0).cloneRange();
|
||
}
|
||
} catch { /* noop */ }
|
||
window.globalCurrentSelection = {
|
||
text: selectedTextForContext,
|
||
range: effectiveRange,
|
||
annotationId: annotationId,
|
||
targetElement: targetElementForAnnotation,
|
||
contentIdentifierForSelection: window.globalCurrentContentIdentifier,
|
||
[identifierType]: identifier,
|
||
blockIndex: blockIndexForContext
|
||
};
|
||
|
||
// Store context directly on the menu element
|
||
annotationContextMenuElement.dataset.contextContentIdentifier = window.globalCurrentContentIdentifier;
|
||
annotationContextMenuElement.dataset.contextTargetIdentifier = identifier;
|
||
annotationContextMenuElement.dataset.contextIdentifierType = identifierType;
|
||
if (annotationId) {
|
||
annotationContextMenuElement.dataset.contextAnnotationId = annotationId;
|
||
} else {
|
||
delete annotationContextMenuElement.dataset.contextAnnotationId;
|
||
}
|
||
if (selectedTextForContext) {
|
||
annotationContextMenuElement.dataset.contextSelectedText = selectedTextForContext;
|
||
} else {
|
||
delete annotationContextMenuElement.dataset.contextSelectedText;
|
||
}
|
||
if (isOnlySubBlock && identifierType === 'subBlockId') {
|
||
annotationContextMenuElement.dataset.contextIsOnlySubBlock = "true";
|
||
} else {
|
||
delete annotationContextMenuElement.dataset.contextIsOnlySubBlock;
|
||
}
|
||
if (blockIndexForContext) {
|
||
annotationContextMenuElement.dataset.contextBlockIndex = blockIndexForContext;
|
||
} else {
|
||
delete annotationContextMenuElement.dataset.contextBlockIndex;
|
||
}
|
||
|
||
// 🔧 BUG FIX: 清除跨子块相关属性,避免单子块操作时误用旧的跨子块数据
|
||
delete annotationContextMenuElement.dataset.contextIsCrossBlock;
|
||
delete annotationContextMenuElement.dataset.contextCrossBlockAnnotationId;
|
||
delete annotationContextMenuElement.dataset.contextAffectedSubBlocks;
|
||
|
||
console.log(`%c[AnnotationLogic ContxtMenu] Event triggered for container: ${mainContainer.id}, content type: ${window.globalCurrentContentIdentifier}`, 'color: blue; font-weight: bold;');
|
||
console.log(` Stored on menu - contentId: ${annotationContextMenuElement.dataset.contextContentIdentifier}, targetId: ${annotationContextMenuElement.dataset.contextTargetIdentifier}, type: ${annotationContextMenuElement.dataset.contextIdentifierType}, annId: ${annotationContextMenuElement.dataset.contextAnnotationId}, blockIdx: ${annotationContextMenuElement.dataset.contextBlockIndex}`);
|
||
console.log(` Selected text stored on menu: ${(annotationContextMenuElement.dataset.contextSelectedText || '').substring(0,50)}...`);
|
||
|
||
const isHighlighted = checkIfTargetIsHighlighted(annotationId, window.globalCurrentContentIdentifier, identifier, identifierType);
|
||
const hasNote = checkIfTargetHasNote(annotationId, window.globalCurrentContentIdentifier, identifier, identifierType);
|
||
|
||
console.log(` checkIfTargetIsHighlighted(...) returned: ${isHighlighted}`);
|
||
console.log(` checkIfTargetHasNote(...) returned: ${hasNote}`);
|
||
|
||
window.globalCurrentHighlightStatus = isHighlighted;
|
||
// 仅在可高亮(跨子块或子块内存在非空选区)或点击已有高亮时显示菜单
|
||
let canHighlight = false;
|
||
try {
|
||
const sel = window.getSelection();
|
||
const hasSelection = sel && sel.rangeCount && !sel.getRangeAt(0).collapsed;
|
||
console.log(`[调试] 选区检测: hasSelection=${hasSelection}, targetSubBlock=${!!targetSubBlock}, annotationId=${annotationId}`);
|
||
if (hasSelection) {
|
||
console.log(`[调试] 选中文本: "${sel.toString().substring(0, 50)}..."`);
|
||
}
|
||
canHighlight = !!(hasSelection && targetSubBlock);
|
||
} catch(e) {
|
||
console.warn('[调试] 选区检测失败:', e);
|
||
canHighlight = false;
|
||
}
|
||
const clickedHighlighted = !!annotationId;
|
||
console.log(`[调试] canHighlight=${canHighlight}, clickedHighlighted=${clickedHighlighted}`);
|
||
if (!canHighlight && !clickedHighlighted) {
|
||
console.log('[调试] ❌ 不显示菜单:既没有有效选区,也没有点击已有高亮');
|
||
hideContextMenu();
|
||
return; // 允许默认浏览器菜单
|
||
}
|
||
|
||
updateContextMenuOptions(isHighlighted, hasNote, false);
|
||
event.preventDefault(); // 仅在显示自定义菜单时阻止默认菜单
|
||
// 使用 clientX/clientY(相对于视口)配合 position: fixed
|
||
showContextMenu(event.clientX, event.clientY);
|
||
}, false);
|
||
}
|
||
// ...其余初始化逻辑...
|
||
annotationContextMenuElement.addEventListener('click', async (event) => {
|
||
let target = event.target;
|
||
let action, color;
|
||
|
||
// Prevent menu from closing itself if a menu item is clicked
|
||
event.stopPropagation();
|
||
|
||
if (target.classList.contains('color-option')) {
|
||
const parentLi = target.closest('li[data-action]');
|
||
if (parentLi) {
|
||
action = parentLi.dataset.action;
|
||
color = target.dataset.color;
|
||
}
|
||
} else {
|
||
const li = target.closest('li[data-action]');
|
||
if (li) {
|
||
action = li.dataset.action;
|
||
}
|
||
}
|
||
|
||
if (!action) {
|
||
hideContextMenu(); // If clicked on non-action area within menu, hide it.
|
||
return;
|
||
}
|
||
|
||
// 更新:在分块对比模式下阻止所有指定操作
|
||
if (window.currentVisibleTabId === 'chunk-compare' &&
|
||
action && //确保 action 已定义
|
||
(action === 'highlight-block' || action === 'remove-highlight' || action === 'add-note' || action === 'edit-note' || action === 'copy-content')) { // 添加 copy-content
|
||
console.warn(`[批注逻辑] 在分块对比模式下尝试执行操作 '${action}'。此操作应已被UI阻止。`);
|
||
hideContextMenu();
|
||
return; // 阻止操作
|
||
}
|
||
|
||
// ===== 新增:跨子块操作检测 =====
|
||
const isCrossBlockOperation = annotationContextMenuElement.dataset.contextIsCrossBlock === "true";
|
||
if (isCrossBlockOperation) {
|
||
return handleCrossBlockMenuAction(action, color, event);
|
||
}
|
||
|
||
const docId = getQueryParam('id');
|
||
if (!docId) {
|
||
alert('错误:无法获取文档ID。');
|
||
hideContextMenu();
|
||
return;
|
||
}
|
||
|
||
// Retrieve context from the menu's dataset
|
||
let currentContentIdentifier = annotationContextMenuElement.dataset.contextContentIdentifier
|
||
|| (window.globalCurrentSelection && window.globalCurrentSelection.contentIdentifierForSelection)
|
||
|| window.globalCurrentContentIdentifier; // 兜底
|
||
let targetIdentifier = annotationContextMenuElement.dataset.contextTargetIdentifier || (window.globalCurrentSelection && (window.globalCurrentSelection.subBlockId || window.globalCurrentSelection.blockIndex));
|
||
let identifierType = annotationContextMenuElement.dataset.contextIdentifierType || (window.globalCurrentSelection && (window.globalCurrentSelection.subBlockId ? 'subBlockId' : 'blockIndex'));
|
||
let targetAnnotationId = annotationContextMenuElement.dataset.contextAnnotationId || (window.globalCurrentSelection && window.globalCurrentSelection.annotationId);
|
||
let originalSelectedText = annotationContextMenuElement.dataset.contextSelectedText || (window.globalCurrentSelection && window.globalCurrentSelection.text);
|
||
|
||
if ((!(currentContentIdentifier && identifierType && targetIdentifier)) &&
|
||
(action === 'highlight-block' || action === 'add-note' || action === 'edit-note' || action === 'remove-highlight')) {
|
||
console.log('context debug', {currentContentIdentifier, targetIdentifier, identifierType, targetAnnotationId, windowGlobal: window.globalCurrentSelection, windowGlobalContent: window.globalCurrentContentIdentifier});
|
||
alert('请重新右键点击目标区块后再操作。');
|
||
hideContextMenu();
|
||
return;
|
||
}
|
||
const hasValidContext = targetIdentifier && identifierType;
|
||
if (!hasValidContext && (action === 'highlight-block' || action === 'add-note' || action === 'edit-note' || action === 'remove-highlight' || action === 'copy-content')) {
|
||
alert('操作目标无效。请重新右键点击目标区块。');
|
||
console.error('[批注逻辑] Context menu action failed: targetIdentifier or identifierType from menu dataset is missing.');
|
||
hideContextMenu();
|
||
return;
|
||
}
|
||
|
||
let refreshNeeded = false;
|
||
|
||
try {
|
||
if (action === 'remove-highlight') {
|
||
// identifierType is already from dataset
|
||
await removeAnnotationFromTarget(docId, targetAnnotationId, targetIdentifier, currentContentIdentifier, identifierType);
|
||
// 新增:只移除目标元素的高亮
|
||
let targetElement = null;
|
||
if (identifierType === 'subBlockId') {
|
||
const containerId = currentContentIdentifier + '-content-wrapper';
|
||
const container = document.getElementById(containerId);
|
||
if (container) {
|
||
targetElement = container.querySelector('.sub-block[data-sub-block-id="' + targetIdentifier + '"]');
|
||
}
|
||
} else if (identifierType === 'blockIndex') {
|
||
const containerId = currentContentIdentifier + '-content-wrapper';
|
||
const container = document.getElementById(containerId);
|
||
if (container) {
|
||
targetElement = container.querySelector('[data-block-index="' + targetIdentifier + '"]');
|
||
}
|
||
}
|
||
if (targetElement && window.removeHighlightFromBlockOrSubBlock) {
|
||
window.removeHighlightFromBlockOrSubBlock(targetElement);
|
||
}
|
||
// 新增:局部刷新所有高亮,保证同步
|
||
if (typeof window.applyBlockAnnotations === 'function') {
|
||
const containerId = currentContentIdentifier + '-content-wrapper';
|
||
const container = document.getElementById(containerId);
|
||
if (container) {
|
||
window.applyBlockAnnotations(container, window.data.annotations, currentContentIdentifier);
|
||
}
|
||
}
|
||
console.log(`${identifierType} 高亮已尝试取消`);
|
||
refreshNeeded = false; // 不再全量刷新
|
||
} else if (action === 'add-note' || action === 'edit-note') {
|
||
// identifierType is from dataset
|
||
const isCurrentlyHighlighted = checkIfTargetIsHighlighted(targetAnnotationId, currentContentIdentifier, targetIdentifier, identifierType);
|
||
if (!isCurrentlyHighlighted) {
|
||
alert('只能对已高亮的区块/子区块操作批注。请先高亮。');
|
||
} else {
|
||
let noteText;
|
||
let currentNoteContent = '';
|
||
if (action === 'edit-note') {
|
||
const existingAnnotation = window.data.annotations.find(a =>
|
||
a.targetType === currentContentIdentifier &&
|
||
(a.id === targetAnnotationId ||
|
||
(targetIdentifier && identifierType && a.target && a.target.selector && a.target.selector[0] &&
|
||
(a.target.selector[0][identifierType] === targetIdentifier || String(a.target.selector[0][identifierType]) === targetIdentifier)
|
||
)) &&
|
||
a.body && a.body.length > 0
|
||
);
|
||
currentNoteContent = existingAnnotation && existingAnnotation.body[0] ? existingAnnotation.body[0].value : '';
|
||
noteText = prompt("编辑批注内容:", currentNoteContent);
|
||
} else { // add-note
|
||
noteText = prompt("请输入批注内容:", "");
|
||
}
|
||
|
||
if (noteText === null) { /* User cancelled */ }
|
||
else if (noteText.trim() === '') {
|
||
alert('批注内容不能为空。');
|
||
} else {
|
||
// 新增:同步 exact 字段
|
||
let annotationToUpdate = window.data.annotations.find(a =>
|
||
a.targetType === currentContentIdentifier &&
|
||
(a.id === targetAnnotationId ||
|
||
(targetIdentifier && identifierType && a.target && a.target.selector && a.target.selector[0] &&
|
||
(a.target.selector[0][identifierType] === targetIdentifier || String(a.target.selector[0][identifierType]) === targetIdentifier)
|
||
))
|
||
);
|
||
if (annotationToUpdate && window.globalCurrentSelection && window.globalCurrentSelection.targetElement) {
|
||
annotationToUpdate.target.selector[0].exact = window.globalCurrentSelection.targetElement.textContent.trim();
|
||
}
|
||
await addNoteToAnnotation(noteText, docId, targetAnnotationId, targetIdentifier, currentContentIdentifier, identifierType);
|
||
console.log(action === 'edit-note' ? `${identifierType} 批注已更新` : `批注已添加到现有 ${identifierType} 高亮`);
|
||
refreshNeeded = true;
|
||
}
|
||
}
|
||
} else if (action === 'highlight-block') {
|
||
// 可选:禁止整块高亮(通过本地开关)
|
||
try {
|
||
const disableBlock = localStorage.getItem('DISABLE_BLOCK_HIGHLIGHT') === 'true';
|
||
if (disableBlock && identifierType === 'blockIndex') {
|
||
alert('已禁用整块高亮,请在子块内选中要高亮的文本。');
|
||
hideContextMenu();
|
||
return;
|
||
}
|
||
} catch { /* noop */ }
|
||
|
||
// 优先按当前选区的起点子块来高亮,避免“跳到上面一段”
|
||
try {
|
||
const sel = window.getSelection();
|
||
if (sel && sel.rangeCount) {
|
||
const r = sel.getRangeAt(0);
|
||
if (!r.collapsed) {
|
||
const startEl = r.startContainer.nodeType === Node.TEXT_NODE ? r.startContainer.parentElement : r.startContainer;
|
||
const subFromSelection = startEl && startEl.closest ? startEl.closest('.sub-block[data-sub-block-id]') : null;
|
||
if (subFromSelection) {
|
||
identifierType = 'subBlockId';
|
||
identifier = subFromSelection.dataset.subBlockId;
|
||
targetIdentifier = identifier;
|
||
targetElementForAnnotation = subFromSelection;
|
||
}
|
||
}
|
||
}
|
||
} catch { /* ignore */ }
|
||
// 允许未选颜色,默认黄色
|
||
if (!color) { color = 'yellow'; }
|
||
{
|
||
// 预判是否为子块内片段选择
|
||
let isSubBlockRange = false;
|
||
try {
|
||
isSubBlockRange = (identifierType === 'subBlockId' && window.globalCurrentSelection && window.globalCurrentSelection.range && window.globalCurrentSelection.targetElement);
|
||
} catch { isSubBlockRange = false; }
|
||
// ====== 修正:高亮保存前去重,保证唯一性 ======
|
||
// 先查找所有同 target 的 annotation
|
||
const duplicateAnnotations = window.data.annotations.filter(ann =>
|
||
ann.targetType === currentContentIdentifier &&
|
||
ann.target && ann.target.selector && ann.target.selector[0] &&
|
||
(ann.target.selector[0][identifierType] === targetIdentifier || String(ann.target.selector[0][identifierType]) === targetIdentifier) &&
|
||
(ann.motivation === 'highlighting' || ann.motivation === 'commenting')
|
||
);
|
||
// 子块内片段:允许同一子块多段并存,不做去重/合并
|
||
let existingAnnotationForTarget = isSubBlockRange ? null : duplicateAnnotations[0];
|
||
if (!isSubBlockRange) {
|
||
// 如果有多个,移除多余的,只保留第一个(仅限非片段场景)
|
||
if (duplicateAnnotations.length > 1) {
|
||
for (let i = 1; i < duplicateAnnotations.length; i++) {
|
||
await removeAnnotationFromTarget(docId, duplicateAnnotations[i].id, targetIdentifier, currentContentIdentifier, identifierType);
|
||
}
|
||
}
|
||
}
|
||
if (existingAnnotationForTarget) {
|
||
existingAnnotationForTarget.highlightColor = color;
|
||
existingAnnotationForTarget.modified = new Date().toISOString();
|
||
if (existingAnnotationForTarget.motivation !== 'commenting') {
|
||
existingAnnotationForTarget.motivation = 'highlighting';
|
||
}
|
||
// 如果是单子块并且存在选区,则转换/更新为区间选择
|
||
if (identifierType === 'subBlockId' && window.globalCurrentSelection && window.globalCurrentSelection.range && window.globalCurrentSelection.targetElement) {
|
||
try {
|
||
const el = window.globalCurrentSelection.targetElement;
|
||
const selRange = window.globalCurrentSelection.range;
|
||
const isInFormula = (n) => {
|
||
let p = n && (n.nodeType === Node.TEXT_NODE ? n.parentElement : n);
|
||
while (p) {
|
||
if (p.classList && (
|
||
p.classList.contains('katex') ||
|
||
p.classList.contains('katex-display') ||
|
||
p.classList.contains('katex-inline') ||
|
||
p.classList.contains('reference-citation') // 保护引用链接
|
||
)) return true;
|
||
p = p.parentElement;
|
||
}
|
||
return false;
|
||
};
|
||
const fragTextLenExclFormula = (frag) => {
|
||
const walker = document.createTreeWalker(frag, NodeFilter.SHOW_TEXT, null, false);
|
||
let len = 0, node;
|
||
while ((node = walker.nextNode())) { if (!isInFormula(node)) len += (node.nodeValue || '').length; }
|
||
return len;
|
||
};
|
||
const calcOffset = (endNode, endOffset) => {
|
||
const r = document.createRange();
|
||
r.selectNodeContents(el);
|
||
r.setEnd(endNode, endOffset);
|
||
return fragTextLenExclFormula(r.cloneContents());
|
||
};
|
||
const sOff = calcOffset(selRange.startContainer, selRange.startOffset);
|
||
const eOff = calcOffset(selRange.endContainer, selRange.endOffset);
|
||
const startOffset = Math.max(0, Math.min(sOff, eOff));
|
||
const endOffset = Math.max(0, Math.max(sOff, eOff));
|
||
const exactSel = (window.globalCurrentSelection.text || '').trim();
|
||
if (!existingAnnotationForTarget.target.selector[0] || existingAnnotationForTarget.target.selector[0].type !== 'SubBlockRangeSelector') {
|
||
existingAnnotationForTarget.target.selector[0] = { type: 'SubBlockRangeSelector', subBlockId: targetIdentifier };
|
||
}
|
||
existingAnnotationForTarget.target.selector[0].startOffset = startOffset;
|
||
existingAnnotationForTarget.target.selector[0].endOffset = endOffset;
|
||
if (exactSel) existingAnnotationForTarget.target.selector[0].exact = exactSel;
|
||
} catch (e) { /* ignore and fallback */ }
|
||
} else if (window.globalCurrentSelection && window.globalCurrentSelection.targetElement) {
|
||
// 同步 exact 字段(整块高亮)
|
||
existingAnnotationForTarget.target.selector[0].exact = window.globalCurrentSelection.targetElement.textContent.trim();
|
||
}
|
||
await updateAnnotationInDB(existingAnnotationForTarget);
|
||
// 新增:只高亮目标元素
|
||
let targetElement = null;
|
||
if (identifierType === 'subBlockId') {
|
||
const containerId = currentContentIdentifier + '-content-wrapper';
|
||
const container = document.getElementById(containerId);
|
||
if (container) {
|
||
targetElement = container.querySelector('.sub-block[data-sub-block-id="' + targetIdentifier + '"]');
|
||
}
|
||
} else if (identifierType === 'blockIndex') {
|
||
const containerId = currentContentIdentifier + '-content-wrapper';
|
||
const container = document.getElementById(containerId);
|
||
if (container) {
|
||
targetElement = container.querySelector('[data-block-index="' + targetIdentifier + '"]');
|
||
}
|
||
}
|
||
if (targetElement && window.highlightBlockOrSubBlock) {
|
||
window.highlightBlockOrSubBlock(targetElement, existingAnnotationForTarget, currentContentIdentifier, targetIdentifier, identifierType === 'subBlockId' ? 'subBlock' : 'block');
|
||
}
|
||
console.log(`${identifierType} 高亮颜色已更新:`, existingAnnotationForTarget);
|
||
refreshNeeded = true;
|
||
} else {
|
||
const newAnnotation = {
|
||
'@context': 'http://www.w3.org/ns/anno.jsonld',
|
||
id: 'urn:uuid:' + _page_generateUUID(),
|
||
type: 'Annotation',
|
||
motivation: 'highlighting',
|
||
created: new Date().toISOString(),
|
||
docId: docId,
|
||
targetType: currentContentIdentifier,
|
||
highlightColor: color,
|
||
target: {
|
||
source: docId,
|
||
selector: [{
|
||
type: identifierType === 'subBlockId' ? 'SubBlockSelector' : 'BlockSelector',
|
||
}]
|
||
},
|
||
body: []
|
||
};
|
||
newAnnotation.target.selector[0][identifierType] = targetIdentifier;
|
||
// 单子块 + 存在选区:改为区间选择
|
||
if (identifierType === 'subBlockId' && window.globalCurrentSelection && window.globalCurrentSelection.range && window.globalCurrentSelection.targetElement) {
|
||
try {
|
||
const el = window.globalCurrentSelection.targetElement;
|
||
const selRange = window.globalCurrentSelection.range;
|
||
const isInFormula = (n) => {
|
||
let p = n && (n.nodeType === Node.TEXT_NODE ? n.parentElement : n);
|
||
while (p) {
|
||
if (p.classList && (
|
||
p.classList.contains('katex') ||
|
||
p.classList.contains('katex-display') ||
|
||
p.classList.contains('katex-inline') ||
|
||
p.classList.contains('reference-citation') // 保护引用链接
|
||
)) return true;
|
||
p = p.parentElement;
|
||
}
|
||
return false;
|
||
};
|
||
const fragTextLenExclFormula = (frag) => {
|
||
const walker = document.createTreeWalker(frag, NodeFilter.SHOW_TEXT, null, false);
|
||
let len = 0, node;
|
||
while ((node = walker.nextNode())) { if (!isInFormula(node)) len += (node.nodeValue || '').length; }
|
||
return len;
|
||
};
|
||
const calcOffset = (endNode, endOffset) => {
|
||
const r = document.createRange();
|
||
r.selectNodeContents(el);
|
||
r.setEnd(endNode, endOffset);
|
||
return fragTextLenExclFormula(r.cloneContents());
|
||
};
|
||
const sOff = calcOffset(selRange.startContainer, selRange.startOffset);
|
||
const eOff = calcOffset(selRange.endContainer, selRange.endOffset);
|
||
const startOffset = Math.max(0, Math.min(sOff, eOff));
|
||
const endOffset = Math.max(0, Math.max(sOff, eOff));
|
||
const exactSel = (window.globalCurrentSelection.text || '').trim();
|
||
newAnnotation.target.selector[0] = {
|
||
type: 'SubBlockRangeSelector',
|
||
subBlockId: targetIdentifier,
|
||
startOffset: startOffset,
|
||
endOffset: endOffset,
|
||
exact: exactSel
|
||
};
|
||
} catch (e) {
|
||
// 回退整块
|
||
if (window.globalCurrentSelection && window.globalCurrentSelection.targetElement) {
|
||
newAnnotation.target.selector[0].exact = window.globalCurrentSelection.targetElement.textContent.trim();
|
||
} else if (originalSelectedText) {
|
||
newAnnotation.target.selector[0].exact = originalSelectedText;
|
||
}
|
||
}
|
||
} else {
|
||
// 新建时写入 exact(整块)
|
||
if (window.globalCurrentSelection && window.globalCurrentSelection.targetElement) {
|
||
newAnnotation.target.selector[0].exact = window.globalCurrentSelection.targetElement.textContent.trim();
|
||
} else if (originalSelectedText) {
|
||
newAnnotation.target.selector[0].exact = originalSelectedText;
|
||
}
|
||
}
|
||
const contextBlockIndex = annotationContextMenuElement.dataset.contextBlockIndex;
|
||
if (identifierType === 'subBlockId' && contextBlockIndex) {
|
||
newAnnotation.target.selector[0].blockIndex = contextBlockIndex;
|
||
}
|
||
await saveAnnotationToDB(newAnnotation);
|
||
if (!window.data.annotations) window.data.annotations = [];
|
||
window.data.annotations.push(newAnnotation);
|
||
// 新增:只高亮目标元素
|
||
let targetElement = null;
|
||
if (identifierType === 'subBlockId') {
|
||
const containerId = currentContentIdentifier + '-content-wrapper';
|
||
const container = document.getElementById(containerId);
|
||
if (container) {
|
||
targetElement = container.querySelector('.sub-block[data-sub-block-id="' + targetIdentifier + '"]');
|
||
}
|
||
} else if (identifierType === 'blockIndex') {
|
||
const containerId = currentContentIdentifier + '-content-wrapper';
|
||
const container = document.getElementById(containerId);
|
||
if (container) {
|
||
targetElement = container.querySelector('[data-block-index="' + targetIdentifier + '"]');
|
||
}
|
||
}
|
||
if (targetElement && window.highlightBlockOrSubBlock) {
|
||
window.highlightBlockOrSubBlock(targetElement, newAnnotation, currentContentIdentifier, targetIdentifier, identifierType === 'subBlockId' ? 'subBlock' : 'block');
|
||
}
|
||
refreshNeeded = true;
|
||
console.log(`新 ${identifierType} 高亮已保存:`, newAnnotation);
|
||
}
|
||
refreshNeeded = false; // 不再全量刷新
|
||
}
|
||
} else if (action === 'copy-content') {
|
||
let textToCopy = originalSelectedText; // Default to textContent
|
||
const contextBlockIndex = annotationContextMenuElement.dataset.contextBlockIndex;
|
||
// 唯一子块判断逻辑修正
|
||
if (identifierType === 'blockIndex' && currentContentIdentifier && targetIdentifier) {
|
||
const blockIndex = parseInt(targetIdentifier, 10);
|
||
if (!isNaN(blockIndex) &&
|
||
window.currentBlockTokensForCopy &&
|
||
window.currentBlockTokensForCopy[currentContentIdentifier] &&
|
||
window.currentBlockTokensForCopy[currentContentIdentifier][blockIndex] &&
|
||
typeof window.currentBlockTokensForCopy[currentContentIdentifier][blockIndex].raw === 'string') {
|
||
textToCopy = window.currentBlockTokensForCopy[currentContentIdentifier][blockIndex].raw;
|
||
console.log(`[批注逻辑] 复制块级内容: 使用来自 currentBlockTokensForCopy 的原始 Markdown (块索引: ${blockIndex})。`);
|
||
} else {
|
||
console.warn(`[批注逻辑] 复制块级内容: 无法从 currentBlockTokensForCopy 获取原始 Markdown (块索引: ${blockIndex}),回退到 textContent。`);
|
||
}
|
||
} else if (identifierType === 'subBlockId' && currentContentIdentifier && contextBlockIndex) {
|
||
// 统计 annotation 里所有属于该父块的唯一子块
|
||
const parentBlockIndex = parseInt(contextBlockIndex, 10);
|
||
const allSubBlockIds = window.data.annotations
|
||
.map(a => a.target && a.target.selector && a.target.selector[0] && a.target.selector[0].subBlockId)
|
||
.filter(id => id && id.startsWith(`${parentBlockIndex}.`));
|
||
const uniqueSubBlockIds = Array.from(new Set(allSubBlockIds));
|
||
if (uniqueSubBlockIds.length === 1 &&
|
||
window.currentBlockTokensForCopy &&
|
||
window.currentBlockTokensForCopy[currentContentIdentifier] &&
|
||
window.currentBlockTokensForCopy[currentContentIdentifier][parentBlockIndex] &&
|
||
typeof window.currentBlockTokensForCopy[currentContentIdentifier][parentBlockIndex].raw === 'string') {
|
||
textToCopy = window.currentBlockTokensForCopy[currentContentIdentifier][parentBlockIndex].raw;
|
||
console.log(`[批注逻辑] 复制唯一的子块: 使用其父块的原始 Markdown (父块索引: ${parentBlockIndex})。`);
|
||
} else {
|
||
// 不是唯一子块,或无法获取父块内容,回退到子块的 textContent
|
||
console.log(`[批注逻辑] 复制子块 (非唯一或无父块信息) 或其他内容: 使用 textContent。`);
|
||
// textToCopy remains originalSelectedText (sub-block's textContent)
|
||
}
|
||
} else {
|
||
// 其它情况
|
||
console.log(`[批注逻辑] 复制子块 (非唯一或无父块信息) 或其他内容: 使用 textContent。`);
|
||
// textToCopy remains originalSelectedText (textContent)
|
||
}
|
||
|
||
if (!textToCopy) { // originalSelectedText could be empty if target has no text
|
||
alert('没有可选择的内容进行复制。');
|
||
} else {
|
||
navigator.clipboard.writeText(textToCopy)
|
||
.then(() => {
|
||
console.log(`文本已复制 (来源: ${identifierType === 'blockIndex' ? '原始Markdown或textContent' : 'textContent'}): ${String(textToCopy).substring(0,50)}...`);
|
||
// alert('内容已复制!'); // Optional
|
||
})
|
||
.catch(err => {
|
||
console.error(`复制失败:`, err);
|
||
alert('复制内容失败。');
|
||
});
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error(`[批注系统] 操作 '${action}' 失败:`, error);
|
||
alert(`操作失败: ${error.message}`);
|
||
} finally {
|
||
hideContextMenu(); // Always hide menu after action or error
|
||
if (refreshNeeded) {
|
||
// ========== 优化:只局部刷新高亮和批注事件 ==========
|
||
// 只在 OCR/translation tab 下局部刷新,不再全量 showTab
|
||
const tab = window.currentVisibleTabId;
|
||
let containerId = null;
|
||
let contentIdentifier = null;
|
||
if (tab === 'ocr') {
|
||
containerId = 'ocr-content-wrapper';
|
||
contentIdentifier = 'ocr';
|
||
} else if (tab === 'translation') {
|
||
containerId = 'translation-content-wrapper';
|
||
contentIdentifier = 'translation';
|
||
}
|
||
if (containerId && typeof window.applyBlockAnnotations === 'function') {
|
||
const container = document.getElementById(containerId);
|
||
if (container) {
|
||
window.applyBlockAnnotations(container, window.data.annotations, contentIdentifier);
|
||
}
|
||
}
|
||
if (containerId && typeof window.addAnnotationListenersToContainer === 'function') {
|
||
window.addAnnotationListenersToContainer(containerId, contentIdentifier);
|
||
}
|
||
// Dock/TOC统计也可局部刷新(可选)
|
||
if (window.DockLogic && typeof window.DockLogic.updateStats === 'function') {
|
||
window.DockLogic.updateStats(window.data, window.currentVisibleTabId);
|
||
}
|
||
if (typeof window.refreshTocList === 'function') {
|
||
window.refreshTocList();
|
||
}
|
||
if(typeof window.updateReadingProgress === 'function') window.updateReadingProgress();
|
||
// =====================================================
|
||
// 只有在内容结构变化时才需要全量 showTab
|
||
// if (typeof window.showTab === 'function' && window.currentVisibleTabId) {
|
||
// const currentScroll = document.documentElement.scrollTop || document.body.scrollTop;
|
||
// await Promise.resolve(window.showTab(window.currentVisibleTabId));
|
||
// requestAnimationFrame(() => {
|
||
// document.documentElement.scrollTop = document.body.scrollTop = currentScroll;
|
||
// if(typeof window.updateReadingProgress === 'function') window.updateReadingProgress();
|
||
// });
|
||
// } else {
|
||
// console.warn("[批注系统] window.showTab 或 window.currentVisibleTabId 不可用,无法自动刷新视图。");
|
||
// }
|
||
}
|
||
}
|
||
});
|
||
|
||
document.addEventListener('click', (event) => {
|
||
if (annotationContextMenuElement && annotationContextMenuElement.classList.contains('context-menu-visible') &&
|
||
!annotationContextMenuElement.contains(event.target)) {
|
||
if (event.target.classList.contains('color-option')) return;
|
||
hideContextMenu();
|
||
}
|
||
});
|
||
// 额外:滚动/窗口变化/Esc 时隐藏菜单,避免“菜单残留”
|
||
try {
|
||
document.addEventListener('scroll', hideContextMenu, true);
|
||
window.addEventListener('resize', hideContextMenu);
|
||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideContextMenu(); }, true);
|
||
} catch { /* noop */ }
|
||
// console.log("[批注系统] 事件监听器已添加 (子块/块级模式)。");
|
||
}
|
||
|
||
// ===== 新增:跨子块选择检测函数 =====
|
||
function detectCrossBlockSelection() {
|
||
const selection = window.getSelection();
|
||
console.log('[跨子块检测] 当前选区:', selection);
|
||
console.log('[跨子块检测] 选区范围数:', selection.rangeCount);
|
||
|
||
if (!selection.rangeCount) {
|
||
console.log('[跨子块检测] 没有选区范围');
|
||
return { isCrossBlock: false };
|
||
}
|
||
|
||
const range = selection.getRangeAt(0);
|
||
console.log('[跨子块检测] 选区范围:', range);
|
||
console.log('[跨子块检测] 选区是否折叠:', range.collapsed);
|
||
console.log('[跨子块检测] 选中文本:', selection.toString());
|
||
|
||
if (range.collapsed) {
|
||
console.log('[跨子块检测] 选区已折叠,不是有效选择');
|
||
return { isCrossBlock: false };
|
||
}
|
||
|
||
// 检测选择范围是否跨越多个子块
|
||
const startContainer = range.startContainer;
|
||
const endContainer = range.endContainer;
|
||
|
||
console.log('[跨子块检测] 开始容器:', startContainer);
|
||
console.log('[跨子块检测] 结束容器:', endContainer);
|
||
|
||
// 辅助函数:获取元素在父元素中的文本偏移(忽略公式内部文本)
|
||
const getTextOffsetInElement = (element, parentElement) => {
|
||
let offset = 0;
|
||
const isFormulaNode = (n) => {
|
||
let p = n && (n.nodeType === Node.TEXT_NODE ? n.parentElement : n);
|
||
while (p) {
|
||
if (p.classList && (p.classList.contains('katex') || p.classList.contains('katex-display') || p.classList.contains('katex-inline'))) return true;
|
||
p = p.parentElement;
|
||
}
|
||
return false;
|
||
};
|
||
const walker = document.createTreeWalker(parentElement, NodeFilter.SHOW_TEXT, null, false);
|
||
let node;
|
||
while ((node = walker.nextNode())) {
|
||
if (node === element || node.parentElement === element) break;
|
||
if (!isFormulaNode(node)) offset += (node.textContent || '').length;
|
||
}
|
||
return offset;
|
||
};
|
||
|
||
// 辅助函数:根据文本偏移找到对应的子块
|
||
const findSubBlockByTextOffset = (blockElement, textOffset) => {
|
||
const subBlocks = blockElement.querySelectorAll('.sub-block[data-sub-block-id]');
|
||
let currentOffset = 0;
|
||
|
||
for (const subBlock of subBlocks) {
|
||
const subBlockTextLength = subBlock.textContent.length;
|
||
if (textOffset >= currentOffset && textOffset < currentOffset + subBlockTextLength) {
|
||
return subBlock;
|
||
}
|
||
currentOffset += subBlockTextLength;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
// 改进:更准确地找到包含的子块或块级元素
|
||
const findParentSubBlock = (node, debugPrefix = '') => {
|
||
// 如果是文本节点,从父元素开始查找
|
||
let element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
|
||
// 若起点在公式内部,先提升到公式容器,以保证后续最近子块判定稳定
|
||
const formulaContainer = element.closest && element.closest('.katex, .katex-display, .katex-inline');
|
||
if (formulaContainer) {
|
||
element = formulaContainer;
|
||
}
|
||
|
||
// 查找所属的块级元素,用于调试
|
||
const blockElement = element.closest('[data-block-index]');
|
||
console.log(`[跨子块检测] ${debugPrefix}查找子块,起始元素:`, element);
|
||
console.log(`[跨子块检测] ${debugPrefix}元素标签:`, element.tagName);
|
||
console.log(`[跨子块检测] ${debugPrefix}所属段落:`, blockElement?.dataset?.blockIndex || '未找到');
|
||
|
||
// 首先查找最近的子块
|
||
const subBlock = element.closest('.sub-block[data-sub-block-id]');
|
||
console.log(`[跨子块检测] ${debugPrefix}找到的子块:`, subBlock?.dataset?.subBlockId || 'null');
|
||
|
||
if (subBlock) {
|
||
return subBlock;
|
||
}
|
||
|
||
// 如果没找到子块,查找块级元素
|
||
console.log(`[跨子块检测] ${debugPrefix}找到的块级元素:`, blockElement);
|
||
|
||
if (blockElement) {
|
||
// 检查这个块是否已经被分段成子块
|
||
const childSubBlocks = blockElement.querySelectorAll('.sub-block[data-sub-block-id]');
|
||
console.log('[跨子块检测] 块级元素的子块数量:', childSubBlocks.length);
|
||
|
||
if (childSubBlocks.length > 0) {
|
||
// 如果有子块,需要确定具体是哪个子块
|
||
// 根据selection的位置来判断
|
||
const textOffset = getTextOffsetInElement(element, blockElement);
|
||
const targetSubBlock = findSubBlockByTextOffset(blockElement, textOffset);
|
||
console.log('[跨子块检测] 根据文本偏移找到的子块:', targetSubBlock);
|
||
return targetSubBlock || childSubBlocks[0]; // 如果找不到就返回第一个
|
||
} else {
|
||
// 如果没有子块,优先尝试自动分段(支持英文)
|
||
if (window.SubBlockSegmenter && typeof window.SubBlockSegmenter.segment === 'function') {
|
||
try {
|
||
console.log('[跨子块检测] 块级元素未分段,触发针对该块的自动分段 (force=true)');
|
||
// 在分段之前先计算选区在该块内的文本偏移
|
||
const preTextOffset = getTextOffsetInElement(element, blockElement);
|
||
window.SubBlockSegmenter.segment(blockElement, blockElement.dataset.blockIndex, true);
|
||
const childAfter = blockElement.querySelectorAll('.sub-block[data-sub-block-id]');
|
||
console.log('[跨子块检测] 自动分段后子块数量:', childAfter.length);
|
||
if (childAfter.length > 0) {
|
||
// 移除之前可能打在块上的虚拟 subBlockId,避免与真实子块冲突
|
||
if (blockElement._virtualSubBlockId) delete blockElement._virtualSubBlockId;
|
||
if (blockElement.dataset && blockElement.dataset.subBlockId) delete blockElement.dataset.subBlockId;
|
||
// 使用分段前计算的偏移,映射到具体子块
|
||
const targetSubBlock2 = findSubBlockByTextOffset(blockElement, preTextOffset);
|
||
console.log('[跨子块检测] 自动分段后根据偏移找到的子块:', targetSubBlock2);
|
||
return targetSubBlock2 || childAfter[0];
|
||
}
|
||
} catch (e) {
|
||
console.warn('[跨子块检测] 单块自动分段失败:', e);
|
||
}
|
||
}
|
||
// 仍无子块,创建虚拟子块标识
|
||
console.log(`[跨子块检测] ${debugPrefix}块级元素未分段,创建虚拟子块标识`);
|
||
|
||
// 改进:确保虚拟子块ID的唯一性
|
||
const proposedId = blockElement.dataset.blockIndex + '.0';
|
||
|
||
// 检查是否已经被标记过(避免重复标记)
|
||
if (!blockElement.dataset.subBlockId) {
|
||
blockElement._virtualSubBlockId = proposedId;
|
||
blockElement.dataset.subBlockId = proposedId;
|
||
console.log(`[跨子块检测] ${debugPrefix}创建虚拟子块ID: ${proposedId}`);
|
||
} else {
|
||
console.log(`[跨子块检测] ${debugPrefix}块级元素已有子块ID: ${blockElement.dataset.subBlockId}`);
|
||
}
|
||
return blockElement;
|
||
}
|
||
}
|
||
|
||
// 调试:查看父元素层次
|
||
let parent = element;
|
||
let level = 0;
|
||
while (parent && level < 5) {
|
||
console.log(`[跨子块检测] 父元素层次${level}:`, parent.tagName, parent.className, parent.dataset);
|
||
parent = parent.parentElement;
|
||
level++;
|
||
}
|
||
|
||
return null;
|
||
};
|
||
|
||
const startSubBlock = findParentSubBlock(startContainer, '开始容器-');
|
||
const endSubBlock = findParentSubBlock(endContainer, '结束容器-');
|
||
|
||
console.log('[跨子块检测] 开始子块:', startSubBlock);
|
||
console.log('[跨子块检测] 结束子块:', endSubBlock);
|
||
console.log('[跨子块检测] 开始子块ID:', startSubBlock?.dataset?.subBlockId);
|
||
console.log('[跨子块检测] 结束子块ID:', endSubBlock?.dataset?.subBlockId);
|
||
|
||
if (!startSubBlock || !endSubBlock) {
|
||
console.log('[跨子块检测] 找不到开始或结束子块');
|
||
return { isCrossBlock: false };
|
||
}
|
||
|
||
// 比较子块标识符而不是DOM元素
|
||
const startId = startSubBlock.dataset.subBlockId || startSubBlock._virtualSubBlockId;
|
||
const endId = endSubBlock.dataset.subBlockId || endSubBlock._virtualSubBlockId;
|
||
|
||
console.log('[跨子块检测] 开始子块ID:', startId);
|
||
console.log('[跨子块检测] 结束子块ID:', endId);
|
||
|
||
// 改进:更严格的跨子块判断逻辑
|
||
if (startId !== endId) {
|
||
// 跨子块选择
|
||
console.log('[跨子块检测] ✅ 检测到跨子块选择!');
|
||
const affectedSubBlocks = getSubBlocksInRange(range, startSubBlock, endSubBlock);
|
||
console.log('[跨子块检测] 影响的子块:', affectedSubBlocks);
|
||
return {
|
||
isCrossBlock: true,
|
||
startSubBlock: startSubBlock,
|
||
endSubBlock: endSubBlock,
|
||
affectedSubBlocks: affectedSubBlocks,
|
||
selectedText: selection.toString(),
|
||
range: range
|
||
};
|
||
}
|
||
|
||
// 额外检查:即使子块ID相同,也要检查是否真的是同一个DOM元素
|
||
if (startSubBlock !== endSubBlock) {
|
||
console.log('[跨子块检测] ✅ 检测到跨DOM元素选择(子块ID相同但DOM不同)!');
|
||
console.log('[跨子块检测] 开始DOM:', startSubBlock);
|
||
console.log('[跨子块检测] 结束DOM:', endSubBlock);
|
||
|
||
// 这种情况说明有问题,但仍然按跨子块处理
|
||
const affectedSubBlocks = getSubBlocksInRange(range, startSubBlock, endSubBlock);
|
||
console.log('[跨子块检测] 影响的子块:', affectedSubBlocks);
|
||
return {
|
||
isCrossBlock: true,
|
||
startSubBlock: startSubBlock,
|
||
endSubBlock: endSubBlock,
|
||
affectedSubBlocks: affectedSubBlocks,
|
||
selectedText: selection.toString(),
|
||
range: range
|
||
};
|
||
}
|
||
|
||
console.log('[跨子块检测] 选择在同一个子块内');
|
||
return { isCrossBlock: false };
|
||
}
|
||
|
||
// ===== 新增:获取范围内的所有子块 =====
|
||
function getSubBlocksInRange(range, startSubBlock, endSubBlock) {
|
||
const subBlocks = [];
|
||
const commonAncestor = range.commonAncestorContainer;
|
||
const container = commonAncestor.nodeType === Node.TEXT_NODE ? commonAncestor.parentElement : commonAncestor;
|
||
|
||
console.log('[获取范围内子块] 公共祖先容器:', container);
|
||
console.log('[获取范围内子块] 开始子块:', startSubBlock);
|
||
console.log('[获取范围内子块] 结束子块:', endSubBlock);
|
||
|
||
// 获取开始和结束子块的ID
|
||
const startId = startSubBlock.dataset.subBlockId || startSubBlock._virtualSubBlockId;
|
||
const endId = endSubBlock.dataset.subBlockId || endSubBlock._virtualSubBlockId;
|
||
|
||
console.log('[获取范围内子块] 开始子块ID:', startId);
|
||
console.log('[获取范围内子块] 结束子块ID:', endId);
|
||
|
||
// 首先确保包含开始和结束子块
|
||
if (startId) {
|
||
subBlocks.push({
|
||
element: startSubBlock,
|
||
subBlockId: startId,
|
||
text: startSubBlock.textContent || '',
|
||
isFullySelected: false,
|
||
isVirtual: !startSubBlock.classList.contains('sub-block')
|
||
});
|
||
console.log('[获取范围内子块] 添加开始子块:', startId);
|
||
}
|
||
|
||
if (endId && endId !== startId) {
|
||
subBlocks.push({
|
||
element: endSubBlock,
|
||
subBlockId: endId,
|
||
text: endSubBlock.textContent || '',
|
||
isFullySelected: false,
|
||
isVirtual: !endSubBlock.classList.contains('sub-block')
|
||
});
|
||
console.log('[获取范围内子块] 添加结束子块:', endId);
|
||
}
|
||
|
||
// 查找中间的子块(优先真实子块,否则按块级虚拟子块)
|
||
const allSubBlocks = container.querySelectorAll('.sub-block[data-sub-block-id]');
|
||
console.log('[获取范围内子块] 找到的真实子块数量:', allSubBlocks.length);
|
||
|
||
if (allSubBlocks.length > 0) {
|
||
// 使用更准确的范围检测:先添加所有真实子块
|
||
for (const subBlock of allSubBlocks) {
|
||
const subBlockId = subBlock.dataset.subBlockId;
|
||
|
||
// 跳过已经添加的开始和结束子块
|
||
if (subBlockId === startId || subBlockId === endId) {
|
||
continue;
|
||
}
|
||
|
||
// 检查子块是否在选择范围内
|
||
if (range.intersectsNode(subBlock)) {
|
||
subBlocks.push({
|
||
element: subBlock,
|
||
subBlockId: subBlockId,
|
||
text: subBlock.textContent || '',
|
||
isFullySelected: range.containsNode ? range.containsNode(subBlock) : false
|
||
});
|
||
console.log('[获取范围内子块] 添加中间子块(真实):', subBlockId);
|
||
}
|
||
}
|
||
|
||
// 同时补充:对范围内“没有真实子块”的段落,创建虚拟子块,避免中间段落遗漏
|
||
const top = container.closest && (container.closest('#ocr-content-wrapper, #translation-content-wrapper') || container.closest('[data-block-index]')?.parentElement) || document;
|
||
const allBlocksInside = top.querySelectorAll('[data-block-index]');
|
||
const startBlockEl = startSubBlock.closest('[data-block-index]') || startSubBlock;
|
||
const endBlockEl = endSubBlock.closest('[data-block-index]') || endSubBlock;
|
||
const startIdxNum = parseInt(startBlockEl.dataset.blockIndex, 10);
|
||
const endIdxNum = parseInt(endBlockEl.dataset.blockIndex, 10);
|
||
const lowIdx = Math.min(startIdxNum, endIdxNum);
|
||
const highIdx = Math.max(startIdxNum, endIdxNum);
|
||
|
||
allBlocksInside.forEach(blockEl => {
|
||
const bi = parseInt(blockEl.dataset.blockIndex, 10);
|
||
if (isNaN(bi) || bi < lowIdx || bi > highIdx) return;
|
||
if (!range.intersectsNode(blockEl)) return;
|
||
|
||
const childSbs = blockEl.querySelectorAll('.sub-block[data-sub-block-id]');
|
||
const hasRealSubBlocks = childSbs.length > 0;
|
||
const hasAnyAdded = subBlocks.some(sb => sb.subBlockId && String(sb.subBlockId).startsWith(String(bi) + '.'));
|
||
const isStartOrEnd = (String(bi) + '.0' === startId) || (String(bi) + '.0' === endId);
|
||
|
||
if (!hasRealSubBlocks && !hasAnyAdded) {
|
||
// 为没有真实子块的段落创建虚拟子块
|
||
const virtualId = String(bi) + '.0';
|
||
if (!isStartOrEnd) {
|
||
blockEl._virtualSubBlockId = virtualId;
|
||
blockEl.dataset.subBlockId = virtualId;
|
||
}
|
||
subBlocks.push({
|
||
element: blockEl,
|
||
subBlockId: virtualId,
|
||
text: blockEl.textContent || '',
|
||
isFullySelected: range.containsNode ? range.containsNode(blockEl) : false,
|
||
isVirtual: true
|
||
});
|
||
console.log('[获取范围内子块] 添加中间子块(虚拟,无真实子块的段落):', virtualId);
|
||
}
|
||
});
|
||
} else {
|
||
// 没有真实子块:按块级元素范围生成虚拟子块,确保中间块不会漏掉
|
||
console.log('[获取范围内子块] 无真实子块,采用块级虚拟子块遍历');
|
||
// 尝试找到更高的容器(如 ocr/translation 包裹)
|
||
let topContainer = container.closest && (container.closest('#ocr-content-wrapper, #translation-content-wrapper') || container.closest('[data-block-index]')?.parentElement) || document;
|
||
const allBlocks = topContainer.querySelectorAll('[data-block-index]');
|
||
console.log('[获取范围内子块] 块级元素数量:', allBlocks.length);
|
||
|
||
// 获取起止 blockIndex
|
||
const startBlockEl = startSubBlock.closest('[data-block-index]') || startSubBlock;
|
||
const endBlockEl = endSubBlock.closest('[data-block-index]') || endSubBlock;
|
||
const startIdx = parseInt(startBlockEl.dataset.blockIndex, 10);
|
||
const endIdx = parseInt(endBlockEl.dataset.blockIndex, 10);
|
||
const low = Math.min(startIdx, endIdx);
|
||
const high = Math.max(startIdx, endIdx);
|
||
|
||
allBlocks.forEach(blockEl => {
|
||
const bi = parseInt(blockEl.dataset.blockIndex, 10);
|
||
if (isNaN(bi) || bi < low || bi > high) return;
|
||
if (!range.intersectsNode(blockEl)) return;
|
||
|
||
// 如果已有真实子块(某些页面后续会动态分割),优先真实子块
|
||
const subBlocksOfBlock = blockEl.querySelectorAll('.sub-block[data-sub-block-id]');
|
||
if (subBlocksOfBlock.length > 0) {
|
||
subBlocksOfBlock.forEach(sb => {
|
||
const id = sb.dataset.subBlockId;
|
||
if (id === startId || id === endId) return;
|
||
subBlocks.push({
|
||
element: sb,
|
||
subBlockId: id,
|
||
text: sb.textContent || '',
|
||
isFullySelected: range.containsNode ? range.containsNode(sb) : false
|
||
});
|
||
console.log('[获取范围内子块] 添加中间子块(真实):', id);
|
||
});
|
||
} else {
|
||
// 创建虚拟子块ID:blockIndex.0
|
||
const virtualId = blockEl.dataset.blockIndex + '.0';
|
||
if (virtualId === startId || virtualId === endId) return;
|
||
blockEl._virtualSubBlockId = virtualId;
|
||
blockEl.dataset.subBlockId = virtualId;
|
||
subBlocks.push({
|
||
element: blockEl,
|
||
subBlockId: virtualId,
|
||
text: blockEl.textContent || '',
|
||
isFullySelected: range.containsNode ? range.containsNode(blockEl) : false,
|
||
isVirtual: true
|
||
});
|
||
console.log('[获取范围内子块] 添加中间子块(虚拟):', virtualId);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 去重
|
||
const uniqueMap = new Map();
|
||
subBlocks.forEach(sb => {
|
||
if (!uniqueMap.has(sb.subBlockId)) uniqueMap.set(sb.subBlockId, sb);
|
||
});
|
||
let uniqueSubBlocks = Array.from(uniqueMap.values());
|
||
|
||
// 按文档顺序排序,确保从起点到终点连续
|
||
uniqueSubBlocks.sort((a, b) => {
|
||
if (a.element === b.element) return 0;
|
||
const pos = a.element.compareDocumentPosition(b.element);
|
||
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
|
||
if (pos & Node.DOCUMENT_POSITION_PRECEDING) return 1;
|
||
return 0;
|
||
});
|
||
|
||
console.log('[获取范围内子块] 最终结果(文档顺序):', uniqueSubBlocks);
|
||
return uniqueSubBlocks;
|
||
}
|
||
|
||
// ===== 新增:处理跨子块标注 =====
|
||
function handleCrossBlockAnnotation(event, crossBlockInfo) {
|
||
event.preventDefault();
|
||
|
||
console.log(`[跨子块标注] 检测到跨子块选择,涉及 ${crossBlockInfo.affectedSubBlocks.length} 个子块`);
|
||
|
||
// 新增:判断是否为只读视图 (分块对比模式)
|
||
const isReadOnlyView = window.currentVisibleTabId === 'chunk-compare';
|
||
if (isReadOnlyView) {
|
||
hideContextMenu();
|
||
return;
|
||
}
|
||
|
||
// 生成跨子块标注ID
|
||
const crossBlockAnnotationId = 'cross-' + _page_generateUUID();
|
||
|
||
// 设置全局选择信息
|
||
window.globalCurrentSelection = {
|
||
text: crossBlockInfo.selectedText,
|
||
range: crossBlockInfo.range.cloneRange(),
|
||
isCrossBlock: true,
|
||
crossBlockAnnotationId: crossBlockAnnotationId,
|
||
affectedSubBlocks: crossBlockInfo.affectedSubBlocks,
|
||
startSubBlock: crossBlockInfo.startSubBlock,
|
||
endSubBlock: crossBlockInfo.endSubBlock,
|
||
contentIdentifierForSelection: window.globalCurrentContentIdentifier,
|
||
targetElement: crossBlockInfo.startSubBlock // 使用起始子块作为代表
|
||
};
|
||
|
||
// 在上下文菜单上存储信息
|
||
annotationContextMenuElement.dataset.contextContentIdentifier = window.globalCurrentContentIdentifier;
|
||
annotationContextMenuElement.dataset.contextIsCrossBlock = "true";
|
||
annotationContextMenuElement.dataset.contextCrossBlockAnnotationId = crossBlockAnnotationId;
|
||
annotationContextMenuElement.dataset.contextSelectedText = crossBlockInfo.selectedText;
|
||
annotationContextMenuElement.dataset.contextAffectedSubBlocks = JSON.stringify(
|
||
crossBlockInfo.affectedSubBlocks.map(sb => sb.subBlockId)
|
||
);
|
||
|
||
// 检查是否已经有跨子块标注
|
||
const isHighlighted = checkCrossBlockHighlight(crossBlockInfo.affectedSubBlocks);
|
||
const hasNote = checkCrossBlockNote(crossBlockInfo.affectedSubBlocks);
|
||
|
||
console.log(`[跨子块标注] 高亮状态: ${isHighlighted}, 有批注: ${hasNote}`);
|
||
|
||
window.globalCurrentHighlightStatus = isHighlighted;
|
||
updateCrossBlockContextMenuOptions(isHighlighted, hasNote);
|
||
// 使用 clientX/clientY(相对于视口)配合 position: fixed
|
||
showContextMenu(event.clientX, event.clientY);
|
||
}
|
||
|
||
// ===== 修改:检查跨子块高亮状态 =====
|
||
function checkCrossBlockHighlight(affectedSubBlocks) {
|
||
if (!window.data || !window.data.annotations) return false;
|
||
|
||
const affectedSubBlockIds = affectedSubBlocks.map(sb => sb.subBlockId);
|
||
|
||
// 查找跨子块标注
|
||
const crossBlockAnnotation = findCrossBlockAnnotation(affectedSubBlockIds, window.globalCurrentContentIdentifier);
|
||
if (crossBlockAnnotation) {
|
||
return true;
|
||
}
|
||
|
||
// 备用检查:是否所有子块都被独立高亮(兼容旧数据)
|
||
for (const subBlock of affectedSubBlocks) {
|
||
const hasHighlight = window.data.annotations.some(ann =>
|
||
ann.targetType === window.globalCurrentContentIdentifier &&
|
||
ann.target && ann.target.selector && ann.target.selector[0] &&
|
||
ann.target.selector[0].subBlockId === subBlock.subBlockId &&
|
||
(ann.motivation === 'highlighting' || ann.motivation === 'commenting')
|
||
);
|
||
if (!hasHighlight) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ===== 修改:检查跨子块批注状态 =====
|
||
function checkCrossBlockNote(affectedSubBlocks) {
|
||
if (!window.data || !window.data.annotations) return false;
|
||
|
||
const affectedSubBlockIds = affectedSubBlocks.map(sb => sb.subBlockId);
|
||
|
||
// 查找跨子块标注的批注
|
||
const crossBlockAnnotation = findCrossBlockAnnotation(affectedSubBlockIds, window.globalCurrentContentIdentifier);
|
||
if (crossBlockAnnotation && crossBlockAnnotation.body && crossBlockAnnotation.body.length > 0 &&
|
||
crossBlockAnnotation.body[0].value && crossBlockAnnotation.body[0].value.trim() !== '') {
|
||
return true;
|
||
}
|
||
|
||
// 备用检查:是否有任意子块有批注
|
||
for (const subBlock of affectedSubBlocks) {
|
||
const hasNote = window.data.annotations.some(ann =>
|
||
ann.targetType === window.globalCurrentContentIdentifier &&
|
||
ann.target && ann.target.selector && ann.target.selector[0] &&
|
||
ann.target.selector[0].subBlockId === subBlock.subBlockId &&
|
||
ann.body && ann.body.length > 0 && ann.body[0].value && ann.body[0].value.trim() !== ''
|
||
);
|
||
if (hasNote) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ===== 新增:更新跨子块上下文菜单 =====
|
||
function updateCrossBlockContextMenuOptions(isHighlighted, hasNote) {
|
||
if (!annotationContextMenuElement) return;
|
||
|
||
const highlightOption = annotationContextMenuElement.querySelector('[data-action="highlight-block"]');
|
||
const removeHighlightOption = document.getElementById('remove-highlight-option');
|
||
const addNoteOption = document.getElementById('add-note-option');
|
||
const editNoteOption = document.getElementById('edit-note-option');
|
||
const copyContentOption = document.getElementById('copy-content-option');
|
||
|
||
// 显示跨块高亮选项,并添加颜色子选项
|
||
if (highlightOption) {
|
||
highlightOption.textContent = '高亮选中区域';
|
||
highlightOption.style.display = isHighlighted ? 'none' : 'block';
|
||
|
||
// 为跨子块高亮选项添加颜色子选项
|
||
if (!isHighlighted) {
|
||
// 清除现有的颜色选项
|
||
const existingColorOptions = highlightOption.querySelectorAll('.color-option');
|
||
existingColorOptions.forEach(option => option.remove());
|
||
|
||
// 添加颜色选项
|
||
const colorOptions = [
|
||
{ color: 'rgba(255, 255, 0, 0.3)', name: '黄色', value: 'yellow' },
|
||
{ color: 'rgba(0, 255, 0, 0.3)', name: '绿色', value: 'green' },
|
||
{ color: 'rgba(255, 192, 203, 0.3)', name: '粉色', value: 'pink' },
|
||
{ color: 'rgba(135, 206, 235, 0.3)', name: '蓝色', value: 'blue' },
|
||
{ color: 'rgba(255, 165, 0, 0.3)', name: '橙色', value: 'orange' }
|
||
];
|
||
|
||
const colorContainer = document.createElement('div');
|
||
colorContainer.className = 'color-submenu';
|
||
colorContainer.style.display = 'flex';
|
||
colorContainer.style.gap = '5px';
|
||
colorContainer.style.marginTop = '5px';
|
||
colorContainer.style.padding = '5px';
|
||
|
||
colorOptions.forEach(option => {
|
||
const colorDiv = document.createElement('div');
|
||
colorDiv.className = 'color-option';
|
||
colorDiv.dataset.color = option.value;
|
||
colorDiv.title = option.name;
|
||
colorDiv.style.width = '20px';
|
||
colorDiv.style.height = '20px';
|
||
colorDiv.style.backgroundColor = option.color;
|
||
colorDiv.style.border = '1px solid #ccc';
|
||
colorDiv.style.borderRadius = '3px';
|
||
colorDiv.style.cursor = 'pointer';
|
||
colorDiv.style.display = 'inline-block';
|
||
|
||
// 添加悬停效果
|
||
colorDiv.addEventListener('mouseenter', function() {
|
||
colorDiv.style.transform = 'scale(1.1)';
|
||
colorDiv.style.borderColor = '#333';
|
||
});
|
||
colorDiv.addEventListener('mouseleave', function() {
|
||
colorDiv.style.transform = 'scale(1)';
|
||
colorDiv.style.borderColor = '#ccc';
|
||
});
|
||
|
||
colorContainer.appendChild(colorDiv);
|
||
});
|
||
|
||
highlightOption.appendChild(colorContainer);
|
||
}
|
||
}
|
||
|
||
if (removeHighlightOption) {
|
||
removeHighlightOption.textContent = '移除选中区域高亮';
|
||
removeHighlightOption.style.display = isHighlighted ? 'block' : 'none';
|
||
}
|
||
|
||
if (copyContentOption) {
|
||
copyContentOption.style.display = 'block';
|
||
}
|
||
|
||
// 批注选项
|
||
if (isHighlighted) {
|
||
if (addNoteOption) {
|
||
addNoteOption.textContent = '为选中区域添加批注';
|
||
addNoteOption.style.display = hasNote ? 'none' : 'block';
|
||
}
|
||
if (editNoteOption) {
|
||
editNoteOption.textContent = '编辑选中区域批注';
|
||
editNoteOption.style.display = hasNote ? 'block' : 'none';
|
||
}
|
||
} else {
|
||
if (addNoteOption) addNoteOption.style.display = 'none';
|
||
if (editNoteOption) editNoteOption.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// ===== 重新设计:跨子块标注数据结构 =====
|
||
async function handleCrossBlockMenuAction(action, color, event) {
|
||
const docId = getQueryParam('id');
|
||
if (!docId) {
|
||
alert('错误:无法获取文档ID。');
|
||
hideContextMenu();
|
||
return;
|
||
}
|
||
|
||
const crossBlockAnnotationId = annotationContextMenuElement.dataset.contextCrossBlockAnnotationId;
|
||
const affectedSubBlockIds = JSON.parse(annotationContextMenuElement.dataset.contextAffectedSubBlocks || '[]');
|
||
const selectedText = annotationContextMenuElement.dataset.contextSelectedText;
|
||
const currentContentIdentifier = annotationContextMenuElement.dataset.contextContentIdentifier;
|
||
|
||
console.log(`[跨子块操作] 执行操作: ${action}, 涉及 ${affectedSubBlockIds.length} 个子块`);
|
||
|
||
try {
|
||
if (action === 'highlight-block') {
|
||
// 如果没有选择颜色,使用默认颜色
|
||
if (!color) {
|
||
color = 'yellow'; // 默认黄色
|
||
console.log("跨子块高亮操作未选择颜色,使用默认颜色: " + color);
|
||
}
|
||
|
||
// 创建单一的跨子块标注对象
|
||
await createCrossBlockAnnotation(docId, affectedSubBlockIds, currentContentIdentifier, color, '', selectedText);
|
||
console.log(`[跨子块高亮] 已创建跨子块标注,涉及 ${affectedSubBlockIds.length} 个子块`);
|
||
|
||
} else if (action === 'remove-highlight') {
|
||
// 移除跨子块标注
|
||
await removeCrossBlockAnnotation(affectedSubBlockIds, currentContentIdentifier);
|
||
console.log(`[跨子块去高亮] 已移除跨子块标注`);
|
||
|
||
} else if (action === 'add-note' || action === 'edit-note') {
|
||
// 为跨子块标注添加/编辑批注
|
||
let noteText;
|
||
if (action === 'edit-note') {
|
||
const existingNote = findExistingCrossBlockNote(affectedSubBlockIds, currentContentIdentifier);
|
||
noteText = prompt("编辑跨子块批注内容:", existingNote || '');
|
||
} else {
|
||
noteText = prompt("为选中区域输入批注内容:", "");
|
||
}
|
||
|
||
if (noteText === null) {
|
||
// 用户取消
|
||
} else if (noteText.trim() === '') {
|
||
alert('批注内容不能为空。');
|
||
} else {
|
||
await addNoteToCrossBlockAnnotation(noteText, affectedSubBlockIds, currentContentIdentifier);
|
||
console.log(`[跨子块批注] 已为选中区域添加批注`);
|
||
}
|
||
|
||
} else if (action === 'copy-content') {
|
||
// 复制选中的跨子块内容
|
||
if (selectedText && selectedText.trim()) {
|
||
navigator.clipboard.writeText(selectedText)
|
||
.then(() => {
|
||
console.log(`[跨子块复制] 已复制跨子块内容: ${selectedText.substring(0,50)}...`);
|
||
})
|
||
.catch(err => {
|
||
console.error('复制失败:', err);
|
||
alert('复制内容失败。');
|
||
});
|
||
} else {
|
||
alert('没有可复制的内容。');
|
||
}
|
||
}
|
||
|
||
// 刷新高亮显示
|
||
if (action !== 'copy-content') {
|
||
const containerId = currentContentIdentifier + '-content-wrapper';
|
||
const container = document.getElementById(containerId);
|
||
if (container && typeof window.applyBlockAnnotations === 'function') {
|
||
window.applyBlockAnnotations(container, window.data.annotations, currentContentIdentifier);
|
||
}
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error(`[跨子块操作] 操作 '${action}' 失败:`, error);
|
||
alert(`跨子块操作失败: ${error.message}`);
|
||
} finally {
|
||
hideContextMenu();
|
||
}
|
||
}
|
||
|
||
// ===== 新增:创建跨子块标注 =====
|
||
async function createCrossBlockAnnotation(docId, affectedSubBlockIds, contentIdentifier, color, note = '', selectedText = '') {
|
||
// 检查是否已存在跨子块标注
|
||
const existingAnnotation = findCrossBlockAnnotation(affectedSubBlockIds, contentIdentifier);
|
||
|
||
if (existingAnnotation) {
|
||
// 更新现有标注
|
||
existingAnnotation.highlightColor = color;
|
||
existingAnnotation.modified = new Date().toISOString();
|
||
if (note) {
|
||
existingAnnotation.body = [{
|
||
type: 'TextualBody',
|
||
value: note,
|
||
format: 'text/plain',
|
||
purpose: 'commenting'
|
||
}];
|
||
existingAnnotation.motivation = 'commenting';
|
||
}
|
||
await updateAnnotationInDB(existingAnnotation);
|
||
} else {
|
||
// 创建新的跨子块标注
|
||
const rangeInfo = calculateCrossBlockRange(affectedSubBlockIds);
|
||
|
||
const newAnnotation = {
|
||
'@context': 'http://www.w3.org/ns/anno.jsonld',
|
||
id: 'urn:uuid:' + _page_generateUUID(),
|
||
type: 'Annotation',
|
||
motivation: note ? 'commenting' : 'highlighting',
|
||
created: new Date().toISOString(),
|
||
docId: docId,
|
||
targetType: contentIdentifier,
|
||
highlightColor: color,
|
||
isCrossBlock: true, // 标识这是跨子块标注
|
||
target: {
|
||
source: docId,
|
||
selector: [{
|
||
type: 'CrossBlockRangeSelector',
|
||
startSubBlockId: rangeInfo.startSubBlockId,
|
||
endSubBlockId: rangeInfo.endSubBlockId,
|
||
startOffset: rangeInfo.startOffset,
|
||
endOffset: rangeInfo.endOffset,
|
||
affectedSubBlocks: affectedSubBlockIds,
|
||
exact: selectedText || ''
|
||
}]
|
||
},
|
||
body: note ? [{
|
||
type: 'TextualBody',
|
||
value: note,
|
||
format: 'text/plain',
|
||
purpose: 'commenting'
|
||
}] : []
|
||
};
|
||
|
||
await saveAnnotationToDB(newAnnotation);
|
||
if (!window.data.annotations) window.data.annotations = [];
|
||
window.data.annotations.push(newAnnotation);
|
||
}
|
||
}
|
||
|
||
// ===== 新增:计算跨子块范围信息 =====
|
||
function calculateCrossBlockRange(affectedSubBlockIds) {
|
||
if (!affectedSubBlockIds.length) return null;
|
||
|
||
// 优先使用跨子块检测时保存的原始 Range,避免上下文菜单点击导致选区变化
|
||
let range = (window.globalCurrentSelection && window.globalCurrentSelection.isCrossBlock && window.globalCurrentSelection.range)
|
||
? window.globalCurrentSelection.range.cloneRange()
|
||
: null;
|
||
if (!range) {
|
||
const selection = window.getSelection();
|
||
if (!selection.rangeCount) return null;
|
||
range = selection.getRangeAt(0);
|
||
}
|
||
|
||
// 找到起止子块元素(优先使用 crossBlockInfo 存下来的 DOM)
|
||
let startSubBlock = (window.globalCurrentSelection && window.globalCurrentSelection.startSubBlock) || null;
|
||
let endSubBlock = (window.globalCurrentSelection && window.globalCurrentSelection.endSubBlock) || null;
|
||
if (!startSubBlock) {
|
||
startSubBlock = range.startContainer.nodeType === Node.TEXT_NODE
|
||
? range.startContainer.parentElement.closest('.sub-block')
|
||
: (range.startContainer.closest ? range.startContainer.closest('.sub-block') : null);
|
||
}
|
||
if (!endSubBlock) {
|
||
endSubBlock = range.endContainer.nodeType === Node.TEXT_NODE
|
||
? range.endContainer.parentElement.closest('.sub-block')
|
||
: (range.endContainer.closest ? range.endContainer.closest('.sub-block') : null);
|
||
}
|
||
|
||
const startSubBlockId = startSubBlock ? startSubBlock.dataset.subBlockId : affectedSubBlockIds[0];
|
||
const endSubBlockId = endSubBlock ? endSubBlock.dataset.subBlockId : affectedSubBlockIds[affectedSubBlockIds.length - 1];
|
||
|
||
// 计算相对各自子块文本的字符偏移
|
||
let startOffsetInSubBlock = 0;
|
||
let endOffsetInSubBlock = 0;
|
||
const fragmentTextLength = (frag) => {
|
||
const walker = document.createTreeWalker(frag, NodeFilter.SHOW_TEXT, null);
|
||
let len = 0;
|
||
let n;
|
||
const isInIndicator = (textNode) => {
|
||
let p = textNode.parentNode;
|
||
while (p) {
|
||
if (p.nodeType === 1 && p.classList && p.classList.contains('cross-block-indicator')) return true;
|
||
p = p.parentNode;
|
||
}
|
||
return false;
|
||
};
|
||
while ((n = walker.nextNode())) {
|
||
if (!isInIndicator(n)) len += (n.nodeValue ? n.nodeValue.length : 0);
|
||
}
|
||
return len;
|
||
};
|
||
|
||
try {
|
||
if (startSubBlock && startSubBlock.contains(range.startContainer)) {
|
||
const r = document.createRange();
|
||
r.selectNodeContents(startSubBlock);
|
||
r.setEnd(range.startContainer, range.startOffset);
|
||
startOffsetInSubBlock = fragmentTextLength(r.cloneContents());
|
||
} else if (startSubBlock) {
|
||
// 兜底:若浏览器把选区起点放到子块外,则认为偏移为0
|
||
startOffsetInSubBlock = 0;
|
||
}
|
||
if (endSubBlock && endSubBlock.contains(range.endContainer)) {
|
||
const r2 = document.createRange();
|
||
r2.selectNodeContents(endSubBlock);
|
||
r2.setEnd(range.endContainer, range.endOffset);
|
||
endOffsetInSubBlock = fragmentTextLength(r2.cloneContents());
|
||
} else if (endSubBlock) {
|
||
// 兜底:若浏览器把选区终点放到子块外,则认为到达末尾
|
||
const rr = document.createRange();
|
||
rr.selectNodeContents(endSubBlock);
|
||
endOffsetInSubBlock = fragmentTextLength(rr.cloneContents());
|
||
}
|
||
} catch (e) {
|
||
console.warn('[跨子块] 计算偏移失败,使用回退 offset', e);
|
||
startOffsetInSubBlock = range.startOffset || 0;
|
||
endOffsetInSubBlock = range.endOffset || 0;
|
||
}
|
||
|
||
return {
|
||
startSubBlockId: startSubBlockId,
|
||
endSubBlockId: endSubBlockId,
|
||
startOffset: startOffsetInSubBlock,
|
||
endOffset: endOffsetInSubBlock,
|
||
selectedText: (window.globalCurrentSelection && window.globalCurrentSelection.isCrossBlock && window.globalCurrentSelection.text)
|
||
? window.globalCurrentSelection.text
|
||
: (window.getSelection ? window.getSelection().toString() : '')
|
||
};
|
||
}
|
||
|
||
// ===== 新增:查找跨子块标注 =====
|
||
function findCrossBlockAnnotation(affectedSubBlockIds, contentIdentifier) {
|
||
if (!window.data || !window.data.annotations) return null;
|
||
|
||
return window.data.annotations.find(ann => {
|
||
if (ann.targetType !== contentIdentifier || !ann.isCrossBlock) return false;
|
||
if (!ann.target || !ann.target.selector || !ann.target.selector[0]) return false;
|
||
|
||
const selector = ann.target.selector[0];
|
||
if (!selector.affectedSubBlocks) return false;
|
||
|
||
// 检查是否包含相同的子块ID集合
|
||
const annotationSubBlocks = selector.affectedSubBlocks.sort();
|
||
const targetSubBlocks = affectedSubBlockIds.sort();
|
||
|
||
return JSON.stringify(annotationSubBlocks) === JSON.stringify(targetSubBlocks);
|
||
});
|
||
}
|
||
|
||
// ===== 新增:移除跨子块标注 =====
|
||
async function removeCrossBlockAnnotation(affectedSubBlockIds, contentIdentifier) {
|
||
const annotation = findCrossBlockAnnotation(affectedSubBlockIds, contentIdentifier);
|
||
if (annotation) {
|
||
await deleteAnnotationFromDB(annotation.id);
|
||
const index = window.data.annotations.findIndex(ann => ann.id === annotation.id);
|
||
if (index > -1) {
|
||
window.data.annotations.splice(index, 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===== 新增:为跨子块标注添加批注 =====
|
||
async function addNoteToCrossBlockAnnotation(noteText, affectedSubBlockIds, contentIdentifier) {
|
||
const annotation = findCrossBlockAnnotation(affectedSubBlockIds, contentIdentifier);
|
||
if (annotation) {
|
||
annotation.body = [{
|
||
type: 'TextualBody',
|
||
value: noteText,
|
||
format: 'text/plain',
|
||
purpose: 'commenting'
|
||
}];
|
||
annotation.modified = new Date().toISOString();
|
||
annotation.motivation = 'commenting';
|
||
|
||
await updateAnnotationInDB(annotation);
|
||
}
|
||
}
|
||
|
||
// ===== 新增:创建或更新子块标注 =====
|
||
async function createOrUpdateSubBlockAnnotation(docId, subBlockId, contentIdentifier, color, note = '', groupId = null) {
|
||
// 查找现有标注
|
||
const existingAnnotation = window.data.annotations.find(ann =>
|
||
ann.targetType === contentIdentifier &&
|
||
ann.target && ann.target.selector && ann.target.selector[0] &&
|
||
ann.target.selector[0].subBlockId === subBlockId
|
||
);
|
||
|
||
if (existingAnnotation) {
|
||
// 更新现有标注
|
||
existingAnnotation.highlightColor = color;
|
||
existingAnnotation.modified = new Date().toISOString();
|
||
if (groupId) existingAnnotation.groupId = groupId;
|
||
if (note) {
|
||
existingAnnotation.body = [{
|
||
type: 'TextualBody',
|
||
value: note,
|
||
format: 'text/plain',
|
||
purpose: 'commenting'
|
||
}];
|
||
existingAnnotation.motivation = 'commenting';
|
||
} else if (!existingAnnotation.body || existingAnnotation.body.length === 0) {
|
||
existingAnnotation.motivation = 'highlighting';
|
||
}
|
||
|
||
await updateAnnotationInDB(existingAnnotation);
|
||
} else {
|
||
// 创建新标注
|
||
const newAnnotation = {
|
||
'@context': 'http://www.w3.org/ns/anno.jsonld',
|
||
id: 'urn:uuid:' + _page_generateUUID(),
|
||
type: 'Annotation',
|
||
motivation: note ? 'commenting' : 'highlighting',
|
||
created: new Date().toISOString(),
|
||
docId: docId,
|
||
targetType: contentIdentifier,
|
||
highlightColor: color,
|
||
target: {
|
||
source: docId,
|
||
selector: [{
|
||
type: 'SubBlockSelector',
|
||
subBlockId: subBlockId
|
||
}]
|
||
},
|
||
body: note ? [{
|
||
type: 'TextualBody',
|
||
value: note,
|
||
format: 'text/plain',
|
||
purpose: 'commenting'
|
||
}] : []
|
||
};
|
||
|
||
if (groupId) newAnnotation.groupId = groupId;
|
||
|
||
// 设置 exact 字段
|
||
const subBlockElement = document.querySelector(`[data-sub-block-id="${subBlockId}"]`);
|
||
if (subBlockElement) {
|
||
newAnnotation.target.selector[0].exact = subBlockElement.textContent.trim();
|
||
}
|
||
|
||
await saveAnnotationToDB(newAnnotation);
|
||
if (!window.data.annotations) window.data.annotations = [];
|
||
window.data.annotations.push(newAnnotation);
|
||
}
|
||
}
|
||
|
||
// ===== 修改:查找跨子块批注 =====
|
||
function findExistingCrossBlockNote(affectedSubBlockIds, contentIdentifier) {
|
||
if (!window.data || !window.data.annotations) return '';
|
||
|
||
// 首先查找跨子块标注
|
||
const crossBlockAnnotation = findCrossBlockAnnotation(affectedSubBlockIds, contentIdentifier);
|
||
if (crossBlockAnnotation && crossBlockAnnotation.body && crossBlockAnnotation.body.length > 0 &&
|
||
crossBlockAnnotation.body[0].value) {
|
||
return crossBlockAnnotation.body[0].value;
|
||
}
|
||
|
||
// 备用:查找第一个子块的批注
|
||
for (const subBlockId of affectedSubBlockIds) {
|
||
const annotation = window.data.annotations.find(ann =>
|
||
ann.targetType === contentIdentifier &&
|
||
ann.target && ann.target.selector && ann.target.selector[0] &&
|
||
ann.target.selector[0].subBlockId === subBlockId &&
|
||
ann.body && ann.body.length > 0 && ann.body[0].value
|
||
);
|
||
|
||
if (annotation) {
|
||
return annotation.body[0].value;
|
||
}
|
||
}
|
||
|
||
return '';
|
||
}
|
||
|
||
// 暴露新功能
|
||
window.detectCrossBlockSelection = detectCrossBlockSelection;
|
||
window.handleCrossBlockAnnotation = handleCrossBlockAnnotation;
|
||
|
||
// 保留旧函数以保持向后兼容性
|
||
window.checkIfTargetIsHighlighted = checkIfTargetIsHighlighted;
|
||
window.checkIfTargetHasNote = checkIfTargetHasNote;
|
||
|
||
// 保留旧函数以保持向后兼容性
|
||
window.updateContextMenuOptions = updateContextMenuOptions;
|
||
window.showContextMenu = showContextMenu;
|
||
|
||
window.initializeGlobalAnnotationVariables = function() {
|
||
window.globalCurrentSelection = null;
|
||
// window.globalCurrentTargetElement = null; // 重要性降低
|
||
window.globalCurrentHighlightStatus = false;
|
||
window.globalCurrentContentIdentifier = ''; // 仍然初始化,但应减少直接依赖
|
||
};
|