File size: 11,584 Bytes
494c9e4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 | /**
* Token位置计算器
* 负责计算token在DOM中的像素位置,处理Unicode/UTF-16转换
*/
import {FrontendAnalyzeResult} from "../api/GLTR_API";
import {TokenFragmentRect} from "./types";
interface TextNodeIndexEntry {
node: Text;
startOffset: number; // Unicode字符偏移起始位置
endOffset: number; // Unicode字符偏移结束位置
utf16Start: number; // UTF-16代码单元起始位置
utf16End: number; // UTF-16代码单元结束位置
charToUtf16Map: number[]; // 字符偏移到UTF-16偏移的映射表
}
export class TokenPositionCalculator {
private textNodeIndex?: TextNodeIndexEntry[];
private baseNode: HTMLElement;
constructor(baseNode: HTMLElement) {
this.baseNode = baseNode;
}
/** 与 calculateTokenPositions 一致的累积 zoom(Range → 覆盖层坐标) */
getZoom(): number {
return this.getAccumulatedZoom(this.baseNode);
}
/**
* 获取元素及其祖先的累积 zoom 值
* CSS zoom 会影响 getBoundingClientRect() 但不影响 clientWidth/clientHeight
*
* 注意事项:
* - CSS zoom 不是标准属性(Chrome/Safari 支持,Firefox 126+ 支持)
*
* @param element 目标元素
* @returns 累积的 zoom 值
*/
private getAccumulatedZoom(element: HTMLElement): number {
let zoom = 1;
let current: HTMLElement | null = element;
let depth = 0;
const MAX_DEPTH = 50; // 防止无限循环
while (current && depth < MAX_DEPTH) {
const style = window.getComputedStyle(current);
// 检查浏览器是否支持 zoom 属性(兼容性处理)
if (typeof style.zoom === 'string' && style.zoom !== '' && style.zoom !== 'normal') {
const elementZoom = parseFloat(style.zoom);
if (!isNaN(elementZoom) && elementZoom > 0) {
zoom *= elementZoom;
} else if (!isNaN(elementZoom) && elementZoom <= 0) {
console.warn(`[TokenPositionCalculator] Invalid zoom value: ${style.zoom}`, current);
// 忽略无效 zoom 值,继续使用当前累积值
}
}
current = current.parentElement;
depth++;
}
if (depth >= MAX_DEPTH) {
console.warn(`[TokenPositionCalculator] DOM depth exceeded ${MAX_DEPTH}, stopping zoom calculation`);
}
return zoom;
}
/**
* 计算token的像素位置(一次性计算,避免重复遍历)
* @param rd 分析结果数据
* @param fromTokenIndex 只计算 index >= fromTokenIndex 的 token,默认 0(全量)
* @returns token位置数组
*/
calculateTokenPositions(rd: FrontendAnalyzeResult, fromTokenIndex = 0): TokenFragmentRect[] {
if (!this.baseNode) return [];
const positions: TokenFragmentRect[] = [];
const containerRect = this.baseNode.getBoundingClientRect();
// 获取累积的 zoom 值,用于将 getBoundingClientRect 坐标转换回未缩放坐标
// 这样输出的坐标与 clientWidth/clientHeight 一致
const zoom = this.getAccumulatedZoom(this.baseNode);
// 过滤有效token(跳过已处理的旧 token)
const validTokens = rd.bpe_strings.map((tokenObj, index) => ({
tokenObj,
index,
offset: tokenObj.offset
})).filter(({ index, offset }) => {
if (index < fromTokenIndex) return false;
const [start, end] = offset;
return !(start === end || start < 0 || end < 0 || end <= start);
});
// 一次性计算所有token位置
validTokens.forEach(({ tokenObj, index, offset }) => {
const [start, end] = offset;
// 使用findNodeAndOffset找到文本节点和偏移
const startResult = this.findNodeAndOffset(start);
const endResult = this.findNodeAndOffset(end);
if (!startResult || !endResult) {
console.warn(`⚠️ 无法找到token ${index} 的位置 (${start}, ${end})`);
return;
}
// 创建Range对象
const range = document.createRange();
range.setStart(startResult.node, startResult.offset);
range.setEnd(endResult.node, endResult.offset);
// 获取各段像素坐标(token可能被拆到多行)
const rectList = Array.from(range.getClientRects());
const fragments = rectList.length > 0 ? rectList : [range.getBoundingClientRect()];
fragments.forEach((rect, fragmentIndex) => {
if (!rect || rect.height === 0) {
return;
}
const hScaled = rect.height / zoom;
if (hScaled <= 0) {
return;
}
// 保留 Range 的真实宽度。iOS/WebKit 在换行后的 token 前可能返回上一行行尾的
// 零宽 rect;如果这里提前改成占位宽,DAG 等下游就无法识别并过滤这个幽灵片。
const wRaw = rect.width / zoom;
// 将 getBoundingClientRect 的缩放坐标转换回未缩放坐标(与 clientWidth/clientHeight 一致)
const tokenPos = {
tokenIndex: index,
fragmentIndex,
fragmentCount: fragments.length,
rectKey: `${index}-${fragmentIndex}`,
x: (rect.left - containerRect.left) / zoom,
y: (rect.top - containerRect.top) / zoom,
width: wRaw,
height: hScaled
};
positions.push(tokenPos);
});
});
return positions;
}
/**
* 构建文本节点索引,用于优化findNodeAndOffset的查找性能
* 从O(n)的线性遍历优化为O(log n)的二分查找
*/
buildTextNodeIndex(): void {
if (!this.baseNode) {
this.textNodeIndex = undefined;
return;
}
const index: TextNodeIndexEntry[] = [];
let currentCharOffset = 0; // Unicode字符偏移(code points)
let currentUtf16Offset = 0; // UTF-16代码单元偏移
// 遍历所有文本节点
const walker = document.createTreeWalker(
this.baseNode,
NodeFilter.SHOW_TEXT,
null
);
let node: Text;
while (node = walker.nextNode() as Text) {
const nodeText = node.textContent || '';
// 使用Array.from()将字符串转换为字符数组(正确处理Unicode字符)
// 注意:这里只在构建索引时执行一次,后续查找时不再执行
const nodeChars = Array.from(nodeText);
const nodeCharLength = nodeChars.length; // Unicode字符数
const nodeUtf16Length = nodeText.length; // UTF-16代码单元长度(相对于当前节点)
const startOffset = currentCharOffset;
const endOffset = currentCharOffset + nodeCharLength;
const utf16Start = currentUtf16Offset;
const utf16End = currentUtf16Offset + nodeUtf16Length;
// 预计算字符偏移到UTF-16偏移的映射表
// charToUtf16Map[i] 表示第i个字符(Unicode字符)在「当前文本节点内部」对应的UTF-16偏移
const charToUtf16Map: number[] = new Array(nodeCharLength + 1); // +1 用于包含末尾位置
let utf16Pos = 0;
// 对于每个字符位置,计算其对应的UTF-16偏移
for (let i = 0; i <= nodeCharLength; i++) {
charToUtf16Map[i] = utf16Pos;
if (i < nodeCharLength) {
// 当前字符的UTF-16长度
const char = nodeChars[i];
utf16Pos += char.length; // 字符的UTF-16长度(对于emoji可能是2)
}
}
index.push({
node,
startOffset,
endOffset,
utf16Start,
utf16End,
charToUtf16Map
});
currentCharOffset += nodeCharLength;
currentUtf16Offset += nodeUtf16Length;
}
this.textNodeIndex = index;
}
/**
* 根据全局字符偏移找到对应的文本节点和局部偏移
* 使用二分查找优化性能:从O(n)优化为O(log n)
* 正确处理Unicode字符(包括emoji):将Unicode字符偏移转换为UTF-16代码单元偏移
*
* @param globalOffset Unicode字符偏移(code points),来自Python的offset_mapping
* @returns 文本节点和UTF-16代码单元偏移(Range API需要)
*/
findNodeAndOffset(globalOffset: number): { node: Text, offset: number } | null {
// 如果索引不存在或为空,回退到构建索引
if (!this.textNodeIndex || this.textNodeIndex.length === 0) {
this.buildTextNodeIndex();
if (!this.textNodeIndex || this.textNodeIndex.length === 0) {
return null;
}
}
const index = this.textNodeIndex;
// 二分查找:找到包含globalOffset的节点
let left = 0;
let right = index.length - 1;
let foundIndex = -1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const entry = index[mid];
if (globalOffset >= entry.startOffset && globalOffset < entry.endOffset) {
foundIndex = mid;
break;
} else if (globalOffset < entry.startOffset) {
right = mid - 1;
} else {
left = mid + 1;
}
}
// 处理边界情况:globalOffset正好等于最后一个节点的结束位置
if (foundIndex === -1 && index.length > 0) {
const lastEntry = index[index.length - 1];
if (globalOffset === lastEntry.endOffset) {
foundIndex = index.length - 1;
// 使用预计算的映射表获取末尾位置的UTF-16偏移
const lastLocalCharOffset = lastEntry.endOffset - lastEntry.startOffset;
const utf16Offset = lastEntry.charToUtf16Map[lastLocalCharOffset];
return { node: lastEntry.node, offset: utf16Offset };
}
return null;
}
if (foundIndex === -1) {
return null;
}
const entry = index[foundIndex];
const localCharOffset = globalOffset - entry.startOffset;
// 使用预计算的映射表直接查表,避免重复的Array.from、slice、join操作
// 这是性能优化的关键:从O(n)的字符串操作优化为O(1)的数组查表
const utf16Offset = entry.charToUtf16Map[localCharOffset];
// 注意:Range.setStart/End 需要的是「相对于当前文本节点」的UTF-16偏移
// charToUtf16Map 已经存的是局部偏移,无需再加上 utf16Start
return { node: entry.node, offset: utf16Offset };
}
/**
* 重置索引(当文本内容变化时调用)
*/
resetIndex(): void {
this.textNodeIndex = undefined;
}
}
|