codebook / potato /static /codebook.js
davidjurgens's picture
Deploy: Potato — Codebook Annotation
aceb1b2 verified
Raw
History Blame Contribute Delete
43.6 kB
/*
* Codebook tray + on-the-fly add + revision provenance.
*
* Self-gating: probes GET /api/codebook. 200 => enabled (reveal the
* toggle); 401/503 => stay hidden. `can_add` decides whether the
* composer is shown (codebook_mode extensible/open or privileged).
*
* Durability: the annotation template is built once at server start, so
* a code added mid-session is NOT in the reloaded form. We reconcile
* client-side on every annotation page load — append any codebook label
* missing from each codebook-backed form. To avoid re-downloading the
* whole codebook every navigation, we poll the cheap /version endpoint
* and only re-fetch the full codebook (cached in sessionStorage) when
* the revision has moved.
*
* Provenance: each annotation is stamped server-side with the codebook
* revision in effect. On revisit, an instance labeled under an older
* revision shows a dismissible banner, and the tray lists a review
* worklist of the annotator's stale instances with jump-to.
*/
(function () {
"use strict";
var API = "/api/codebook";
var dismissed = {}; // instance_id -> true (banner dismissed)
function el(id) { return document.getElementById(id); }
function esc(s) {
var d = document.createElement("div");
d.textContent = s == null ? "" : String(s);
return d.innerHTML;
}
function project() {
return (window.config && window.config.annotation_task_name)
|| "default";
}
function cacheKey() { return "cb_cache:" + project(); }
function readCache() {
try { return JSON.parse(sessionStorage.getItem(cacheKey())); }
catch (e) { return null; }
}
function writeCache(data) {
try {
sessionStorage.setItem(cacheKey(), JSON.stringify({
revision: data.revision, labels: data.labels,
tree: data.tree, schemes: data.schemes,
mode: data.mode, can_add: data.can_add,
}));
} catch (e) { /* sessionStorage full/disabled — fall back to net */ }
}
// ---- tray rendering --------------------------------------------------
function renderTree(nodes) {
if (!nodes || !nodes.length) return "";
return nodes.map(function (n) {
var dot = n.color
? '<span class="cb-dot" style="background:'
+ esc(n.color) + '"></span>'
: "";
return '<li class="cb-node">'
+ '<div class="cb-node-row">' + dot
+ '<span class="cb-name">' + esc(n.name) + "</span></div>"
+ (n.children && n.children.length
? '<ul class="cb-children">'
+ renderTree(n.children) + "</ul>"
: "")
+ "</li>";
}).join("");
}
var MODE_HINT = {
open: "You can add and organize codes.",
extensible: "You can add new codes.",
};
function renderTray(data) {
var box = el("cb-tree");
if (box) {
var tree = (data && data.tree) || [];
box.innerHTML = tree.length
? '<ul class="cb-root">' + renderTree(tree) + "</ul>"
: '<div class="cb-empty">No codes yet.</div>';
}
var composer = el("cb-composer");
if (composer) composer.hidden = !(data && data.can_add);
var hint = el("cb-mode-hint");
if (hint && data) hint.textContent = MODE_HINT[data.mode] || "";
}
// ---- form reconciliation (append missing codebook options) ----------
function slug(s) {
return String(s).replace(/[^a-zA-Z0-9_-]/g, "_");
}
function optionValues(form) {
var vals = {};
form.querySelectorAll(
"input.annotation-input, input.shadcn-span-checkbox"
).forEach(function (i) { vals[i.value] = true; });
return vals;
}
// Span schemes (annotation_type: span, codebook: true) render a
// different option shape (.shadcn-span-option / .shadcn-span-checkbox,
// no .annotation-input, an inline changeSpanLabel onclick). The label
// palette must still gain runtime codes so they are usable as span
// labels; span *persistence* itself is overlay-based and independent
// of the palette.
function reconcileSpanForm(form, tmpl, labels) {
var schema = form.getAttribute("data-schema-name") || form.id;
var have = optionValues(form);
var parent = tmpl.parentElement;
var tInput = tmpl.querySelector("input");
var targetField = tInput
? (tInput.getAttribute("data-target-field") || "") : "";
labels.forEach(function (name) {
if (have[name]) return; // idempotent
var node = tmpl.cloneNode(true);
var input = node.querySelector("input");
var label = node.querySelector("label");
if (!input || !label) return;
var newId = schema + "__cb__" + slug(name);
input.value = name;
input.id = newId;
input.checked = false;
input.removeAttribute("data-key");
// label/title carry the code name; color is hash-derived in
// SpanManager (getSpanColor) so '' here is correct.
input.setAttribute(
"onclick",
"onlyOne(this); changeSpanLabel(this, "
+ JSON.stringify(schema) + ", " + JSON.stringify(name)
+ ", " + JSON.stringify(name) + ", '', "
+ JSON.stringify(targetField) + ");");
label.setAttribute("for", newId);
var swatch = label.querySelector("span");
if (swatch) {
swatch.textContent = name;
swatch.style.backgroundColor = ""; // no borrowed color
} else {
label.textContent = name;
}
parent.appendChild(node);
have[name] = true;
});
}
function reconcileForm(form, labels) {
var span = form.querySelector(".shadcn-span-option");
if (span) return reconcileSpanForm(form, span, labels);
var radio = form.querySelector(".shadcn-radio-option");
var multi = form.querySelector(".shadcn-multiselect-item");
var tmpl = radio || multi;
if (!tmpl) return; // unsupported scheme type — skip
var isCheckbox = !!multi;
var schema = form.getAttribute("data-schema-name") || form.id;
var have = optionValues(form);
var parent = tmpl.parentElement;
labels.forEach(function (name) {
if (have[name]) return; // already an option (idempotent)
var node = tmpl.cloneNode(true);
var input = node.querySelector("input");
var label = node.querySelector("label");
if (!input || !label) return;
var newId = schema + "__cb__" + slug(name);
input.value = name;
input.id = newId;
input.checked = false;
input.removeAttribute("data-key");
input.setAttribute("label_name", newId);
if (isCheckbox) input.setAttribute("name", schema + ":::" + name);
label.setAttribute("for", newId);
label.textContent = name; // drops any keybinding badge
parent.appendChild(node);
have[name] = true;
});
}
function selectedValues(labelAnnos, schema) {
// get_label_annotations returns {schema: <list|value>}; be
// defensive about the item shape (string or {label/name/value}).
var raw = labelAnnos && labelAnnos[schema];
if (raw == null) return {};
var arr = Array.isArray(raw) ? raw : [raw];
var out = {};
arr.forEach(function (it) {
if (it == null) return;
if (typeof it === "string" || typeof it === "number") {
out[String(it)] = true;
} else if (typeof it === "object") {
var v = it.label != null ? it.label
: (it.name != null ? it.name : it.value);
if (v != null) out[String(v)] = true;
}
});
return out;
}
function restoreRuntimeSelections(schemes, instanceId) {
if (!instanceId) return;
fetch("/get_annotations?instance_id="
+ encodeURIComponent(instanceId))
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (!data || !data.label_annotations) return;
schemes.forEach(function (schema) {
var form = document.querySelector(
'form[data-schema-name="' + cssEsc(schema) + '"]')
|| document.getElementById(schema);
if (!form) return;
// Saved annotations come back keyed by the Label
// *name*, which equals the input's `label_name`
// (that is what saveAnnotations sends). Match on
// label_name, not value, so a code name with odd
// characters still round-trips.
var sel = selectedValues(
data.label_annotations, schema);
form.querySelectorAll("input.annotation-input")
.forEach(function (input) {
var key = input.getAttribute("label_name");
if (key && sel[key] && !input.checked) {
input.checked = true;
input.setAttribute(
"data-server-set", "true");
if (typeof registerAnnotation
=== "function") {
try { registerAnnotation(input); }
catch (e) { /* non-fatal */ }
}
}
});
});
})
.catch(function () { /* restore is best-effort */ });
}
function cssEsc(s) {
return String(s).replace(/["\\]/g, "\\$&");
}
function reconcileForms(data, instanceId) {
var schemes = (data && data.schemes) || [];
var labels = (data && data.labels) || [];
if (!schemes.length || !labels.length) return;
schemes.forEach(function (schema) {
var form = document.querySelector(
'form[data-schema-name="' + cssEsc(schema) + '"]')
|| document.getElementById(schema);
if (form) reconcileForm(form, labels);
});
restoreRuntimeSelections(schemes, instanceId);
}
// ---- provenance banner + review worklist ----------------------------
function instanceId() {
var e = el("instance_id");
return e ? (e.value || e.textContent || "").trim() : "";
}
var staleDismissBound = false;
var staleInstance = null;
function renderBanner(p) {
var bar = el("cb-stale-banner");
var msgEl = el("cb-stale-msg");
if (!bar || !msgEl) return;
// Bind the persistent dismiss control exactly once — the banner
// is stable structure (the message <span> is the live region),
// never re-injected, so the control is never re-announced.
if (!staleDismissBound) {
var x = el("cb-stale-dismiss");
if (x) x.addEventListener("click", function () {
if (staleInstance) dismissed[staleInstance] = true;
bar.hidden = true;
});
staleDismissBound = true;
}
if (!p || !p.stale || dismissed[p.instance_id]) {
bar.hidden = true;
return;
}
var added = p.codes_added_since || [];
staleInstance = p.instance_id;
msgEl.textContent = added.length
? added.length + " code" + (added.length > 1 ? "s" : "")
+ " added since you labeled this: " + added.join(", ")
: "The codebook changed since you labeled this "
+ "(renamed / recolored / reorganized).";
bar.hidden = false;
}
function renderWorklist(items) {
var box = el("cb-worklist");
var head = el("cb-worklist-head");
var section = el("cb-worklist-section");
if (!box) return;
items = items || [];
if (head) head.textContent = "Review (" + items.length + ")";
// The empty worklist is the common case — collapse the whole
// section so it adds no resting chrome to the tray.
if (section) section.hidden = !items.length;
if (!items.length) {
box.innerHTML =
'<div class="cb-empty">Nothing to review.</div>';
return;
}
box.innerHTML = '<ul class="cb-worklist-list">'
+ items.map(function (it) {
var added = (it.codes_added_since || []);
var sub = added.length
? esc(added.join(", "))
: "codebook changed";
var btn = it.index == null ? ""
: '<button type="button" class="cb-go" data-idx="'
+ it.index + '">Go</button>';
return '<li class="cb-wl-item">'
+ '<div class="cb-wl-id">' + esc(it.instance_id)
+ "</div>"
+ '<div class="cb-wl-sub">' + sub + "</div>"
+ btn + "</li>";
}).join("") + "</ul>";
box.querySelectorAll(".cb-go").forEach(function (b) {
b.addEventListener("click", function () {
var idx = parseInt(b.getAttribute("data-idx"), 10);
if (!isNaN(idx)
&& typeof navigateToInstance === "function") {
navigateToInstance(idx);
}
});
});
}
function refreshProvenance() {
var iid = instanceId();
if (iid) {
fetch(API + "/provenance?instance_id="
+ encodeURIComponent(iid))
.then(function (r) { return r.ok ? r.json() : null; })
.then(renderBanner)
.catch(function () { /* banner is best-effort */ });
}
fetch(API + "/stale")
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
renderWorklist(d && d.stale);
})
.catch(function () { /* worklist is best-effort */ });
}
// ---- load orchestration ---------------------------------------------
function fetchFull() {
return fetch(API).then(function (r) {
return r.ok ? r.json() : null;
}).then(function (data) {
if (data) { writeCache(data); }
return data;
});
}
// Reconcile + restore off the sessionStorage cache *immediately*
// (no network on the critical path), then poll the cheap /version
// in the background and, only if the revision moved, full-fetch and
// reconcile again (idempotent). This keeps the runtime-code restore
// deterministic after a full-reload navigation instead of racing a
// multi-round-trip chain.
function syncCodebook(instanceId) {
var cache = readCache();
if (cache) {
renderTray(cache);
reconcileForms(cache, instanceId); // each load = stale tmpl
}
return fetch(API + "/version")
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (v) {
if (v && cache && cache.revision === v.revision) {
return cache; // cache fresh — done
}
return fetchFull().then(function (data) {
if (data) {
renderTray(data);
reconcileForms(data, instanceId);
}
return data;
});
})
.catch(function () { return cache || null; });
}
function onInstance() {
if (!el("cb-panel")) return;
syncCodebook(instanceId());
refreshProvenance();
refreshAdmin();
}
// ---- admin curation (Phase 2 C): merge / split / proposals ----------
// Admin status is probed once via the gated endpoint (200 -> show
// the section, 403 -> stay hidden) — same self-gating pattern as the
// codebook toggle itself.
var ADMIN_API = API + "/admin";
var adminProbed = false, adminOK = false;
function flatCodes() {
var c = readCache();
var tree = (c && c.tree) || [];
var out = [];
(function walk(nodes, depth) {
nodes.forEach(function (n) {
out.push({ id: n.id,
name: (depth ? "— " : "") + n.name });
if (n.children) walk(n.children, depth + 1);
});
})(tree, 0);
return out;
}
function fillCodeSelect(sel, codes, placeholder) {
if (!sel) return;
var cur = sel.value;
sel.innerHTML = '<option value="">' + esc(placeholder)
+ "</option>"
+ codes.map(function (c) {
return '<option value="' + esc(c.id) + '">'
+ esc(c.name) + "</option>";
}).join("");
if (cur) sel.value = cur;
}
function codeNameMap() {
var c = readCache();
var map = {};
(function walk(nodes) {
(nodes || []).forEach(function (n) {
map[n.id] = n.name;
if (n.children) walk(n.children);
});
})((c && c.tree) || []);
return map;
}
function codeName(id) {
if (!id) return "?";
var nm = codeNameMap()[id];
return nm != null ? nm : String(id).slice(0, 8);
}
// Human, reviewable sentence — an admin must understand what they
// are confirming, not decode uuids.
function describeProposal(p) {
var q = function (s) { return "«" + esc(s) + "»"; };
var pay = p.payload || {};
switch (p.op) {
case "merge":
return "Merge " + q(codeName(pay.src_id)) + " into "
+ q(codeName(pay.dst_id));
case "split":
var dest = pay.new_name
? " → " + q(pay.new_name)
: (pay.target_id
? " → " + q(codeName(pay.target_id)) : "");
return "Split " + q(codeName(pay.src_id)) + " by "
+ esc(pay.annotator || "?") + dest;
case "rename":
return "Rename " + q(codeName(pay.code_id)) + " → "
+ q(pay.new_name || "?");
case "recolor":
return "Recolour " + q(codeName(pay.code_id));
case "move":
return "Move " + q(codeName(pay.code_id));
case "delete":
return "Delete " + q(codeName(pay.code_id));
default:
return esc(p.op);
}
}
function adminStatus(msg) {
var s = el("cb-admin-status");
if (s) { s.textContent = msg || ""; }
}
var _statusT;
function flashStatus(msg) {
adminStatus(msg);
clearTimeout(_statusT);
_statusT = setTimeout(function () { adminStatus(""); }, 4000);
}
function adminErr(msg) {
var e = el("cb-admin-error");
adminStatus("");
if (e) {
e.textContent = msg || "";
e.hidden = !msg;
if (msg && e.focus) {
try { e.focus(); } catch (x) { /* non-fatal */ }
}
}
}
function afterAdminOp(okMsg) {
var e = el("cb-admin-error");
if (e) { e.textContent = ""; e.hidden = true; }
flashStatus(okMsg || "Done.");
fetchFull().then(function (data) {
if (data) {
renderTray(data);
reconcileForms(data, instanceId());
}
refreshProvenance();
populateAdmin();
});
}
function _adminButtons() {
var sec = el("cb-admin-section");
return sec ? sec.querySelectorAll(
".cb-primary, .cb-go, .cb-iv-cancel") : [];
}
function postAdmin(path, body, onok) {
adminStatus("");
var btns = _adminButtons();
btns.forEach(function (b) { b.disabled = true; });
var done = function () {
btns.forEach(function (b) { b.disabled = false; });
};
fetch(ADMIN_API + path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body || {}),
}).then(function (r) {
return r.json().then(function (b) {
return { ok: r.ok, body: b };
});
}).then(function (res) {
done();
if (!res.ok) {
adminErr(res.body && res.body.error
? res.body.error : "Action failed.");
return;
}
onok(res.body);
}).catch(function () {
done();
adminErr("Action failed.");
});
}
function renderProposals(items) {
var box = el("cb-proposals");
if (!box) return;
items = items || [];
if (!items.length) {
box.innerHTML =
'<div class="cb-empty">No pending proposals.</div>';
return;
}
box.innerHTML = '<ul class="cb-prop-list">'
+ items.map(function (p) {
return '<li class="cb-prop-item">'
+ '<div class="cb-prop-desc">'
+ describeProposal(p) + "</div>"
+ '<div class="cb-prop-actions">'
+ '<button type="button" class="cb-go" '
+ 'data-confirm="' + esc(p.id) + '">Confirm</button>'
+ '<button type="button" class="cb-iv-cancel" '
+ 'data-reject="' + esc(p.id) + '">Reject</button>'
+ "</div></li>";
}).join("") + "</ul>";
box.querySelectorAll("[data-confirm]").forEach(function (b) {
b.addEventListener("click", function () {
postAdmin("/proposals/"
+ encodeURIComponent(b.getAttribute("data-confirm"))
+ "/confirm", null, function () {
afterAdminOp("Proposal confirmed.");
});
});
});
box.querySelectorAll("[data-reject]").forEach(function (b) {
b.addEventListener("click", function () {
postAdmin("/proposals/"
+ encodeURIComponent(b.getAttribute("data-reject"))
+ "/reject", null, function () {
flashStatus("Proposal rejected.");
populateAdmin();
});
});
});
}
var CHANGES_CAP = 20;
function renderChanges(items) {
var box = el("cb-changes");
if (!box) return;
var all = (items || []).slice().reverse(); // newest first
if (!all.length) {
box.innerHTML = '<li class="cb-empty">No changes yet.</li>';
return;
}
var shown = all.slice(0, CHANGES_CAP);
var note = all.length > CHANGES_CAP
? '<li class="cb-empty">Showing latest ' + CHANGES_CAP
+ "</li>"
: "";
box.innerHTML = note + shown.map(function (c) {
var from = c.old_value == null ? "" : esc(c.old_value);
var to = c.new_value == null ? "" : (" → "
+ esc(c.new_value));
return '<li class="cb-chg-item"><span class="cb-chg-op">'
+ esc(c.op) + '</span> ' + from + to
+ ' <span class="cb-chg-by">'
+ esc(c.actor) + "</span></li>";
}).join("");
}
function populateAdmin() {
var codes = flatCodes();
fillCodeSelect(el("cb-merge-src"), codes, "merge from…");
fillCodeSelect(el("cb-merge-dst"), codes, "into…");
fillCodeSelect(el("cb-split-src"), codes, "split…");
fetch(ADMIN_API + "/proposals")
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) { renderProposals(d && d.proposals); })
.catch(function () { /* best-effort */ });
fetch(ADMIN_API + "/changes")
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) { renderChanges(d && d.changes); })
.catch(function () { /* best-effort */ });
}
function refreshAdmin() {
var sec = el("cb-admin-section");
if (!sec) return;
if (adminProbed) {
if (adminOK) populateAdmin();
return;
}
adminProbed = true;
fetch(ADMIN_API + "/proposals").then(function (r) {
adminOK = r.status === 200;
sec.hidden = !adminOK;
if (adminOK) populateAdmin();
}).catch(function () { sec.hidden = true; });
}
function wireAdmin() {
var m = el("cb-merge-btn");
if (m) m.addEventListener("click", function () {
var s = el("cb-merge-src").value;
var d = el("cb-merge-dst").value;
if (!s || !d) { adminErr("Pick both codes."); return; }
postAdmin("/merge", { src_id: s, dst_id: d }, afterAdminOp);
});
var sp = el("cb-split-btn");
if (sp) sp.addEventListener("click", function () {
var body = {
src_id: el("cb-split-src").value,
annotator: (el("cb-split-annotator").value || "").trim(),
new_name: (el("cb-split-name").value || "").trim(),
};
if (!body.src_id || !body.annotator) {
adminErr("Pick a code and an annotator."); return;
}
postAdmin("/split", body, function () {
el("cb-split-name").value = "";
el("cb-split-annotator").value = "";
afterAdminOp();
});
});
}
// ---- composer (add a code) ------------------------------------------
function addCode() {
var input = el("cb-new-name");
var name = (input && input.value || "").trim();
var err = el("cb-error");
if (err) { err.hidden = true; err.textContent = ""; }
if (!name) { if (input) input.focus(); return; }
var btn = el("cb-add-btn");
if (btn) btn.disabled = true;
fetch(API, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name }),
}).then(function (r) {
return r.json().then(function (b) {
return { ok: r.ok, body: b };
});
}).then(function (res) {
if (btn) btn.disabled = false;
if (!res.ok) {
if (err) {
err.textContent = res.body && res.body.error
? res.body.error : "Could not add code.";
err.hidden = false;
}
return;
}
if (input) input.value = "";
// Force a full refresh + immediate in-place reconcile so the
// new code is usable on the current instance without reload.
fetchFull().then(function (data) {
if (data) {
renderTray(data);
reconcileForms(data, instanceId());
}
refreshProvenance();
});
}).catch(function () {
if (btn) btn.disabled = false;
if (err) {
err.textContent = "Could not add code.";
err.hidden = false;
}
});
}
// ---- in-vivo coding (D): select text -> key -> code from selection --
// Reuses the (B) create path + the existing span create/save/overlay
// pipeline: capture the selection Range, create (or reuse) the code,
// reconcile the palette, then replay the Range through SpanManager so
// zero span logic is duplicated here.
var INVIVO_CAP = 60;
var iv = null; // popover root (built lazily)
var ivState = null; // { range, schema, field, chosen }
var ivReturn = null; // element to restore focus to on close
// Mirrors potato/codebook/similar.py derive_code_name — keep in sync.
function deriveName(text) {
var s = String(text || "").replace(/\s+/g, " ").trim();
if (s.length <= INVIVO_CAP) return s;
var head = s.slice(0, INVIVO_CAP).replace(/\s\S*$/, "");
return (head || s.slice(0, INVIVO_CAP)).trim();
}
function invivoKey() {
var c = readCache();
var k = c && c.invivo_key;
return (k || "i").toString().slice(0, 1).toLowerCase();
}
function codebookSpanForm() {
var forms = document.querySelectorAll("form.annotation-form.span");
for (var i = 0; i < forms.length; i++) {
if (forms[i].querySelector(".shadcn-span-option")) {
return forms[i];
}
}
return null;
}
function activeSelectionInInstance() {
var sel = window.getSelection && window.getSelection();
if (!sel || !sel.rangeCount || sel.isCollapsed) return null;
if (!sel.toString().trim()) return null;
var node = sel.getRangeAt(0).startContainer;
var elx = node.nodeType === 3 ? node.parentElement : node;
if (!elx || !elx.closest) return null;
var host = elx.closest(
'[id^="text-content-"], #instance-text, #text-content');
return host ? sel : null;
}
function fieldOfSelection(sel) {
var node = sel.getRangeAt(0).startContainer;
var elx = node.nodeType === 3 ? node.parentElement : node;
var host = elx && elx.closest
? elx.closest('[id^="text-content-"]') : null;
return host ? host.id.replace("text-content-", "") : "";
}
function buildPopover() {
if (iv) return iv;
iv = document.createElement("div");
iv.id = "cb-invivo";
iv.className = "cb-invivo";
iv.setAttribute("role", "dialog");
iv.setAttribute("aria-modal", "true");
iv.setAttribute("aria-label",
"Create a code from the selected text");
iv.hidden = true;
iv.innerHTML =
'<div class="cb-iv-quote" id="cb-iv-quote"></div>' +
'<input id="cb-iv-name" class="cb-iv-input" type="text" ' +
'autocomplete="off" spellcheck="false" ' +
'aria-label="New code name" ' +
'aria-describedby="cb-iv-quote" />' +
'<div id="cb-iv-sim" class="cb-iv-sim" hidden ' +
'aria-live="polite"></div>' +
'<div id="cb-iv-err" class="cb-error" hidden ' +
'role="alert"></div>' +
'<div class="cb-iv-actions">' +
'<button type="button" id="cb-iv-cancel" ' +
'class="cb-iv-cancel">Cancel</button>' +
'<button type="button" id="cb-iv-go" ' +
'class="cb-primary">Create &amp; code</button>' +
'</div>';
document.body.appendChild(iv);
el("cb-iv-cancel").addEventListener("click", closeInvivo);
el("cb-iv-go").addEventListener("click", commitInvivo);
var nm = el("cb-iv-name");
nm.addEventListener("keydown", function (e) {
if (e.key === "Enter") {
e.preventDefault(); commitInvivo();
} else if (e.key === "Escape") {
e.preventDefault(); closeInvivo();
}
});
var simT;
nm.addEventListener("input", function () {
if (ivState) ivState.chosen = null;
updateGoLabel();
clearTimeout(simT);
simT = setTimeout(fetchSimilar, 220);
});
// Modal dialog: Esc closes from anywhere inside; Tab is trapped
// so keyboard focus can't fall behind the popover.
iv.addEventListener("keydown", function (e) {
if (e.key === "Escape") {
e.preventDefault(); closeInvivo(); return;
}
if (e.key !== "Tab") return;
var f = ivFocusables();
if (!f.length) return;
var first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault(); last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault(); first.focus();
}
});
return iv;
}
function truncate(s, n) {
s = String(s || "").replace(/\s+/g, " ").trim();
return s.length > n ? s.slice(0, n - 1).trim() + "…" : s;
}
// Reflect what the primary action will actually do so "Create" never
// lies when the name resolves to an existing code.
function updateGoLabel() {
var go = el("cb-iv-go");
if (!go || !ivState) return;
var nm = el("cb-iv-name");
var name = (nm && nm.value || "").trim().toLowerCase();
var reuse = !!ivState.chosen;
if (!reuse && name) {
var cache = readCache();
var labels = (cache && cache.labels) || [];
reuse = labels.some(function (l) {
return String(l).trim().toLowerCase() === name;
});
}
go.textContent = reuse ? "Apply code" : "Create & code";
}
function positionPopover(rect) {
var pad = 8, w = 320;
var de = document.documentElement;
var left = Math.max(pad, Math.min(
rect.left + window.scrollX,
window.scrollX + de.clientWidth - w - pad));
// Flip above the selection if it would overflow the fold.
var h = iv.offsetHeight || 0;
var below = rect.bottom + pad + h <= de.clientHeight;
var top = below
? rect.bottom + window.scrollY + pad
: Math.max(pad + window.scrollY,
rect.top + window.scrollY - pad - h);
iv.style.top = top + "px";
iv.style.left = left + "px";
}
function ivFocusables() {
if (!iv) return [];
return Array.prototype.filter.call(
iv.querySelectorAll("input, button"),
function (n) { return !n.disabled && n.offsetParent !== null; });
}
function closeInvivo() {
if (iv) iv.hidden = true;
ivState = null;
// Restore focus so the annotator isn't dropped onto <body>
// mid-coding (WCAG 2.4.3).
var back = ivReturn;
ivReturn = null;
if (back && back.focus) {
try { back.focus(); } catch (e) { /* non-fatal */ }
}
}
function showIvErr(msg) {
var e = el("cb-iv-err");
if (e) { e.textContent = msg; e.hidden = false; }
}
function renderSimilar(matches) {
var box = el("cb-iv-sim");
if (!box) return;
if (!matches || !matches.length) {
box.hidden = true; box.innerHTML = ""; return;
}
box.hidden = false;
box.innerHTML = '<span class="cb-iv-sim-label">Similar existing '
+ 'code' + (matches.length > 1 ? "s" : "")
+ ' — reuse instead?</span>';
matches.forEach(function (m) {
var b = document.createElement("button");
b.type = "button";
b.className = "cb-iv-chip";
b.textContent = m;
b.addEventListener("click", function () {
var nm = el("cb-iv-name");
nm.value = m;
if (ivState) ivState.chosen = m;
box.querySelectorAll(".cb-iv-chip").forEach(
function (c) {
c.classList.toggle("cb-iv-chip-on", c === b);
});
updateGoLabel();
nm.focus();
});
box.appendChild(b);
});
}
function fetchSimilar() {
var nm = el("cb-iv-name");
var q = (nm && nm.value || "").trim();
if (!q) { renderSimilar([]); return; }
fetch(API + "/similar?name=" + encodeURIComponent(q))
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) { renderSimilar(d && d.matches); })
.catch(function () { /* suggestion is best-effort */ });
}
function openInvivo(sel, form) {
buildPopover();
// Where focus returns on close. The opener is a keypress over a
// text selection (no focused control), so prefer the codebook
// toggle; fall back to whatever was focused, else body.
var ae = document.activeElement;
ivReturn = (ae && ae !== document.body && ae.focus) ? ae
: (el("cb-panel-toggle") || document.body);
var range = sel.getRangeAt(0).cloneRange();
var rect = range.getBoundingClientRect();
var raw = sel.toString();
ivState = {
range: range,
schema: form.getAttribute("data-schema-name") || form.id,
field: fieldOfSelection(sel),
chosen: null,
};
// Quote shows the *selected text* for context; the input holds
// the editable derived code name.
el("cb-iv-quote").textContent = "“" + truncate(raw, 140) + "”";
var nm = el("cb-iv-name");
nm.value = deriveName(raw);
el("cb-iv-err").hidden = true;
renderSimilar([]);
updateGoLabel();
iv.hidden = false;
positionPopover(rect);
nm.focus();
nm.select();
fetchSimilar();
}
function applySpan(name) {
var sm = window.spanManager;
if (!sm || !ivState) return;
try {
sm.selectLabel(name, ivState.schema, ivState.field);
var s = window.getSelection();
s.removeAllRanges();
s.addRange(ivState.range);
sm.handleTextSelection({});
s.removeAllRanges();
} catch (e) { /* code is created; span apply is best-effort */ }
}
function afterCode(name) {
fetchFull().then(function (data) {
if (data) {
renderTray(data);
reconcileForms(data, instanceId());
}
applySpan(name);
refreshProvenance();
closeInvivo();
});
}
function commitInvivo() {
if (!ivState) return;
var nm = el("cb-iv-name");
var name = (nm && nm.value || "").trim();
var errEl = el("cb-iv-err");
if (errEl) errEl.hidden = true;
if (!name) { if (nm) nm.focus(); return; }
// Reuse an existing code? (chip-picked, or exact normalized hit)
var existing = ivState.chosen;
if (!existing) {
var cache = readCache();
var labels = (cache && cache.labels) || [];
for (var i = 0; i < labels.length; i++) {
if (String(labels[i]).trim().toLowerCase()
=== name.toLowerCase()) {
existing = labels[i]; break;
}
}
}
if (existing) { afterCode(existing); return; }
var go = el("cb-iv-go");
if (go) go.disabled = true;
fetch(API, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name }),
}).then(function (r) {
return r.json().then(function (b) {
return { ok: r.ok, status: r.status, body: b };
});
}).then(function (res) {
if (go) go.disabled = false;
if (!res.ok) {
if (res.status === 409) { afterCode(name); return; }
showIvErr(res.body && res.body.error
? res.body.error : "Could not add code.");
return;
}
afterCode(name);
}).catch(function () {
if (go) go.disabled = false;
showIvErr("Could not add code.");
});
}
function onGlobalKeydown(e) {
if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey) {
return;
}
if (iv && !iv.hidden) return; // popover owns its own keys
var t = e.target, tag = t && t.tagName;
if (tag === "INPUT" || tag === "TEXTAREA"
|| (t && t.isContentEditable)) return;
if ((e.key || "").toLowerCase() !== invivoKey()) return;
var form = codebookSpanForm();
if (!form) return;
var sel = activeSelectionInInstance();
if (!sel) return;
e.preventDefault();
openInvivo(sel, form);
}
function closePanel() {
var panel = el("cb-panel");
var toggle = el("cb-panel-toggle");
if (panel) panel.hidden = true;
if (toggle) { toggle.hidden = false; toggle.focus(); }
}
function wire() {
var toggle = el("cb-panel-toggle");
var panel = el("cb-panel");
var close = el("cb-panel-close");
if (toggle && panel) {
toggle.addEventListener("click", function () {
panel.hidden = false;
toggle.hidden = true;
onInstance();
var n = el("cb-new-name");
if (n && !el("cb-composer").hidden) n.focus();
});
}
if (close) close.addEventListener("click", closePanel);
if (panel) {
panel.addEventListener("keydown", function (e) {
if (e.key === "Escape") {
e.preventDefault(); closePanel();
}
});
}
// In-vivo coding: global key, active whenever a codebook span
// scheme exists (wire() runs only when the codebook is enabled).
document.addEventListener("keydown", onGlobalKeydown);
wireAdmin();
var add = el("cb-add-btn");
if (add) add.addEventListener("click", addCode);
var input = el("cb-new-name");
if (input) {
input.addEventListener("keydown", function (e) {
if (e.key === "Enter") {
e.preventDefault(); addCode();
}
});
}
}
// Hook annotation.js calls on every instance load (after a full
// navigation reload too) so reconcile + banner stay correct.
window.CodebookPanel = { onInstance: onInstance };
function init() {
if (!window.config || !window.config.is_annotation_page) return;
if (!el("cb-panel")) return;
fetch(API).then(function (r) {
if (r.status === 200) {
var t = el("cb-panel-toggle");
if (t) t.hidden = false;
wire();
return r.json();
}
return null;
}).then(function (data) {
if (!data) return;
writeCache(data);
renderTray(data);
reconcileForms(data, instanceId());
refreshProvenance();
}).catch(function () { /* leave hidden */ });
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();