外观
基于iframe和原生文本选择的文档标注工具技术实现
项目概述
本文介绍了一个HTML文档标注工具的技术实现,该工具能够在浏览器中对HTML内容进行交互式标注。工具采用iframe架构,支持多页面标注、数据持久化和实时同步等功能。
项目开源地址
核心技术架构
1. iframe多页面架构
工具采用iframe架构来加载和显示HTML文档,支持同时打开多个HTML页面进行标注。
页面管理机制
let pages = []; // 存储所有页面信息 [{id, url, title, frameId}]
let activePageId = null; // 当前激活的页面ID
// 创建新页面
function createPage(url, title) {
const pageId = "page_" + Date.now() + "_" + Math.random().toString(36).slice(2, 11);
const frameId = "frame_" + pageId;
const newPage = {
id: pageId,
url: url,
title: title,
frameId: frameId
};
pages.push(newPage);
createTab(newPage);
createIframe(newPage);
switchToPage(pageId);
}标签页切换
通过标签页(Tab)机制实现多页面之间的切换,每个标签页对应一个iframe页面:
function switchToPage(pageId) {
// 更新激活状态
activePageId = pageId;
// 更新标签页样式
$(".tab").removeClass("active");
$(`.tab[data-page-id="${pageId}"]`).addClass("active");
// 显示/隐藏iframe
$(".iframe-wrapper iframe").hide();
$(`#${page.frameId}`).show();
// 加载该页面的标注数据
loadAnnotationsFromFrame(page.frameId);
}2. 脚本注入机制
标注功能通过动态注入脚本到iframe中实现,确保标注逻辑与文档内容隔离。
脚本注入实现
function injectAnnotationScript(frameDoc, frame) {
// 检查是否已经注入
if (frameDoc.getElementById('annotation-script-injected')) {
return;
}
// 创建script标签
const script = frameDoc.createElement('script');
script.id = 'annotation-script-injected';
script.src = '/word/annotation.js';
script.onload = function() {
// 脚本加载完成后初始化
if (frame.contentWindow.annotationTool) {
frame.contentWindow.annotationTool.init();
}
};
frameDoc.head.appendChild(script);
}这种方式的优势:
- 隔离性:标注脚本只在iframe中运行,不影响父页面
- 可维护性:标注逻辑独立,便于维护和更新
- 灵活性:可以为不同的iframe注入不同的脚本
3. 原生文本选择实现
工具使用浏览器原生的文本选择API,提供类似浏览器中拖选文字的自然体验。
文本选择捕获
function handleTextSelection(e) {
const frame = document.getElementById('contentFrame');
try {
const frameDoc = frame.contentDocument || frame.contentWindow.document;
const selection = frameDoc.getSelection();
// 延迟一下,确保浏览器已经完成文本选择
setTimeout(() => {
if (selection && selection.toString().trim()) {
const selectedText = selection.toString().trim();
const range = selection.getRangeAt(0);
// 获取选中元素的上下文信息
const container = range.commonAncestorContainer;
const element = container.nodeType === 1
? container
: container.parentElement;
// 显示标注类型选择对话框
showAnnotationTypeDialog(selectedText, range);
// 清除选择
selection.removeAllRanges();
}
}, 10);
} catch (err) {
console.error('获取选择内容时出错:', err);
}
}选择范围信息提取
使用Range API获取详细的选择位置信息:
function getRangeInfo(range) {
if (!range) return null;
return {
startContainer: range.startContainer,
startOffset: range.startOffset,
endContainer: range.endContainer,
endOffset: range.endOffset,
text: range.toString(),
// 使用XPath记录位置
startXPath: getXPath(range.startContainer),
endXPath: getXPath(range.endContainer)
};
}4. XPath定位技术
为了支持页面刷新后恢复标注位置,工具使用XPath来记录标注在DOM中的精确位置。
XPath生成
function getXPath(element) {
if (!element) return null;
if (element.id) {
return `//*[@id="${element.id}"]`;
}
const parts = [];
let current = element;
while (current && current.nodeType === Node.ELEMENT_NODE) {
let index = 1;
let sibling = current.previousSibling;
while (sibling) {
if (sibling.nodeType === Node.ELEMENT_NODE &&
sibling.tagName === current.tagName) {
index++;
}
sibling = sibling.previousSibling;
}
const tagName = current.tagName.toLowerCase();
parts.unshift(`${tagName}[${index}]`);
current = current.parentElement;
}
return '/' + parts.join('/');
}XPath恢复
function getNodeFromXPath(xpath) {
try {
const result = document.evaluate(
xpath,
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null
);
return result.singleNodeValue;
} catch (e) {
console.error('XPath解析失败:', e);
return null;
}
}5. 跨页面通信机制
父页面和iframe之间通过两种方式通信:直接API访问和postMessage。
postMessage通信
// 父页面监听iframe消息
function setupMessageListener() {
window.addEventListener("message", function (event) {
if (event.data && event.data.type === "annotationEvent") {
handleAnnotationEvent(
event.data.event,
event.data.data,
event.source
);
}
});
}
// iframe向父页面发送消息
function notifyParent(event, data) {
if (window.parent && window.parent !== window) {
window.parent.postMessage(
{
type: "annotationEvent",
event: event,
data: data,
},
"*"
);
}
}直接API访问(同源时)
// 父页面直接调用iframe中的方法
function callFrameAPI(frameId, method, ...args) {
const frame = document.getElementById(frameId);
if (!frame) return;
try {
const frameWindow = frame.contentWindow;
if (frameWindow.annotationTool &&
frameWindow.annotationTool[method]) {
return frameWindow.annotationTool[method](...args);
}
} catch (e) {
// 跨域时使用postMessage
frameWindow.postMessage({
type: method,
args: args
}, "*");
}
}6. 数据持久化方案
标注数据存储在localStorage中,支持页面刷新后自动恢复。
数据存储结构
const annotation = {
id: Date.now() + Math.random(),
num: generateGuid(), // 唯一标识
parentnum: null, // 父标注ID(支持嵌套)
type: "标注类型",
content: "标注的文本内容",
timestamp: new Date().toLocaleString("zh-CN"),
rangeInfo: {
startXPath: "起始节点XPath",
startOffset: 起始偏移量,
endXPath: "结束节点XPath",
endOffset: 结束偏移量,
text: "文本内容"
},
position: 位置索引, // 用于排序
pageUrl: "页面URL" // 区分不同页面的标注
};保存和加载
// 保存标注数据
function saveAnnotations() {
try {
const data = JSON.stringify(annotations);
localStorage.setItem(ANNOTATION_DATA_KEY, data);
} catch (e) {
console.error("保存标注数据时出错:", e);
}
}
// 加载标注数据
function loadAnnotations() {
try {
const data = localStorage.getItem(ANNOTATION_DATA_KEY);
if (data) {
annotations = JSON.parse(data);
// 兼容性处理:为旧数据添加必要字段
annotations.forEach((ann) => {
if (!ann.num) {
ann.num = generateGuid();
}
if (ann.pageUrl === undefined) {
ann.pageUrl = "";
}
});
saveAnnotations(); // 保存更新后的数据
}
} catch (e) {
console.error("加载标注数据时出错:", e);
annotations = [];
}
}标注恢复
页面加载后,自动恢复已保存的标注:
function restoreAnnotations() {
loadAnnotations();
const currentPageAnnotations = getCurrentPageAnnotations();
currentPageAnnotations.forEach((annotation) => {
try {
// 使用XPath恢复位置
const startNode = getNodeFromXPath(annotation.rangeInfo.startXPath);
const endNode = getNodeFromXPath(annotation.rangeInfo.endXPath);
if (startNode && endNode) {
const range = document.createRange();
range.setStart(startNode, annotation.rangeInfo.startOffset);
range.setEnd(endNode, annotation.rangeInfo.endOffset);
// 重新高亮标注
highlightAnnotation(annotation, range);
}
} catch (e) {
console.warn("恢复标注失败:", e);
}
});
}7. 标注高亮显示
标注成功后,在选中内容周围添加可视化边框和标签。
高亮实现
function highlightAnnotation(annotation, range) {
// 创建标注容器
const wrapper = document.createElement('span');
wrapper.className = 'annotation-highlight';
wrapper.setAttribute('data-annotation-id', annotation.id);
// 设置样式
wrapper.style.border = '2px solid #4a90e2';
wrapper.style.borderRadius = '3px';
wrapper.style.position = 'relative';
wrapper.style.display = 'inline-block';
// 包裹选中的内容
try {
range.surroundContents(wrapper);
} catch (e) {
// 如果surroundContents失败,使用其他方法
const contents = range.extractContents();
wrapper.appendChild(contents);
range.insertNode(wrapper);
}
// 添加删除按钮
addDeleteButton(wrapper, annotation.id);
// 添加悬停提示
addHoverTooltip(wrapper, annotation);
}8. 表格标注处理
工具对表格内容进行了特殊处理,支持表格单元格的选择、高亮和转换为JSON格式。
表格检测
首先需要判断用户选择的内容是否在表格中:
function isRangeInTable(range) {
let node = range.commonAncestorContainer;
if (node.nodeType === Node.TEXT_NODE) {
node = node.parentNode;
}
while (node && node !== document.body) {
if (
node.tagName &&
(node.tagName.toLowerCase() === "table" ||
node.tagName.toLowerCase() === "td" ||
node.tagName.toLowerCase() === "th" ||
node.tagName.toLowerCase() === "tr" ||
node.tagName.toLowerCase() === "tbody" ||
node.tagName.toLowerCase() === "thead" ||
node.tagName.toLowerCase() === "tfoot")
) {
return true;
}
node = node.parentNode;
}
return false;
}表格单元格提取
从Range中提取所有涉及的表格单元格,支持跨单元格选择:
function getTableCellsFromRange(range) {
const cells = [];
const commonAncestor = range.commonAncestorContainer;
// 如果选择了整个表格
if (commonAncestor.tagName.toLowerCase() === "table") {
const allCells = commonAncestor.querySelectorAll("td, th");
allCells.forEach((cell) => cells.push(cell));
return cells;
}
// 找到起始和结束单元格
let startCell = range.startContainer;
let endCell = range.endContainer;
// 向上查找单元格
while (startCell && startCell.tagName?.toLowerCase() !== "td" &&
startCell.tagName?.toLowerCase() !== "th") {
startCell = startCell.parentNode;
}
while (endCell && endCell.tagName?.toLowerCase() !== "td" &&
endCell.tagName?.toLowerCase() !== "th") {
endCell = endCell.parentNode;
}
// 获取起始和结束单元格之间的所有单元格
if (startCell && endCell) {
const table = startCell.closest("table");
const allCells = table.querySelectorAll("td, th");
let startIndex = -1;
let endIndex = -1;
allCells.forEach((cell, index) => {
if (cell === startCell) startIndex = index;
if (cell === endCell) endIndex = index;
});
// 提取范围内的单元格
for (let i = Math.min(startIndex, endIndex);
i <= Math.max(startIndex, endIndex); i++) {
cells.push(allCells[i]);
}
}
return cells;
}表格标注信息存储
在标注数据中保存表格相关的信息:
function getRangeInfo(range) {
let isInTable = isRangeInTable(range);
// 如果只涉及一个单元格,按普通文本处理
if (isInTable) {
const tableCells = getTableCellsFromRange(range);
if (tableCells.length === 1) {
isInTable = false;
}
}
// 保存表格单元格的XPath
let tableInfo = null;
if (isInTable) {
const tableCells = getTableCellsFromRange(range);
if (tableCells.length > 0) {
tableInfo = {
cellXPaths: tableCells.map((cell) => getXPath(cell)),
};
}
}
return {
startXPath: getXPath(range.startContainer),
startOffset: range.startOffset,
endXPath: getXPath(range.endContainer),
endOffset: range.endOffset,
text: range.toString(),
isInTable: isInTable,
tableInfo: tableInfo,
};
}表格高亮显示
对表格单元格进行特殊的高亮处理:
function highlightTableAnnotation(annotation, tableInfo) {
const cells = [];
// 通过XPath恢复单元格
if (tableInfo && tableInfo.cellXPaths) {
tableInfo.cellXPaths.forEach((xpath) => {
const cell = getNodeFromXPath(xpath);
if (cell && (cell.tagName.toLowerCase() === "td" ||
cell.tagName.toLowerCase() === "th")) {
cells.push(cell);
}
});
}
// 找到表格元素
let table = cells[0]?.closest("table");
if (!table) return;
// 确保表格有相对定位
if (!table.style.position || table.style.position === "static") {
table.style.position = "relative";
}
// 给每个单元格添加标注样式
cells.forEach((cell) => {
cell.classList.add("annotation-highlight-cell");
cell.style.background = "rgba(52, 152, 219, 0.1)";
cell.style.outline = "2px solid #3498db";
cell.style.outlineOffset = "-1px";
cell.style.position = "relative";
// 记录标注ID
if (cell.dataset.annotationIds) {
const ids = cell.dataset.annotationIds.split(",");
if (!ids.includes(annotation.id.toString())) {
ids.push(annotation.id.toString());
cell.dataset.annotationIds = ids.join(",");
}
} else {
cell.dataset.annotationIds = annotation.id.toString();
}
});
// 在表格上添加标注标签和删除按钮
addTableAnnotationContainer(table, annotation);
}表格转JSON
对于特定类型的标注,需要将表格转换为JSON数组格式。这个过程需要处理复杂的rowspan(跨行)和colspan(跨列)情况。
表头处理
首先提取表头作为JSON对象的键名,需要处理colspan(跨列)情况:
// 获取表头行
const thead = table.querySelector("thead");
const tbody = table.querySelector("tbody");
let headerRow = thead?.querySelector("tr") ||
tbody?.querySelector("tr") ||
table.querySelector("tr");
// 提取表头作为键名(考虑colspan)
const keys = [];
const headerCells = headerRow.querySelectorAll("td, th");
headerCells.forEach((cell) => {
const key = (cell.textContent || "").trim();
const colspan = parseInt(cell.getAttribute("colspan") || "1", 10);
// 如果单元格有colspan,为每个跨越的列都添加相同的键名
// 例如:colspan=3的单元格,会在keys数组中添加3个相同的键名
for (let i = 0; i < colspan; i++) {
keys.push(key || `column${keys.length + 1}`);
}
});跨行处理(rowspan)的核心逻辑
处理rowspan是表格转JSON的关键难点。需要跟踪哪些列被rowspan占用,并在后续行中跳过这些列:
// 扫描所有数据行,记录每个单元格的范围信息
const cellRangeMap = []; // 存储每行的单元格映射
const rowspanTracker = new Map(); // 跟踪每列的rowspan剩余行数
dataTrs.forEach((tr, rowIndex) => {
const cells = tr.querySelectorAll("td, th");
const rowRangeMap = new Map(); // 存储当前行的单元格范围信息
let logicalColIndex = 0; // 逻辑列索引(考虑rowspan占用的列)
// 遍历当前行的所有单元格
cells.forEach((cell) => {
const colspan = parseInt(cell.getAttribute("colspan") || "1", 10);
const rowspan = parseInt(cell.getAttribute("rowspan") || "1", 10);
// 关键步骤1:跳过被rowspan占用的列
// 如果某个列还在rowspan跨行中,该列在后续行中不会出现实际的td元素
// 需要通过逻辑列索引跳过这些列
while (rowspanTracker.has(logicalColIndex) &&
rowspanTracker.get(logicalColIndex) > 0) {
logicalColIndex++; // 跳过被占用的列
}
// 关键步骤2:计算当前单元格占用的逻辑列范围
const actualStartCol = logicalColIndex;
const actualEndCol = logicalColIndex + colspan - 1;
// 关键步骤3:记录该单元格覆盖的所有列的范围信息
for (let col = actualStartCol; col <= actualEndCol; col++) {
rowRangeMap.set(col, {
cell: cell,
rowspan: rowspan,
colspan: colspan,
startCol: actualStartCol,
endCol: actualEndCol
});
// 关键步骤4:如果该单元格有rowspan,记录后续行需要跳过这些列
if (rowspan > 1) {
rowspanTracker.set(col, rowspan);
// 例如:rowspan=3的单元格,会在rowspanTracker中记录3
// 表示后续2行(3-1=2)都需要跳过这些列
}
}
// 移动到下一个单元格的逻辑列索引
logicalColIndex = actualEndCol + 1;
});
cellRangeMap.push(rowRangeMap);
// 关键步骤5:处理完当前行后,减少所有rowspan的剩余行数
// 这是跨行处理的核心:每处理完一行,所有rowspan的剩余行数减1
const keysToDelete = [];
rowspanTracker.forEach((remainingRows, colIndex) => {
if (remainingRows > 1) {
rowspanTracker.set(colIndex, remainingRows - 1);
} else {
// rowspan已经结束,删除跟踪记录
keysToDelete.push(colIndex);
}
});
keysToDelete.forEach(colIndex => rowspanTracker.delete(colIndex));
});转换为JSON对象数组
将单元格映射转换为JSON对象,同时保存单元格的合并信息:
// 将数据行转换为对象数组
dataTrs.forEach((tr, rowIndex) => {
const rowObj = {};
const rowRangeMap = cellRangeMap[rowIndex];
// 存储每个列的单元格属性信息(rowspan、colspan),供渲染时使用
rowObj._cellAttributes = {};
// 遍历所有keys(表头列)
keys.forEach((key, colIndex) => {
const cellInfo = rowRangeMap ? rowRangeMap.get(colIndex) : null;
if (cellInfo && cellInfo.cell) {
// 找到单元格,获取数据(保留HTML内容)
const cellHtml = cellInfo.cell.innerHTML || "";
rowObj[key] = cellHtml;
// 记录单元格的合并信息(只在起始列记录,避免重复)
if (colIndex === cellInfo.startCol) {
rowObj._cellAttributes[key] = {
rowspan: cellInfo.rowspan,
colspan: cellInfo.colspan,
startCol: cellInfo.startCol,
endCol: cellInfo.endCol
};
} else {
// 对于被colspan覆盖的其他列,标记为被合并
rowObj._cellAttributes[key] = {
isMerged: true,
mergedFrom: cellInfo.startCol
};
}
} else {
// 没找到单元格(被rowspan占用),设置为undefined
rowObj[key] = undefined;
rowObj._cellAttributes[key] = {
isRowspan: true // 标记为被rowspan占用
};
}
});
// 只有当行有内容时才添加
if (Object.keys(rowObj).some(key =>
key !== '_cellAttributes' && rowObj[key] !== undefined)) {
dataRows.push(rowObj);
}
});跨行处理示例
假设有一个表格:
| 项目 | 要求1 | 要求2 |
|------|-------|-------|
| 操作 | 步骤1 | 步骤2 | (rowspan=2)
| | 步骤3 | 步骤4 |转换后的JSON结构:
[
{
"项目": "操作",
"要求1": "步骤1",
"要求2": "步骤2",
"_cellAttributes": {
"项目": { rowspan: 2, colspan: 1 },
"要求1": { rowspan: 1, colspan: 1 },
"要求2": { rowspan: 1, colspan: 1 }
}
},
{
"项目": undefined, // 被rowspan占用
"要求1": "步骤3",
"要求2": "步骤4",
"_cellAttributes": {
"项目": { isRowspan: true },
"要求1": { rowspan: 1, colspan: 1 },
"要求2": { rowspan: 1, colspan: 1 }
}
}
]JSON渲染成表格
将JSON数组重新渲染成HTML表格时,需要反向处理rowspan和colspan:
function renderTableFromJson(jsonArray) {
if (!Array.isArray(jsonArray) || jsonArray.length === 0) {
return "<div>无表格数据</div>";
}
// 获取所有键名(表头)
const keys = new Set();
jsonArray.forEach((row) => {
Object.keys(row).forEach((key) => {
if (key !== '_cellAttributes') {
keys.add(key);
}
});
});
const headers = Array.from(keys);
// 构建表格HTML
let tableHtml = '<table class="preview-table">';
tableHtml += "<thead><tr>";
headers.forEach((header) => {
tableHtml += `<th>${escapeHtml(header)}</th>`;
});
tableHtml += "</tr></thead>";
// 表体渲染
tableHtml += "<tbody>";
// 关键:跟踪rowspan的剩余行数,用于跳过被rowspan占用的单元格
const rowspanTracker = new Map(); // Map<header名称, 剩余行数>
jsonArray.forEach((row, rowIndex) => {
tableHtml += "<tr>";
let colspanOffset = 0; // 用于跟踪colspan跳过的列数
headers.forEach((header, colIndex) => {
// 步骤1:如果还在colspan的偏移中,跳过
if (colspanOffset > 0) {
colspanOffset--;
return; // 不输出td标签
}
// 步骤2:检查该列是否还在rowspan跨行中
if (rowspanTracker.has(header) && rowspanTracker.get(header) > 0) {
// 该列还在跨行中,跳过渲染(不输出td标签)
const remainingRows = rowspanTracker.get(header);
if (remainingRows > 1) {
rowspanTracker.set(header, remainingRows - 1);
} else {
rowspanTracker.delete(header);
}
return; // 不输出td标签
}
const cellValue = row[header];
const cellAttrs = row._cellAttributes?.[header];
// 步骤3:如果单元格被标记为被合并或被rowspan占用,跳过
if (cellAttrs && (cellAttrs.isMerged || cellAttrs.isRowspan)) {
return; // 不输出td标签
}
// 步骤4:构建td标签的属性
let tdAttributes = '';
let actualColspan = 1;
if (cellAttrs) {
// 如果有rowspan,添加rowspan属性并跟踪
if (cellAttrs.rowspan > 1) {
tdAttributes += ` rowspan="${cellAttrs.rowspan}"`;
// 记录后续行需要跳过这些列
rowspanTracker.set(header, cellAttrs.rowspan - 1);
}
// 如果有colspan,添加colspan属性
if (cellAttrs.colspan > 1) {
actualColspan = cellAttrs.colspan;
tdAttributes += ` colspan="${actualColspan}"`;
// 设置colspan偏移,跳过后续被合并的列
colspanOffset = actualColspan - 1;
}
}
// 步骤5:输出单元格内容
const cellHtml = typeof cellValue === "string" && cellValue.includes("<")
? cellValue // HTML内容直接插入
: escapeHtml(String(cellValue || ""));
tableHtml += `<td ${tdAttributes}>${cellHtml}</td>`;
});
tableHtml += "</tr>";
});
tableHtml += "</tbody>";
tableHtml += "</table>";
return tableHtml;
}渲染时的关键处理点
- rowspan跟踪:使用
rowspanTracker跟踪每列的剩余跨行数,当剩余行数>0时,跳过该列的渲染 - colspan偏移:使用
colspanOffset跟踪当前单元格跨过的列数,后续列需要跳过 - 合并标记处理:检查
_cellAttributes中的isMerged和isRowspan标记,跳过被合并的单元格 - HTML内容保留:单元格内容如果是HTML格式,直接插入而不转义
这样就能完整地实现表格与JSON之间的双向转换,保持表格的原始结构和格式。
表格标注恢复
页面刷新后,通过XPath恢复表格标注:
function getAnnotationTableJson(annotation) {
if (!annotation || !annotation.rangeInfo || !annotation.rangeInfo.isInTable) {
return null;
}
try {
let table = null;
let selectedRows = new Set();
// 通过cellXPaths恢复表格和选中的行
if (annotation.rangeInfo.tableInfo &&
annotation.rangeInfo.tableInfo.cellXPaths) {
const cellXPaths = annotation.rangeInfo.tableInfo.cellXPaths;
cellXPaths.forEach((cellXPath) => {
const cell = getNodeFromXPath(cellXPath);
if (cell) {
// 向上查找表格和行
let node = cell;
let foundTr = null;
while (node && node !== document.body) {
if (node.tagName?.toLowerCase() === "tr") {
foundTr = node;
}
if (node.tagName?.toLowerCase() === "table") {
if (!table) {
table = node;
}
if (foundTr) {
selectedRows.add(foundTr);
}
break;
}
node = node.parentNode;
}
}
});
}
if (table && selectedRows.size > 0) {
// 对选中的行进行排序
const allRows = Array.from(table.querySelectorAll("tr"));
const sortedRows = Array.from(selectedRows).sort((a, b) => {
return allRows.indexOf(a) - allRows.indexOf(b);
});
return tableToJsonArray(table, sortedRows);
}
return null;
} catch (e) {
console.error("获取表格JSON失败:", e);
return null;
}
}9. 模块化设计
工具采用模块化设计,将功能拆分为多个独立的模块:
- 共用功能模块:包含数据存储、XPath处理、Range处理等通用功能
- 主标注脚本:包含文本选择、标注添加、标注显示等核心功能
- 场景适配模块:针对不同使用场景的特定标注逻辑
- 平台适配模块:针对不同平台的适配逻辑
这种设计的优势:
- 代码复用:共用功能集中管理,避免重复代码
- 易于维护:各模块职责清晰,便于定位和修复问题
- 灵活扩展:可以轻松添加新的标注类型或功能模块
10. 动态配置机制
工具支持动态配置标注类型,根据不同场景显示不同的标注选项。
配置结构
const PAPER_TYPE_CONFIG = {
'类型A': ['标注类型1', '标注类型2', '标注类型3'],
'类型B': ['标注类型A', '标注类型B', '标注类型C']
};配置传递
// 父页面设置配置
window.PAPER_TYPE_CONFIG = PAPER_TYPE_CONFIG;
// iframe从父页面获取配置
function getPaperTypeConfig() {
try {
if (window.parent && window.parent !== window &&
window.parent.PAPER_TYPE_CONFIG) {
return window.parent.PAPER_TYPE_CONFIG;
}
} catch (e) {
console.error('无法从父窗口获取配置:', e);
}
return null;
}11. HTML页面加载
工具支持直接加载HTML页面进行标注,可以通过URL或本地文件路径加载。
页面加载实现
// 从URL加载HTML页面
async function loadPageFromUrl(url) {
// 检查是否已经存在该页面
const existingPage = pages.find((p) => p.url === url);
if (existingPage) {
switchToPage(existingPage.id);
return;
}
// 创建新页面
const pageId = "page_" + Date.now() + "_" + Math.random().toString(36).slice(2, 11);
const frameId = "frame_" + pageId;
// 从URL提取页面标题
const urlParts = url.split("/");
const fileName = urlParts[urlParts.length - 1];
const pageTitle = fileName.replace(/\.html?$/, "") || "未命名页面";
const newPage = {
id: pageId,
url: url,
title: pageTitle,
frameId: frameId
};
pages.push(newPage);
createTab(newPage);
createIframe(newPage);
switchToPage(pageId);
}
// 批量加载多个HTML页面
async function loadPagesFromUrls(urls) {
for (const url of urls) {
await loadPageFromUrl(url);
}
}本地文件加载
对于本地开发环境,可以直接通过文件路径加载:
// 从本地文件路径加载
function loadPageFromLocalPath(filePath) {
// 转换为file://协议URL
const fileUrl = `file://${filePath}`;
loadPageFromUrl(fileUrl);
}技术亮点总结
- 原生文本选择:使用浏览器原生API,提供自然的用户体验
- XPath定位:精确记录标注位置,支持页面刷新后恢复
- iframe隔离:标注逻辑与文档内容隔离,互不干扰
- 跨页面通信:postMessage + 直接API双重保障
- 数据持久化:localStorage自动保存,无需手动操作
- 表格处理:支持表格单元格选择、高亮和转换为JSON格式,处理rowspan和colspan
- 模块化设计:代码结构清晰,易于维护和扩展
- 动态配置:支持不同场景的灵活配置
注意事项
- 跨域限制:如果iframe加载跨域内容,可能无法直接访问其DOM,需要使用postMessage通信
- XPath精度:复杂DOM结构下,XPath可能不够精确,需要结合文本搜索
- 浏览器兼容性:某些旧版浏览器可能不支持部分API,需要做兼容处理
- 性能考虑:大量标注时,恢复标注可能影响页面加载性能,需要优化
- 表格复杂性:处理包含复杂rowspan和colspan的表格时,需要仔细处理单元格的映射关系
总结
这个HTML文档标注工具通过iframe架构、原生文本选择、XPath定位、表格处理等技术,实现了一个功能完善、用户体验良好的标注系统。其模块化设计和动态配置机制,使得工具具有良好的可扩展性和灵活性。特别是对表格内容的特殊处理,能够准确识别和转换表格数据,为结构化数据提取提供了有力支持。这些技术方案也可以应用到其他需要HTML内容标注、内容提取、表格处理等场景的项目中。
贡献者
更新日志
2025/12/2 10:33
查看所有更新日志
78b0f-docs(blog): 删除重复标题于7676b-docs(blog): 更新文档标注工具开源地址于a45ad-docs(blog): 添加项目开源地址和RepoCard组件于30e79-docs(blog): 添加基于iframe和原生文本选择的文档标注工具技术实现博客文章于
版权所有
版权归属:ntzw
许可证:CC0 1.0 通用 (CC0)
