spanmark 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spanmark/__init__.py +17 -0
- spanmark/_autosave.py +237 -0
- spanmark/_model.py +290 -0
- spanmark/_session.py +896 -0
- spanmark/_source.py +179 -0
- spanmark/_storage.py +97 -0
- spanmark/_version.py +24 -0
- spanmark/_widget.py +41 -0
- spanmark/static/widget.css +308 -0
- spanmark/static/widget.js +851 -0
- spanmark-0.1.0.dist-info/METADATA +436 -0
- spanmark-0.1.0.dist-info/RECORD +14 -0
- spanmark-0.1.0.dist-info/WHEEL +4 -0
- spanmark-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,851 @@
|
|
|
1
|
+
function render({ model, el }) {
|
|
2
|
+
let selectedLabel = model.get("labels")[0] || null;
|
|
3
|
+
let selectedSpanId = null;
|
|
4
|
+
let history = [];
|
|
5
|
+
let railFrame = null;
|
|
6
|
+
|
|
7
|
+
// Okabe-Ito color-universal palette. Color is always paired with label text,
|
|
8
|
+
// so it remains a redundant cue rather than the only way to identify a label.
|
|
9
|
+
const PALETTE = [
|
|
10
|
+
"#E69F00", "#56B4E9", "#009E73", "#F0E442",
|
|
11
|
+
"#0072B2", "#D55E00", "#CC79A7", "#000000"
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
function colorForLabel(label) {
|
|
15
|
+
const labels = model.get("labels");
|
|
16
|
+
const index = Math.max(0, labels.indexOf(label));
|
|
17
|
+
return PALETTE[index % PALETTE.length];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function contrastTextForColor(color) {
|
|
21
|
+
const channel = value => {
|
|
22
|
+
const srgb = value / 255;
|
|
23
|
+
return srgb <= 0.04045
|
|
24
|
+
? srgb / 12.92
|
|
25
|
+
: Math.pow((srgb + 0.055) / 1.055, 2.4);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const r = channel(Number.parseInt(color.slice(1, 3), 16));
|
|
29
|
+
const g = channel(Number.parseInt(color.slice(3, 5), 16));
|
|
30
|
+
const b = channel(Number.parseInt(color.slice(5, 7), 16));
|
|
31
|
+
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
32
|
+
const whiteContrast = 1.05 / (luminance + 0.05);
|
|
33
|
+
const blackContrast = (luminance + 0.05) / 0.05;
|
|
34
|
+
return whiteContrast >= blackContrast ? "#ffffff" : "#000000";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
el.innerHTML = "";
|
|
38
|
+
el.classList.add("so-root");
|
|
39
|
+
el.tabIndex = 0;
|
|
40
|
+
|
|
41
|
+
const head = document.createElement("div");
|
|
42
|
+
head.className = "so-head";
|
|
43
|
+
const doc = document.createElement("div");
|
|
44
|
+
doc.className = "so-doc";
|
|
45
|
+
const progress = document.createElement("div");
|
|
46
|
+
progress.className = "so-progress";
|
|
47
|
+
head.append(doc, progress);
|
|
48
|
+
|
|
49
|
+
const labelbar = document.createElement("div");
|
|
50
|
+
labelbar.className = "so-labelbar";
|
|
51
|
+
|
|
52
|
+
const textBox = document.createElement("div");
|
|
53
|
+
textBox.className = "so-text";
|
|
54
|
+
|
|
55
|
+
const textLayer = document.createElement("div");
|
|
56
|
+
textLayer.className = "so-textlayer";
|
|
57
|
+
|
|
58
|
+
const railLayer = document.createElement("div");
|
|
59
|
+
railLayer.className = "so-rail-layer";
|
|
60
|
+
|
|
61
|
+
textBox.append(textLayer, railLayer);
|
|
62
|
+
|
|
63
|
+
const actions = document.createElement("div");
|
|
64
|
+
actions.className = "so-actions";
|
|
65
|
+
|
|
66
|
+
const completion = document.createElement("div");
|
|
67
|
+
completion.className = "so-completion";
|
|
68
|
+
|
|
69
|
+
const help = document.createElement("div");
|
|
70
|
+
help.className = "so-help";
|
|
71
|
+
help.innerHTML =
|
|
72
|
+
'Select text to add a span. Click an annotation to select it; ' +
|
|
73
|
+
'double-click to remove it. ' +
|
|
74
|
+
'<span class="so-key">A</span> accept, ' +
|
|
75
|
+
'<span class="so-key">X</span> reject, ' +
|
|
76
|
+
'<span class="so-key">Space</span> ignore, ' +
|
|
77
|
+
'<span class="so-key">F</span> flag, ' +
|
|
78
|
+
'<span class="so-key">Backspace / Mac Delete</span> undo previous decision, ' +
|
|
79
|
+
'<span class="so-key">1–9</span> choose/relabel, ' +
|
|
80
|
+
'<span class="so-key">Del / Mac Fn+Delete</span> remove selected span, ' +
|
|
81
|
+
'<span class="so-key">Ctrl/Cmd+Z</span> undo span edit, ' +
|
|
82
|
+
'<span class="so-key">Esc</span> clear span selection.';
|
|
83
|
+
|
|
84
|
+
const status = document.createElement("div");
|
|
85
|
+
status.className = "so-status";
|
|
86
|
+
|
|
87
|
+
el.append(head, labelbar, textBox, actions, completion, help, status);
|
|
88
|
+
|
|
89
|
+
function emit(type, extra = {}) {
|
|
90
|
+
model.set("event", {
|
|
91
|
+
type,
|
|
92
|
+
nonce: Date.now() + Math.random(),
|
|
93
|
+
...extra,
|
|
94
|
+
});
|
|
95
|
+
model.save_changes();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function makeButton(label, onclick, className = "") {
|
|
99
|
+
const button = document.createElement("button");
|
|
100
|
+
button.type = "button";
|
|
101
|
+
button.textContent = label;
|
|
102
|
+
if (className) button.className = className;
|
|
103
|
+
button.addEventListener("click", () => {
|
|
104
|
+
onclick();
|
|
105
|
+
el.focus({ preventScroll: true });
|
|
106
|
+
});
|
|
107
|
+
return button;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function cancelRailFrame() {
|
|
111
|
+
if (railFrame !== null) {
|
|
112
|
+
cancelAnimationFrame(railFrame);
|
|
113
|
+
railFrame = null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function spanId(span, index) {
|
|
118
|
+
return String(span._id ?? `${span.start}:${span.end}:${span.label}:${index}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function pushHistory() {
|
|
122
|
+
history.push(JSON.parse(JSON.stringify(model.get("spans"))));
|
|
123
|
+
if (history.length > 100) history.shift();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function setSpans(spans) {
|
|
127
|
+
model.set("spans", spans);
|
|
128
|
+
model.set("status", "");
|
|
129
|
+
model.save_changes();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function undoEdit() {
|
|
133
|
+
if (!history.length) {
|
|
134
|
+
model.set("status", "Nothing to undo.");
|
|
135
|
+
model.save_changes();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
selectedSpanId = null;
|
|
139
|
+
setSpans(history.pop());
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function codePoints(text) {
|
|
143
|
+
return Array.from(text);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function buildCodePointToCodeUnitMap(text) {
|
|
147
|
+
const cps = codePoints(text);
|
|
148
|
+
const map = [0];
|
|
149
|
+
let cu = 0;
|
|
150
|
+
for (const cp of cps) {
|
|
151
|
+
cu += cp.length;
|
|
152
|
+
map.push(cu);
|
|
153
|
+
}
|
|
154
|
+
return map;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function characterOffset(root, node, offset) {
|
|
158
|
+
const range = document.createRange();
|
|
159
|
+
range.selectNodeContents(root);
|
|
160
|
+
range.setEnd(node, offset);
|
|
161
|
+
|
|
162
|
+
const fragment = range.cloneContents();
|
|
163
|
+
fragment.querySelectorAll?.(".so-inline-label").forEach(x => x.remove());
|
|
164
|
+
|
|
165
|
+
return codePoints(fragment.textContent).length;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function renderHead() {
|
|
169
|
+
const i = model.get("index") + 1;
|
|
170
|
+
const total = model.get("total");
|
|
171
|
+
const decided = model.get("decided_count");
|
|
172
|
+
const flagged = model.get("flagged_count");
|
|
173
|
+
doc.textContent = `Document: ${model.get("doc_id")}`;
|
|
174
|
+
progress.textContent = model.get("reviewing_flagged")
|
|
175
|
+
? `Flagged review • ${flagged} remaining`
|
|
176
|
+
: `${i} / ${total} • ${decided} decided • ${flagged} flagged`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function relabelSelected(label) {
|
|
180
|
+
if (selectedSpanId === null) return false;
|
|
181
|
+
|
|
182
|
+
const spans = [...model.get("spans")];
|
|
183
|
+
const i = spans.findIndex((s, idx) => spanId(s, idx) === selectedSpanId);
|
|
184
|
+
if (i < 0) {
|
|
185
|
+
selectedSpanId = null;
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
pushHistory();
|
|
190
|
+
const updated = {
|
|
191
|
+
...spans[i],
|
|
192
|
+
label,
|
|
193
|
+
source: "user",
|
|
194
|
+
};
|
|
195
|
+
delete updated.score;
|
|
196
|
+
spans[i] = updated;
|
|
197
|
+
setSpans(spans);
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function chooseLabel(label) {
|
|
202
|
+
selectedLabel = label;
|
|
203
|
+
relabelSelected(label);
|
|
204
|
+
renderLabels();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function renderLabels() {
|
|
208
|
+
labelbar.innerHTML = "";
|
|
209
|
+
const labels = model.get("labels");
|
|
210
|
+
|
|
211
|
+
if (!labels.includes(selectedLabel)) {
|
|
212
|
+
selectedLabel = labels[0] || null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
labels.forEach((label, i) => {
|
|
216
|
+
const button = makeButton(`${i + 1}: ${label}`, () => chooseLabel(label));
|
|
217
|
+
const color = colorForLabel(label);
|
|
218
|
+
button.classList.add("so-label-button");
|
|
219
|
+
button.style.setProperty("--so-label-color", color);
|
|
220
|
+
button.style.setProperty(
|
|
221
|
+
"--so-label-foreground",
|
|
222
|
+
contrastTextForColor(color)
|
|
223
|
+
);
|
|
224
|
+
if (label === selectedLabel) button.classList.add("active");
|
|
225
|
+
labelbar.appendChild(button);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
labelbar.appendChild(
|
|
229
|
+
makeButton("Clear spans", () => {
|
|
230
|
+
if (!model.get("spans").length) return;
|
|
231
|
+
if (!window.confirm("Remove all spans from this document?")) return;
|
|
232
|
+
pushHistory();
|
|
233
|
+
selectedSpanId = null;
|
|
234
|
+
setSpans([]);
|
|
235
|
+
})
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function syncSelectedOverlay() {
|
|
240
|
+
railLayer.querySelectorAll("[data-span-id]").forEach(node => {
|
|
241
|
+
node.classList.toggle(
|
|
242
|
+
"so-selected",
|
|
243
|
+
node.dataset.spanId === selectedSpanId
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
textLayer.querySelectorAll("mark[data-span-id]").forEach(node => {
|
|
247
|
+
node.classList.toggle(
|
|
248
|
+
"so-selected",
|
|
249
|
+
node.dataset.spanId === selectedSpanId
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function assignRailLanes(spans) {
|
|
255
|
+
const laneEnds = [];
|
|
256
|
+
const laneById = {};
|
|
257
|
+
|
|
258
|
+
for (const span of spans) {
|
|
259
|
+
let lane = 0;
|
|
260
|
+
while (
|
|
261
|
+
lane < laneEnds.length &&
|
|
262
|
+
laneEnds[lane] > Number(span.start)
|
|
263
|
+
) {
|
|
264
|
+
lane += 1;
|
|
265
|
+
}
|
|
266
|
+
laneEnds[lane] = Number(span.end);
|
|
267
|
+
laneById[spanId(span, span.__renderIndex)] = lane;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return laneById;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function renderRails() {
|
|
274
|
+
cancelRailFrame();
|
|
275
|
+
railLayer.innerHTML = "";
|
|
276
|
+
|
|
277
|
+
const text = model.get("text");
|
|
278
|
+
const spans = [...model.get("spans")]
|
|
279
|
+
.map((s, i) => ({ ...s, __renderIndex: i }))
|
|
280
|
+
.sort((a, b) =>
|
|
281
|
+
a.start - b.start ||
|
|
282
|
+
b.end - a.end ||
|
|
283
|
+
String(a.label).localeCompare(String(b.label))
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
const laneById = assignRailLanes(spans);
|
|
287
|
+
const laneValues = Object.values(laneById);
|
|
288
|
+
const maxLane = laneValues.length ? Math.max(...laneValues) : -1;
|
|
289
|
+
|
|
290
|
+
const laneStep = 22;
|
|
291
|
+
const extra = maxLane >= 0 ? (maxLane + 1) * laneStep + 3 : 0;
|
|
292
|
+
textBox.style.setProperty("--so-rail-extra", `${extra}px`);
|
|
293
|
+
|
|
294
|
+
railFrame = requestAnimationFrame(() => {
|
|
295
|
+
railFrame = null;
|
|
296
|
+
railLayer.innerHTML = "";
|
|
297
|
+
if (!textLayer.firstChild || !spans.length) return;
|
|
298
|
+
|
|
299
|
+
const cpToCu = buildCodePointToCodeUnitMap(text);
|
|
300
|
+
const textNode = textLayer.firstChild;
|
|
301
|
+
const box = textBox.getBoundingClientRect();
|
|
302
|
+
const railOffset = 3;
|
|
303
|
+
|
|
304
|
+
function selectSpanFromEvent(sid, span) {
|
|
305
|
+
selectedSpanId = selectedSpanId === sid ? null : sid;
|
|
306
|
+
if (selectedSpanId !== null) {
|
|
307
|
+
selectedLabel = span.label;
|
|
308
|
+
}
|
|
309
|
+
renderLabels();
|
|
310
|
+
syncSelectedOverlay();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function removeSpanById(sid) {
|
|
314
|
+
pushHistory();
|
|
315
|
+
const current = model.get("spans");
|
|
316
|
+
const filtered = current.filter(
|
|
317
|
+
(s, idx) => spanId(s, idx) !== sid
|
|
318
|
+
);
|
|
319
|
+
selectedSpanId = null;
|
|
320
|
+
setSpans(filtered);
|
|
321
|
+
window.getSelection()?.removeAllRanges();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
for (const span of spans) {
|
|
325
|
+
const sid = spanId(span, span.__renderIndex);
|
|
326
|
+
const lane = laneById[sid];
|
|
327
|
+
const color = colorForLabel(span.label);
|
|
328
|
+
|
|
329
|
+
const startCp = Math.max(
|
|
330
|
+
0, Math.min(cpToCu.length - 1, Number(span.start))
|
|
331
|
+
);
|
|
332
|
+
const endCp = Math.max(
|
|
333
|
+
0, Math.min(cpToCu.length - 1, Number(span.end))
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
const range = document.createRange();
|
|
337
|
+
range.setStart(textNode, cpToCu[startCp]);
|
|
338
|
+
range.setEnd(textNode, cpToCu[endCp]);
|
|
339
|
+
|
|
340
|
+
const rects = Array.from(range.getClientRects())
|
|
341
|
+
.filter(rect => rect.width > 0);
|
|
342
|
+
if (!rects.length) continue;
|
|
343
|
+
|
|
344
|
+
const railYFor = rect =>
|
|
345
|
+
rect.bottom - box.top + textBox.scrollTop +
|
|
346
|
+
railOffset + lane * laneStep;
|
|
347
|
+
|
|
348
|
+
rects.forEach(rect => {
|
|
349
|
+
const seg = document.createElement("div");
|
|
350
|
+
seg.className = "so-rail-seg";
|
|
351
|
+
if (span.source === "suggestion") seg.classList.add("so-model");
|
|
352
|
+
if (sid === selectedSpanId) seg.classList.add("so-selected");
|
|
353
|
+
seg.dataset.spanId = sid;
|
|
354
|
+
seg.style.setProperty("--so-span-color", color);
|
|
355
|
+
seg.style.left = `${rect.left - box.left}px`;
|
|
356
|
+
seg.style.width = `${rect.width}px`;
|
|
357
|
+
seg.style.top = `${railYFor(rect)}px`;
|
|
358
|
+
seg.title = `${span.label} (${span.start}, ${span.end})`;
|
|
359
|
+
seg.addEventListener("click", event => {
|
|
360
|
+
event.stopPropagation();
|
|
361
|
+
selectSpanFromEvent(sid, span);
|
|
362
|
+
});
|
|
363
|
+
seg.addEventListener("dblclick", event => {
|
|
364
|
+
event.preventDefault();
|
|
365
|
+
event.stopPropagation();
|
|
366
|
+
removeSpanById(sid);
|
|
367
|
+
});
|
|
368
|
+
railLayer.appendChild(seg);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const firstRect = rects[0];
|
|
372
|
+
const badge = document.createElement("div");
|
|
373
|
+
badge.className = "so-rail-badge";
|
|
374
|
+
if (span.source === "suggestion") badge.classList.add("so-model");
|
|
375
|
+
if (sid === selectedSpanId) badge.classList.add("so-selected");
|
|
376
|
+
badge.dataset.spanId = sid;
|
|
377
|
+
badge.style.setProperty("--so-span-color", color);
|
|
378
|
+
badge.textContent = span.label;
|
|
379
|
+
badge.style.left = `${firstRect.left - box.left}px`;
|
|
380
|
+
badge.style.top = `${railYFor(firstRect) + 5}px`;
|
|
381
|
+
badge.title = `${span.label} (${span.start}, ${span.end})`;
|
|
382
|
+
badge.addEventListener("click", event => {
|
|
383
|
+
event.stopPropagation();
|
|
384
|
+
selectSpanFromEvent(sid, span);
|
|
385
|
+
});
|
|
386
|
+
badge.addEventListener("dblclick", event => {
|
|
387
|
+
event.preventDefault();
|
|
388
|
+
event.stopPropagation();
|
|
389
|
+
removeSpanById(sid);
|
|
390
|
+
});
|
|
391
|
+
railLayer.appendChild(badge);
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function renderInlineText() {
|
|
397
|
+
cancelRailFrame();
|
|
398
|
+
railLayer.innerHTML = "";
|
|
399
|
+
textBox.style.setProperty("--so-rail-extra", "0px");
|
|
400
|
+
|
|
401
|
+
const text = model.get("text") || "";
|
|
402
|
+
const chars = codePoints(text);
|
|
403
|
+
const textLength = chars.length;
|
|
404
|
+
const spans = [...model.get("spans")]
|
|
405
|
+
.map((s, i) => ({ ...s, __renderIndex: i }))
|
|
406
|
+
.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
407
|
+
|
|
408
|
+
textLayer.innerHTML = "";
|
|
409
|
+
let pos = 0;
|
|
410
|
+
|
|
411
|
+
for (const span of spans) {
|
|
412
|
+
const start = Math.max(0, Math.min(textLength, Number(span.start)));
|
|
413
|
+
const end = Math.max(start, Math.min(textLength, Number(span.end)));
|
|
414
|
+
|
|
415
|
+
if (start > pos) {
|
|
416
|
+
textLayer.appendChild(
|
|
417
|
+
document.createTextNode(chars.slice(pos, start).join(""))
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (start < pos) continue;
|
|
422
|
+
|
|
423
|
+
const mark = document.createElement("mark");
|
|
424
|
+
mark.className = "so-inline-span";
|
|
425
|
+
const sid = spanId(span, span.__renderIndex);
|
|
426
|
+
mark.dataset.spanId = sid;
|
|
427
|
+
mark.style.setProperty("--so-span-color", colorForLabel(span.label));
|
|
428
|
+
|
|
429
|
+
if (span.source === "suggestion") {
|
|
430
|
+
mark.classList.add("so-model");
|
|
431
|
+
const scoreText = typeof span.score === "number"
|
|
432
|
+
? ` (${span.score.toFixed(3)})`
|
|
433
|
+
: "";
|
|
434
|
+
mark.title =
|
|
435
|
+
`Model suggestion${scoreText}. Click to select; double-click to remove.`;
|
|
436
|
+
} else {
|
|
437
|
+
mark.title = "Click to select; double-click to remove.";
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (sid === selectedSpanId) {
|
|
441
|
+
mark.classList.add("so-selected");
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
mark.appendChild(
|
|
445
|
+
document.createTextNode(chars.slice(start, end).join(""))
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
const label = document.createElement("span");
|
|
449
|
+
label.className = "so-inline-label";
|
|
450
|
+
label.textContent = span.label;
|
|
451
|
+
mark.appendChild(label);
|
|
452
|
+
|
|
453
|
+
mark.addEventListener("click", event => {
|
|
454
|
+
event.stopPropagation();
|
|
455
|
+
selectedSpanId = selectedSpanId === sid ? null : sid;
|
|
456
|
+
if (selectedSpanId !== null) {
|
|
457
|
+
selectedLabel = span.label;
|
|
458
|
+
}
|
|
459
|
+
renderLabels();
|
|
460
|
+
syncSelectedOverlay();
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
mark.addEventListener("dblclick", event => {
|
|
464
|
+
event.preventDefault();
|
|
465
|
+
event.stopPropagation();
|
|
466
|
+
pushHistory();
|
|
467
|
+
const current = model.get("spans");
|
|
468
|
+
const filtered = current.filter(
|
|
469
|
+
(s, idx) => spanId(s, idx) !== sid
|
|
470
|
+
);
|
|
471
|
+
selectedSpanId = null;
|
|
472
|
+
setSpans(filtered);
|
|
473
|
+
window.getSelection()?.removeAllRanges();
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
textLayer.appendChild(mark);
|
|
477
|
+
pos = end;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (pos < textLength) {
|
|
481
|
+
textLayer.appendChild(
|
|
482
|
+
document.createTextNode(chars.slice(pos).join(""))
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function renderText() {
|
|
488
|
+
if (model.get("allow_overlaps")) {
|
|
489
|
+
textLayer.textContent = model.get("text") || "";
|
|
490
|
+
renderRails();
|
|
491
|
+
} else {
|
|
492
|
+
renderInlineText();
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function deleteSelectedSpan() {
|
|
497
|
+
if (selectedSpanId === null) {
|
|
498
|
+
model.set("status", "Select a span first.");
|
|
499
|
+
model.save_changes();
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const current = model.get("spans");
|
|
504
|
+
const filtered = current.filter(
|
|
505
|
+
(s, idx) => spanId(s, idx) !== selectedSpanId
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
if (filtered.length === current.length) {
|
|
509
|
+
selectedSpanId = null;
|
|
510
|
+
model.set("status", "Selected span was not found.");
|
|
511
|
+
model.save_changes();
|
|
512
|
+
return false;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
pushHistory();
|
|
516
|
+
selectedSpanId = null;
|
|
517
|
+
setSpans(filtered);
|
|
518
|
+
window.getSelection()?.removeAllRanges();
|
|
519
|
+
return true;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function renderActions() {
|
|
523
|
+
actions.innerHTML = "";
|
|
524
|
+
const answer = model.get("answer");
|
|
525
|
+
|
|
526
|
+
for (const [value, label] of [
|
|
527
|
+
["accept", "✓ Accept"],
|
|
528
|
+
["reject", "✕ Reject"],
|
|
529
|
+
["ignore", "– Ignore"],
|
|
530
|
+
]) {
|
|
531
|
+
const button = makeButton(label, () => emit("decision", { answer: value }));
|
|
532
|
+
button.dataset.action = value;
|
|
533
|
+
if (answer === value) button.classList.add("active");
|
|
534
|
+
actions.appendChild(button);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const flag = makeButton(
|
|
538
|
+
"⚑ Flag",
|
|
539
|
+
() => {
|
|
540
|
+
model.set("flagged", !model.get("flagged"));
|
|
541
|
+
model.save_changes();
|
|
542
|
+
}
|
|
543
|
+
);
|
|
544
|
+
flag.dataset.action = "flag";
|
|
545
|
+
if (model.get("flagged")) flag.classList.add("active");
|
|
546
|
+
actions.appendChild(flag);
|
|
547
|
+
|
|
548
|
+
if (!model.get("reviewing_flagged")) {
|
|
549
|
+
const undo = makeButton("↶ Undo", () => emit("undo_decision"));
|
|
550
|
+
undo.dataset.action = "undo";
|
|
551
|
+
undo.disabled = !model.get("can_undo");
|
|
552
|
+
undo.title = model.get("can_undo")
|
|
553
|
+
? "Undo the previous submitted decision"
|
|
554
|
+
: "No previous submitted decision to undo";
|
|
555
|
+
actions.appendChild(undo);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function renderCompletion() {
|
|
560
|
+
const showCompletion =
|
|
561
|
+
model.get("complete") && !model.get("reviewing_flagged");
|
|
562
|
+
|
|
563
|
+
labelbar.style.display = showCompletion ? "none" : "";
|
|
564
|
+
textBox.style.display = showCompletion ? "none" : "";
|
|
565
|
+
actions.style.display = showCompletion ? "none" : "";
|
|
566
|
+
help.style.display = showCompletion ? "none" : "";
|
|
567
|
+
completion.classList.toggle("show", showCompletion);
|
|
568
|
+
|
|
569
|
+
if (!showCompletion) {
|
|
570
|
+
completion.innerHTML = "";
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
completion.innerHTML = "";
|
|
575
|
+
|
|
576
|
+
const title = document.createElement("div");
|
|
577
|
+
title.className = "so-completion-title";
|
|
578
|
+
title.textContent = "✓ Annotation complete";
|
|
579
|
+
|
|
580
|
+
const subtitle = document.createElement("div");
|
|
581
|
+
subtitle.textContent =
|
|
582
|
+
`${model.get("decided_count")} / ${model.get("total")} examples decided`;
|
|
583
|
+
|
|
584
|
+
const counts = document.createElement("div");
|
|
585
|
+
counts.className = "so-completion-counts";
|
|
586
|
+
|
|
587
|
+
for (const [label, value] of [
|
|
588
|
+
["Accept", model.get("accept_count")],
|
|
589
|
+
["Reject", model.get("reject_count")],
|
|
590
|
+
["Ignore", model.get("ignore_count")],
|
|
591
|
+
["Flagged", model.get("flagged_count")],
|
|
592
|
+
]) {
|
|
593
|
+
const item = document.createElement("span");
|
|
594
|
+
item.className = "so-completion-count";
|
|
595
|
+
item.textContent = `${label}: ${value}`;
|
|
596
|
+
counts.appendChild(item);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const completionActions = document.createElement("div");
|
|
600
|
+
completionActions.className = "so-completion-actions";
|
|
601
|
+
|
|
602
|
+
const flagged = model.get("flagged_count");
|
|
603
|
+
if (flagged > 0) {
|
|
604
|
+
const review = makeButton(
|
|
605
|
+
`Review flagged (${flagged})`,
|
|
606
|
+
() => emit("review_flagged")
|
|
607
|
+
);
|
|
608
|
+
completionActions.appendChild(review);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const undo = makeButton("↶ Undo last decision", () => emit("undo_decision"));
|
|
612
|
+
undo.disabled = !model.get("can_undo");
|
|
613
|
+
completionActions.appendChild(undo);
|
|
614
|
+
|
|
615
|
+
completion.append(title, subtitle, counts, completionActions);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function renderStatus() {
|
|
619
|
+
status.textContent = model.get("status") || "";
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
textBox.addEventListener("click", (event) => {
|
|
623
|
+
if (event.target === textBox || event.target === textLayer) {
|
|
624
|
+
selectedSpanId = null;
|
|
625
|
+
syncSelectedOverlay();
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
textLayer.addEventListener("mouseup", () => {
|
|
630
|
+
if (!selectedLabel) return;
|
|
631
|
+
|
|
632
|
+
const selection = window.getSelection();
|
|
633
|
+
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return;
|
|
634
|
+
|
|
635
|
+
const range = selection.getRangeAt(0);
|
|
636
|
+
if (
|
|
637
|
+
!textLayer.contains(range.startContainer) ||
|
|
638
|
+
!textLayer.contains(range.endContainer)
|
|
639
|
+
) return;
|
|
640
|
+
|
|
641
|
+
let start = characterOffset(textLayer, range.startContainer, range.startOffset);
|
|
642
|
+
let end = characterOffset(textLayer, range.endContainer, range.endOffset);
|
|
643
|
+
if (end < start) [start, end] = [end, start];
|
|
644
|
+
|
|
645
|
+
const chars = codePoints(model.get("text"));
|
|
646
|
+
|
|
647
|
+
if (model.get("trim_whitespace")) {
|
|
648
|
+
while (start < end && /\s/.test(chars[start])) start += 1;
|
|
649
|
+
while (end > start && /\s/.test(chars[end - 1])) end -= 1;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (start === end) {
|
|
653
|
+
selection.removeAllRanges();
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const spans = [...model.get("spans")];
|
|
658
|
+
|
|
659
|
+
if (model.get("allow_overlaps")) {
|
|
660
|
+
if (spans.some(s => (
|
|
661
|
+
Number(s.start) === start &&
|
|
662
|
+
Number(s.end) === end &&
|
|
663
|
+
String(s.label) === String(selectedLabel)
|
|
664
|
+
))) {
|
|
665
|
+
model.set("status", "That exact span already exists.");
|
|
666
|
+
model.save_changes();
|
|
667
|
+
selection.removeAllRanges();
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
} else {
|
|
671
|
+
if (spans.some(s =>
|
|
672
|
+
start < Number(s.end) && Number(s.start) < end
|
|
673
|
+
)) {
|
|
674
|
+
model.set("status", "That selection overlaps an existing span.");
|
|
675
|
+
model.save_changes();
|
|
676
|
+
selection.removeAllRanges();
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
pushHistory();
|
|
682
|
+
selectedSpanId = null;
|
|
683
|
+
spans.push({
|
|
684
|
+
start,
|
|
685
|
+
end,
|
|
686
|
+
label: selectedLabel,
|
|
687
|
+
source: "user",
|
|
688
|
+
_id: `user-${Date.now()}-${Math.random()}`,
|
|
689
|
+
});
|
|
690
|
+
setSpans(spans);
|
|
691
|
+
selection.removeAllRanges();
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
function actionShortcut(event) {
|
|
695
|
+
if (event.key.toLowerCase() === "a") {
|
|
696
|
+
emit("decision", { answer: "accept" });
|
|
697
|
+
return true;
|
|
698
|
+
}
|
|
699
|
+
if (event.key.toLowerCase() === "x") {
|
|
700
|
+
emit("decision", { answer: "reject" });
|
|
701
|
+
return true;
|
|
702
|
+
}
|
|
703
|
+
if (event.code === "Space") {
|
|
704
|
+
emit("decision", { answer: "ignore" });
|
|
705
|
+
return true;
|
|
706
|
+
}
|
|
707
|
+
if (event.key.toLowerCase() === "f") {
|
|
708
|
+
model.set("flagged", !model.get("flagged"));
|
|
709
|
+
model.save_changes();
|
|
710
|
+
return true;
|
|
711
|
+
}
|
|
712
|
+
return false;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function onKeyDown(event) {
|
|
716
|
+
if (!el.contains(document.activeElement)) return;
|
|
717
|
+
|
|
718
|
+
const tag = document.activeElement?.tagName?.toLowerCase();
|
|
719
|
+
if (
|
|
720
|
+
tag === "input" ||
|
|
721
|
+
tag === "textarea" ||
|
|
722
|
+
document.activeElement?.isContentEditable
|
|
723
|
+
) return;
|
|
724
|
+
|
|
725
|
+
if (tag === "button" && (event.code === "Space" || event.key === "Enter")) {
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const completionVisible =
|
|
730
|
+
model.get("complete") && !model.get("reviewing_flagged");
|
|
731
|
+
if (completionVisible) {
|
|
732
|
+
if (
|
|
733
|
+
event.key === "Backspace" &&
|
|
734
|
+
!event.ctrlKey && !event.metaKey && !event.altKey
|
|
735
|
+
) {
|
|
736
|
+
event.preventDefault();
|
|
737
|
+
emit("undo_decision");
|
|
738
|
+
}
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (!event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
743
|
+
const n = Number(event.key);
|
|
744
|
+
if (
|
|
745
|
+
Number.isInteger(n) &&
|
|
746
|
+
n >= 1 &&
|
|
747
|
+
n <= model.get("labels").length
|
|
748
|
+
) {
|
|
749
|
+
event.preventDefault();
|
|
750
|
+
chooseLabel(model.get("labels")[n - 1]);
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (event.key === "Backspace") {
|
|
755
|
+
event.preventDefault();
|
|
756
|
+
if (!model.get("reviewing_flagged")) {
|
|
757
|
+
emit("undo_decision");
|
|
758
|
+
}
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if (actionShortcut(event)) {
|
|
763
|
+
event.preventDefault();
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "z") {
|
|
769
|
+
event.preventDefault();
|
|
770
|
+
undoEdit();
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
if (
|
|
775
|
+
(event.key === "Delete" || event.code === "Delete") &&
|
|
776
|
+
!event.ctrlKey && !event.metaKey && !event.altKey
|
|
777
|
+
) {
|
|
778
|
+
event.preventDefault();
|
|
779
|
+
deleteSelectedSpan();
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
if (event.key === "Escape") {
|
|
784
|
+
selectedSpanId = null;
|
|
785
|
+
window.getSelection()?.removeAllRanges();
|
|
786
|
+
syncSelectedOverlay();
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
el.addEventListener("keydown", onKeyDown);
|
|
791
|
+
|
|
792
|
+
model.on("change:spans", renderText);
|
|
793
|
+
model.on("change:text", renderText);
|
|
794
|
+
model.on("change:allow_overlaps", renderText);
|
|
795
|
+
model.on("change:labels", () => {
|
|
796
|
+
renderLabels();
|
|
797
|
+
renderText();
|
|
798
|
+
});
|
|
799
|
+
model.on("change:answer", renderActions);
|
|
800
|
+
model.on("change:flagged", renderActions);
|
|
801
|
+
model.on("change:can_undo", () => {
|
|
802
|
+
renderActions();
|
|
803
|
+
renderCompletion();
|
|
804
|
+
});
|
|
805
|
+
model.on("change:complete", renderCompletion);
|
|
806
|
+
model.on("change:reviewing_flagged", () => {
|
|
807
|
+
renderHead();
|
|
808
|
+
renderActions();
|
|
809
|
+
renderCompletion();
|
|
810
|
+
});
|
|
811
|
+
model.on("change:accept_count", renderCompletion);
|
|
812
|
+
model.on("change:reject_count", renderCompletion);
|
|
813
|
+
model.on("change:ignore_count", renderCompletion);
|
|
814
|
+
model.on("change:index", renderHead);
|
|
815
|
+
model.on("change:total", renderHead);
|
|
816
|
+
model.on("change:doc_id", () => {
|
|
817
|
+
history = [];
|
|
818
|
+
selectedSpanId = null;
|
|
819
|
+
textBox.scrollTop = 0;
|
|
820
|
+
window.getSelection()?.removeAllRanges();
|
|
821
|
+
syncSelectedOverlay();
|
|
822
|
+
renderHead();
|
|
823
|
+
});
|
|
824
|
+
model.on("change:decided_count", () => {
|
|
825
|
+
renderHead();
|
|
826
|
+
renderCompletion();
|
|
827
|
+
});
|
|
828
|
+
model.on("change:flagged_count", () => {
|
|
829
|
+
renderHead();
|
|
830
|
+
renderCompletion();
|
|
831
|
+
});
|
|
832
|
+
model.on("change:status", renderStatus);
|
|
833
|
+
|
|
834
|
+
renderHead();
|
|
835
|
+
renderLabels();
|
|
836
|
+
renderText();
|
|
837
|
+
renderActions();
|
|
838
|
+
renderCompletion();
|
|
839
|
+
renderStatus();
|
|
840
|
+
|
|
841
|
+
el.addEventListener("mousedown", () => {
|
|
842
|
+
if (!el.contains(document.activeElement)) el.focus({ preventScroll: true });
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
return () => {
|
|
846
|
+
cancelRailFrame();
|
|
847
|
+
el.removeEventListener("keydown", onKeyDown);
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
export default { render };
|