You've already forked DataMate
feat(annotation): 实现自定义数据标注结果面板
实现功能: - 替换 Label Studio 自带的侧边栏,使用自定义结果面板 - 支持通用区域关系标注(任意标注区域之间建立关系) - 实时同步 Label Studio 的标注变更 - 双向联动:点击面板区域可高亮 LS 内对应区域,反之亦然 - 快捷标注关系:关系拾取模式、CRUD 操作、自动切换 Tab - 保存联动:自动合并面板关系到标注结果,判断是否已标注 技术实现: - 新增 5 个组件: - annotation-result.types.ts: TypeScript 类型定义 - RegionList.tsx: 区域列表组件 - RelationEditor.tsx: 关系编辑弹窗 - RelationList.tsx: 关系列表组件 - AnnotationResultPanel.tsx: 主面板组件(300px,可折叠,Tabs 切换) - 修改 2 个文件: - lsf.html: 消息协议扩展、防抖广播、区域选择监听、事件绑定/解绑 - LabelStudioTextEditor.tsx: 移除 LS 侧边栏、集成自定义面板、消息处理、taskId 校验 关键设计: - 单向读取 + 保存时合并:避免复杂的双向同步 - _source: 'panel' 标记:区分面板创建的关系和 LS 原生关系 - 150ms 防抖广播:避免消息洪泛 - 幂等事件绑定:避免监听器累积 - taskId 校验:防止跨任务消息混乱 代码审查: - 经过 3 轮 codex 审查,所有问题已修复 - 构建成功,Lint 检查通过 - 事件绑定/解绑结构清晰,幂等性处理合理 - 跨任务消息校验与状态更新路径一致性明显提升
This commit is contained in:
@@ -59,6 +59,151 @@
|
||||
|
||||
let lsInstance = null;
|
||||
let currentTask = null;
|
||||
let broadcastTimer = null;
|
||||
let lastSelectedRegionId = null;
|
||||
let regionSelectionBound = false;
|
||||
|
||||
/**
|
||||
* Build display-friendly region info from a serialized result item.
|
||||
*/
|
||||
function buildRegionDisplay(item) {
|
||||
if (!item || typeof item !== "object") return { displayText: "", displayLabel: "" };
|
||||
var val = item.value || {};
|
||||
var displayLabel = "";
|
||||
var displayText = "";
|
||||
|
||||
// Extract label
|
||||
if (Array.isArray(val.labels) && val.labels.length > 0) {
|
||||
displayLabel = val.labels.join(", ");
|
||||
} else if (Array.isArray(val.choices) && val.choices.length > 0) {
|
||||
displayLabel = val.choices.join(", ");
|
||||
} else if (Array.isArray(val.taxonomy) && val.taxonomy.length > 0) {
|
||||
displayLabel = val.taxonomy.map(function(t) { return Array.isArray(t) ? t.join("/") : String(t); }).join(", ");
|
||||
} else if (val.rating !== undefined) {
|
||||
displayLabel = "Rating: " + val.rating;
|
||||
} else {
|
||||
displayLabel = item.type || "";
|
||||
}
|
||||
|
||||
// Extract text
|
||||
if (typeof val.text === "string") {
|
||||
displayText = val.text.length > 80 ? val.text.substring(0, 80) + "..." : val.text;
|
||||
} else if (typeof val.textarea === "string") {
|
||||
displayText = val.textarea.length > 80 ? val.textarea.substring(0, 80) + "..." : val.textarea;
|
||||
} else if (Array.isArray(val.text)) {
|
||||
displayText = val.text.join(" ").substring(0, 80);
|
||||
} else if (val.x !== undefined && val.y !== undefined) {
|
||||
displayText = "(" + Math.round(val.x) + ", " + Math.round(val.y) + ")";
|
||||
if (val.width !== undefined) displayText += " " + Math.round(val.width) + "x" + Math.round(val.height);
|
||||
} else if (val.start !== undefined && val.end !== undefined) {
|
||||
displayText = "[" + val.start + ":" + val.end + "]";
|
||||
}
|
||||
|
||||
return { displayText: displayText, displayLabel: displayLabel };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and broadcast region selection changes.
|
||||
* LS 1.7.1 has no onSelectRegion callback, so we poll the store.
|
||||
*/
|
||||
function checkRegionSelection() {
|
||||
try {
|
||||
if (!lsInstance) return;
|
||||
var store = pickAnnotationStore(lsInstance);
|
||||
var annotation = resolveSelectedAnnotation(store);
|
||||
if (!annotation) return;
|
||||
// LS stores the selected region on the annotation object
|
||||
var selected = annotation.highlightedNode || null;
|
||||
var regionId = (selected && selected.id) ? String(selected.id) : null;
|
||||
var taskId = typeof currentTask?.id === "number" ? currentTask.id : Number(currentTask?.id) || null;
|
||||
if (regionId !== lastSelectedRegionId) {
|
||||
lastSelectedRegionId = regionId;
|
||||
postToParent("LS_REGION_SELECTED", { regionId: regionId, taskId: taskId });
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function handleLsClick() {
|
||||
setTimeout(checkRegionSelection, 50);
|
||||
}
|
||||
function handleLsMouseup() {
|
||||
setTimeout(checkRegionSelection, 100);
|
||||
}
|
||||
|
||||
function bindRegionSelectionListeners() {
|
||||
if (regionSelectionBound) return;
|
||||
var lsRoot = document.getElementById("label-studio");
|
||||
if (!lsRoot) return;
|
||||
lsRoot.addEventListener("click", handleLsClick);
|
||||
lsRoot.addEventListener("mouseup", handleLsMouseup);
|
||||
regionSelectionBound = true;
|
||||
}
|
||||
|
||||
function unbindRegionSelectionListeners() {
|
||||
var lsRoot = document.getElementById("label-studio");
|
||||
if (lsRoot) {
|
||||
lsRoot.removeEventListener("click", handleLsClick);
|
||||
lsRoot.removeEventListener("mouseup", handleLsMouseup);
|
||||
}
|
||||
regionSelectionBound = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast current annotation state to parent (debounced).
|
||||
*/
|
||||
function broadcastAnnotationState() {
|
||||
if (broadcastTimer) clearTimeout(broadcastTimer);
|
||||
broadcastTimer = setTimeout(function() {
|
||||
broadcastTimer = null;
|
||||
try {
|
||||
if (!lsInstance) return;
|
||||
var store = pickAnnotationStore(lsInstance);
|
||||
if (!store) return;
|
||||
var selected = resolveSelectedAnnotation(store);
|
||||
if (!selected) return;
|
||||
|
||||
var serialized = null;
|
||||
if (typeof selected.serializeAnnotation === "function") {
|
||||
serialized = selected.serializeAnnotation();
|
||||
} else if (typeof selected.serialize === "function") {
|
||||
serialized = selected.serialize();
|
||||
}
|
||||
|
||||
var result = [];
|
||||
if (Array.isArray(serialized)) {
|
||||
result = serialized;
|
||||
} else if (serialized && typeof serialized === "object") {
|
||||
result = Array.isArray(serialized.result) ? serialized.result : [];
|
||||
}
|
||||
|
||||
var taskId = typeof currentTask?.id === "number" ? currentTask.id : Number(currentTask?.id) || null;
|
||||
|
||||
// Build region info (exclude relations)
|
||||
var regions = [];
|
||||
for (var i = 0; i < result.length; i++) {
|
||||
var item = result[i];
|
||||
if (item && item.type !== "relation") {
|
||||
var display = buildRegionDisplay(item);
|
||||
regions.push({
|
||||
id: item.id || ("r" + i),
|
||||
type: item.type || "",
|
||||
from_name: item.from_name || "",
|
||||
to_name: item.to_name || "",
|
||||
value: item.value || {},
|
||||
displayText: display.displayText,
|
||||
displayLabel: display.displayLabel,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
postToParent("LS_ANNOTATION_CHANGED", {
|
||||
taskId: taskId,
|
||||
result: result,
|
||||
regions: regions,
|
||||
});
|
||||
} catch (_) {}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function postToParent(type, payload) {
|
||||
window.parent.postMessage({ type, payload }, ORIGIN);
|
||||
@@ -78,6 +223,8 @@
|
||||
});
|
||||
|
||||
function destroyLabelStudio() {
|
||||
unbindRegionSelectionListeners();
|
||||
|
||||
try {
|
||||
if (lsInstance && typeof lsInstance.destroy === "function") {
|
||||
lsInstance.destroy();
|
||||
@@ -86,6 +233,7 @@
|
||||
|
||||
lsInstance = null;
|
||||
currentTask = null;
|
||||
lastSelectedRegionId = null;
|
||||
|
||||
const root = document.getElementById("label-studio");
|
||||
if (root) root.innerHTML = "";
|
||||
@@ -359,9 +507,15 @@
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Broadcast initial annotation state to parent panel
|
||||
broadcastAnnotationState();
|
||||
|
||||
// Bind region selection listeners (idempotent - only binds once)
|
||||
bindRegionSelectionListeners();
|
||||
|
||||
postToParent("LS_READY", { taskId: task?.id || null });
|
||||
},
|
||||
// 让内嵌编辑器的“提交/保存”按钮也能触发父页面保存
|
||||
// 让内嵌编辑器的"提交/保存"按钮也能触发父页面保存
|
||||
onSubmitAnnotation: function () {
|
||||
try {
|
||||
const raw = exportSelectedAnnotation();
|
||||
@@ -370,6 +524,23 @@
|
||||
postToParent("LS_ERROR", { message: e?.message || String(e) });
|
||||
}
|
||||
},
|
||||
// Annotation change callbacks -> broadcast to parent panel
|
||||
onUpdateAnnotation: function () {
|
||||
broadcastAnnotationState();
|
||||
setTimeout(checkRegionSelection, 50);
|
||||
},
|
||||
onEntityCreate: function () {
|
||||
broadcastAnnotationState();
|
||||
setTimeout(checkRegionSelection, 50);
|
||||
},
|
||||
onEntityDelete: function () {
|
||||
broadcastAnnotationState();
|
||||
setTimeout(checkRegionSelection, 50);
|
||||
},
|
||||
onSelectAnnotation: function () {
|
||||
broadcastAnnotationState();
|
||||
setTimeout(checkRegionSelection, 50);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -415,6 +586,26 @@
|
||||
postToParent("LS_PONG", {});
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "LS_SELECT_REGION") {
|
||||
var regionId = msg.payload && msg.payload.regionId;
|
||||
if (regionId && lsInstance) {
|
||||
var regionStore = pickAnnotationStore(lsInstance);
|
||||
var annotation = resolveSelectedAnnotation(regionStore);
|
||||
if (annotation && annotation.regionStore) {
|
||||
var regions = annotation.regionStore.regions || [];
|
||||
for (var ri = 0; ri < regions.length; ri++) {
|
||||
if (String(regions[ri].id) === String(regionId)) {
|
||||
if (typeof regions[ri].selectRegion === "function") {
|
||||
regions[ri].selectRegion();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
postToParent("LS_ERROR", { message: e?.message || String(e) });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user