| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| (function () { |
| "use strict"; |
|
|
| var API = "/api/codebook"; |
| var 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) { } |
| } |
|
|
| |
|
|
| 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] || ""; |
| } |
|
|
| |
|
|
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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; |
| 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( |
| "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 = ""; |
| } 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; |
| 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; |
| 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; |
| parent.appendChild(node); |
| have[name] = true; |
| }); |
| } |
|
|
| function selectedValues(labelAnnos, schema) { |
| |
| |
| 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; |
| |
| |
| |
| |
| |
| 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) { } |
| } |
| } |
| }); |
| }); |
| }) |
| .catch(function () { }); |
| } |
|
|
| 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); |
| } |
|
|
| |
|
|
| 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; |
| |
| |
| |
| 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 + ")"; |
| |
| |
| 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 () { }); |
| } |
| fetch(API + "/stale") |
| .then(function (r) { return r.ok ? r.json() : null; }) |
| .then(function (d) { |
| renderWorklist(d && d.stale); |
| }) |
| .catch(function () { }); |
| } |
|
|
| |
|
|
| function fetchFull() { |
| return fetch(API).then(function (r) { |
| return r.ok ? r.json() : null; |
| }).then(function (data) { |
| if (data) { writeCache(data); } |
| return data; |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| function syncCodebook(instanceId) { |
| var cache = readCache(); |
| if (cache) { |
| renderTray(cache); |
| reconcileForms(cache, instanceId); |
| } |
| 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; |
| } |
| 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(); |
| } |
|
|
| |
| |
| |
| |
|
|
| 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); |
| } |
|
|
| |
| |
| 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) { } |
| } |
| } |
| } |
|
|
| 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(); |
| 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 () { }); |
| fetch(ADMIN_API + "/changes") |
| .then(function (r) { return r.ok ? r.json() : null; }) |
| .then(function (d) { renderChanges(d && d.changes); }) |
| .catch(function () { }); |
| } |
|
|
| 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(); |
| }); |
| }); |
| } |
|
|
| |
|
|
| 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 = ""; |
| |
| |
| 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; |
| } |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
|
|
| var INVIVO_CAP = 60; |
| var iv = null; |
| var ivState = null; |
| var ivReturn = null; |
|
|
| |
| 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 & 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); |
| }); |
| |
| |
| 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; |
| } |
|
|
| |
| |
| 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)); |
| |
| 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; |
| |
| |
| var back = ivReturn; |
| ivReturn = null; |
| if (back && back.focus) { |
| try { back.focus(); } catch (e) { } |
| } |
| } |
|
|
| 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 () { }); |
| } |
|
|
| function openInvivo(sel, form) { |
| buildPopover(); |
| |
| |
| |
| 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, |
| }; |
| |
| |
| 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) { } |
| } |
|
|
| 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; } |
| |
| 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; |
| 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(); |
| } |
| }); |
| } |
| |
| |
| 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(); |
| } |
| }); |
| } |
| } |
|
|
| |
| |
| 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 () { }); |
| } |
|
|
| if (document.readyState === "loading") { |
| document.addEventListener("DOMContentLoaded", init); |
| } else { |
| init(); |
| } |
| })(); |
|
|