1/* 2 * compose-files.js â shared composer file uploader for the AI surfaces. 3 * 4 * Extracted from the Interact composer so Draft (and any future surface) reuse 5 * one implementation. Handles: a canonical selection, inline chips + a "+N" 6 * overflow pill, a manager modal (live stats, sortable columns, remove), 7 * drag-drop onto the form, dedupe by name+size, the EITHER-limit caps (too many 8 * files OR too many bytes), and an over-limit submit-guard that opens the 9 * manager instead of posting. 10 * 11 * Two modes: 12 * - default files ride with the form post (fileInput.files rebuilt 13 * from the selection). 14 * - staging (opts.stage) each file is XHR-uploaded on attach to a staging 15 * endpoint (progress, OCR off-request, "Other language" 16 * + Enterprise "Use AI to OCR"); the form posts the 17 * returned ids as hidden <input name=idField> instead of 18 * the raw bytes. Submit holds until every file is ready. 19 * 20 * Usage: 21 * LexComposeFiles.init(form, { 22 * onChange: function (state) { ... }, 23 * stage: { uploadUrl, statusUrl, deleteUrl, reocrUrl, idField: "upload_ids" }, 24 * }); 25 * 26 * The form must contain: 27 * [data-file-input] the hidden <input type="file" multiple> 28 * [data-file-chips] a container for the inline chips 29 * [data-file-trigger] (optional) a button that opens the OS file picker 30 * [data-plus-manage] (optional) a "Manage files" menu item 31 * [data-files-manage-count](optional) text node for the file count 32 * and carry data-files-modal="<id>" pointing at an included 33 * partials/_compose_files_modal.html (its data-max-* attrs are the caps). 34 * 35 * `onChange(state)` fires on every edit with 36 * { count, bytes, overCount, overBytes, over } so the page can update its own 37 * previews (e.g. Interact's "you'll get N answers" line). 38 */ 39(function () { 40 "use strict"; 41 42 function init(form, opts) { 43 opts = opts || {}; 44 if (!form || form.dataset.composeFilesInit === "1") return null; 45 form.dataset.composeFilesInit = "1"; 46 47 var onChange = typeof opts.onChange === "function" ? opts.onChange : function () {}; 48 // Staging config (optional). When present, files are uploaded on attach and 49 // the run posts ids, not bytes. 50 var stage = opts.stage || null; 51 var ID_FIELD = (stage && stage.idField) || "upload_ids"; 52 53 var fileInput = form.querySelector("[data-file-input]"); 54 var chips = form.querySelector("[data-file-chips]"); 55 var attachBtn = form.querySelector("[data-file-trigger]"); 56 var manageBtn = form.querySelector("[data-plus-manage]"); 57 var manageCount = form.querySelector("[data-files-manage-count]"); 58 // Show this many file chips inline; the rest collapse behind a "+N" pill 59 // that opens the manager modal. 60 var MAX_VISIBLE_CHIPS = 3; 61 62 function csrf() { 63 var el = form.querySelector('input[name="csrfmiddlewaretoken"]'); 64 return el ? el.value : ""; 65 } 66 67 // Manager modal (Bootstrap). Reparented to <body> so an ancestor's 68 // overflow/transform can't clip the overlay. 69 var modalEl = form.dataset.filesModal ? document.getElementById(form.dataset.filesModal) : null; 70 if (modalEl && modalEl.parentNode !== document.body) document.body.appendChild(modalEl); 71 // Caps come from the server (core.attachments) via data-attributes â the 72 // single source of truth, so the JS can't drift from the backstop. 73 var MAX_FILES = (modalEl && parseInt(modalEl.dataset.maxFiles, 10)) || 20; 74 var MAX_TOTAL_BYTES = (modalEl && parseInt(modalEl.dataset.maxBytes, 10)) || 10 * 1024 * 1024; 75 var modalTbody = modalEl && modalEl.querySelector("[data-files-tbody]"); 76 var modalDone = modalEl && modalEl.querySelector("[data-files-done]"); 77 var statCountBox = modalEl && modalEl.querySelector("[data-stat-count]"); 78 var statSizeBox = modalEl && modalEl.querySelector("[data-stat-size]"); 79 var statCountVal = modalEl && modalEl.querySelector("[data-stat-count-val]"); 80 var statSizeVal = modalEl && modalEl.querySelector("[data-stat-size-val]"); 81 82 // Modal sort state: by 'name' (asc) until the user clicks a header. 83 var sortKey = "name", sortDir = 1; 84 85 // Canonical selection of wrappers: { file, name, size, status, pct, stage, 86 // id, error, xhr }. In default mode every item is 'ready' and `file` rides 87 // the form; in staging mode `status` follows the upload/OCR lifecycle. 88 var selected = []; 89 var uidSeq = 0; 90 91 function fmtSize(n) { 92 if (n < 1024) return n + " B"; 93 if (n < 1048576) return (n / 1024).toFixed(1) + " KB"; 94 return (n / 1048576).toFixed(1) + " MB"; 95 } 96 function plural(word, k) { return k + " " + word + (k === 1 ? "" : "s"); } 97 function totalBytes() { 98 return selected.reduce(function (s, f) { return s + (f.size || 0); }, 0); 99 } 100 function overState() { 101 var bytes = totalBytes(); 102 return { 103 count: selected.length, bytes: bytes, 104 overCount: selected.length > MAX_FILES, 105 overBytes: bytes > MAX_TOTAL_BYTES, 106 }; 107 } 108 function isOver() { var s = overState(); return s.overCount || s.overBytes; } 109 function anyUploading() { return selected.some(function (f) { return f.status === "uploading"; }); } 110 function anyProcessing() { return selected.some(function (f) { return f.status === "processing"; }); } 111 function anyError() { return selected.some(function (f) { return f.status === "error"; }); } 112 function anyOtherLang() { return selected.some(function (f) { return f.status === "other_language"; }); } 113 // Anything that should block Send (and open the manager to explain why). In 114 // staging mode a still-uploading/extracting/errored/other-language file 115 // holds the post; in default mode only a cap breach does. 116 function hasIssues() { 117 if (isOver()) return true; 118 if (!stage) return false;
119 return anyUploading() || anyProcessing() || anyError() || anyOtherLang(); 120 } 121 122 function syncInput() { 123 if (stage) { syncIdInputs(); return; } // staging posts ids, not bytes 124 if (!fileInput) return; 125 var dt = new DataTransfer(); 126 selected.forEach(function (w) { if (w.file) dt.items.add(w.file); }); 127 fileInput.files = dt.files; 128 } 129 // Staging: keep one hidden <input name=ID_FIELD> per ready upload so the 130 // run posts the staged ids (de-duped by id, rebuilt each edit). The id 131 // de-dupe is the backstop for the staging endpoint reusing one row for 132 // identical bytes â two chips can hold the SAME id, and posting it twice 133 // would double-attach and double-consume the row server-side. 134 function syncIdInputs() { 135 form.querySelectorAll('[data-compose-id="' + ID_FIELD + '"]').forEach(function (n) { n.remove(); }); 136 var posted = {}; 137 selected.forEach(function (w) { 138 if (w.status === "ready" && w.id && !posted[w.id]) { 139 posted[w.id] = 1; 140 var h = document.createElement("input"); 141 h.type = "hidden"; h.name = ID_FIELD; h.value = w.id; 142 h.setAttribute("data-compose-id", ID_FIELD); 143 form.appendChild(h); 144 } 145 }); 146 } 147 // Is this staged id held by any OTHER chip? (The endpoint returns one row 148 // for identical bytes, so twins share it â see syncIdInputs.) 149 function idSharedWithOther(w) { 150 return selected.some(function (o) { return o !== w && o.id && o.id === w.id; }); 151 } 152 // Order the canonical list by the active sort. Upload order is irrelevant, 153 // so sorting in place keeps chips + modal + input consistent. 154 function sortSelected() { 155 selected.sort(function (a, b) { 156 var r = sortKey === "size" 157 ? (a.size || 0) - (b.size || 0) 158 : a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }); 159 return r * sortDir; 160 }); 161 } 162 // "Address files by number" tip pill â names in the same (sorted) order the 163 // form posts them, so File N in the popover == File N the model is told. 164 var tip = window.LexComposeTip 165 ? window.LexComposeTip.attach(form, function () { return selected.map(function (f) { return f.name; }); }) 166 : { sync: function () {} }; 167 168 function commit() { 169 sortSelected(); syncInput(); syncChips(); syncManageBtn(); syncModal(); tip.sync(); 170 var s = overState(); 171 onChange({ count: s.count, bytes: s.bytes, overCount: s.overCount, overBytes: s.overBytes, overFile: s.overFile, over: s.overCount || s.overBytes || s.overFile }); 172 } 173 function removeAt(idx) { 174 var w = selected[idx]; 175 if (w) { 176 if (w.xhr) { try { w.xhr.abort(); } catch (e) {} } 177 // Never drop a row another chip still points at â the staging endpoint 178 // reuses one row for identical bytes, so deleting it here would pull the 179 // file out from under its twin. 180 if (stage && w.id && stage.deleteUrl && !idSharedWithOther(w)) { 181 var body = new FormData(); body.append("id", w.id); 182 try { fetch(stage.deleteUrl, { method: "POST", credentials: "same-origin", headers: { "X-CSRFToken": csrf() }, body: body }); } catch (e) {} 183 } 184 } 185 selected.splice(idx, 1); commit(); 186 } 187 188 // ----- inline chips ----- 189 function chipExtra(w) { 190 if (w.status === "uploading") return (w.pct || 0) + "%"; 191 if (w.status === "processing") return (w.stage || "OCR") + " " + (w.pct || 0) + "%"; 192 if (w.status === "other_language") return "Other language"; 193 if (w.status === "error") return "â "; 194 return fmtSize(w.size); 195 } 196 function makeChip(idx, w) { 197 var chip = document.createElement("span"); 198 chip.className = "compose__file" 199 + ((w.status === "error" || w.status === "other_language") ? " is-error" : "") 200 + ((w.status === "uploading" || w.status === "processing") ? " is-loading" : ""); 201 chip.innerHTML = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3v5h5M7 3h8l5 5v11a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/></svg>' 202 + '<span class="compose__file-name"></span>' 203 + '<span class="compose__file-size text-subtle"></span>' 204 + '<button type="button" class="compose__file-x" aria-label="Remove">×</button>'; 205 chip.querySelector(".compose__file-name").textContent = w.name; 206 var sz = chip.querySelector(".compose__file-size"); 207 if (w.status === "uploading" || w.status === "processing") { 208 sz.innerHTML = '<span class="picker-spin" aria-hidden="true"></span> ' + chipExtra(w); 209 } else { 210 sz.textContent = chipExtra(w); 211 } 212 chip.querySelector(".compose__file-x").addEventListener("click", function () { removeAt(idx); }); 213 return chip; 214 } 215 function makeMorePill(label, onClick) { 216 var pill = document.createElement("button"); 217 pill.type = "button"; 218 pill.className = "compose__file compose__file-more"; 219 pill.textContent = label; 220 pill.addEventListener("click", onClick); 221 return pill; 222 } 223 function syncChips() { 224 if (!chips) return; 225 chips.innerHTML = ""; 226 chips.hidden = selected.length === 0; 227 chips.classList.toggle("is-over", isOver() || anyError() || anyOtherLang()); 228 if (selected.length === 0) return; 229 var overflowing = selected.length > MAX_VISIBLE_CHIPS; 230 var visible = overflowing ? MAX_VISIBLE_CHIPS : selected.length; 231 for (var i = 0; i < visible; i++) chips.appendChild(makeChip(i, selected[i])); 232 if (overflowing) { 233 chips.appendChild(makeMorePill("+" + (selected.length - MAX_VISIBLE_CHIPS) + " more", openModal)); 234 } 235 } 236 function syncManageBtn() { 237 if (!manageBtn) return; 238 manageBtn.hidden = selected.length === 0; 239 manageBtn.classList.toggle("is-over", isOver() || anyError() || anyOtherLang()); 240 if (manageCount) manageCount.textContent = selected.length ? plural("file", selected.length) : ""; 241 } 242 243 // ----- manager modal ----- 244 function syncStats() { 245 var s = overState(); 246 if (statCountVal) statCountVal.textContent = s.count; 247 if (statSizeVal) statSizeVal.textContent = fmtSize(s.bytes); 248 if (statCountBox) { 249 statCountBox.classList.toggle("is-over", s.overCount); 250 statCountBox.classList.toggle("is-ok", !s.overCount); 251 } 252 if (statSizeBox) { 253 statSizeBox.classList.toggle("is-over", s.overBytes); 254 statSizeBox.classList.toggle("is-ok", !s.overBytes); 255 } 256 // Reveal the "why a limit / upgrade" note only when over a cap. 257 if (modalEl) modalEl.classList.toggle("is-over", isOver()); 258 } 259 function updateSortIndicators() { 260 if (!modalEl) return; 261 modalEl.querySelectorAll("[data-sort]").forEach(function (btn) { 262 var active = btn.getAttribute("data-sort") === sortKey; 263 var arrow = btn.querySelector(".compose-files-modal__sort-arrow"); 264 if (arrow) arrow.textContent = active ? (sortDir === 1 ? "â²" : "â¼") : ""; 265 var th = btn.closest("th"); 266 if (th) th.setAttribute("aria-sort", active ? (sortDir === 1 ? "ascending" : "descending") : "none"); 267 }); 268 } 269 function syncModal() { 270 if (!modalEl) return; 271 syncStats(); 272 if (modalTbody) { 273 modalTbody.innerHTML = ""; 274 if (!selected.length) { 275 var empty = document.createElement("tr"); 276 empty.innerHTML = '<td colspan="3" class="text-subtle text-center py-3">No files attached.</td>'; 277 modalTbody.appendChild(empty); 278 } 279 selected.forEach(function (w, i) { 280 var tr = document.createElement("tr"); 281 if (w.status === "error" || w.status === "other_language") tr.className = "is-error";
282 tr.innerHTML = '<td class="compose-files-modal__name"></td>' 283 + '<td class="text-end font-mono text-subtle compose-files-modal__size"></td>' 284 + '<td class="text-end"><button type="button" class="compose-files-modal__del" aria-label="Remove file">×</button></td>'; 285 var nameCell = tr.querySelector(".compose-files-modal__name"); 286 nameCell.textContent = w.name; 287 var sizeCell = tr.querySelector(".compose-files-modal__size"); 288 if (w.status === "uploading") { 289 sizeCell.innerHTML = '<span class="picker-spin" aria-hidden="true"></span> ' + (w.pct || 0) + "%"; 290 } else if (w.status === "processing") { 291 sizeCell.innerHTML = '<span class="picker-spin" aria-hidden="true"></span> ' + (w.stage || "OCR") + " " + (w.pct || 0) + "%"; 292 } else if (w.status === "other_language") { 293 var ole = document.createElement("div"); 294 ole.className = "compose-files-modal__err"; 295 ole.textContent = w.error || "We couldn't read usable text from this scan (it may be in another language)."; 296 nameCell.appendChild(ole); 297 var aiBtn = document.createElement("button"); 298 aiBtn.type = "button"; 299 aiBtn.className = "btn btn-link btn-sm p-0 mt-1"; 300 aiBtn.textContent = "Use AI to OCR"; 301 aiBtn.addEventListener("click", function () { reocrItem(w); }); 302 nameCell.appendChild(aiBtn); 303 sizeCell.textContent = "â"; 304 } else if (w.status === "error") { 305 var er = document.createElement("div"); 306 er.className = "compose-files-modal__err"; 307 er.textContent = w.error || "This file couldn't be used."; 308 nameCell.appendChild(er); 309 sizeCell.textContent = "â"; 310 } else { 311 sizeCell.textContent = fmtSize(w.size); 312 } 313 tr.querySelector(".compose-files-modal__del").addEventListener("click", function () { removeAt(i); }); 314 modalTbody.appendChild(tr); 315 }); 316 } 317 if (modalDone) modalDone.disabled = hasIssues(); 318 } 319 var bsModal = null; 320 function openModal() { 321 if (!modalEl || !window.bootstrap || !window.bootstrap.Modal) return; 322 syncModal(); 323 bsModal = window.bootstrap.Modal.getInstance(modalEl) || new window.bootstrap.Modal(modalEl); 324 bsModal.show(); 325 } 326 if (modalDone) modalDone.addEventListener("click", function () { 327 if (hasIssues()) return; 328 if (bsModal) bsModal.hide(); 329 }); 330 // Sortable columns: click a header to sort by name/size; click the active 331 // header again to flip direction. 332 if (modalEl) modalEl.querySelectorAll("[data-sort]").forEach(function (btn) { 333 btn.addEventListener("click", function () { 334 var key = btn.getAttribute("data-sort"); 335 if (sortKey === key) sortDir = -sortDir; 336 else { sortKey = key; sortDir = 1; } 337 updateSortIndicators(); 338 commit(); 339 }); 340 }); 341 updateSortIndicators(); 342 343 // ----- staging (opt-in) -----
344 function stageFile(w) { 345 var fd = new FormData(); 346 fd.append("file", w.file); 347 var xhr = new XMLHttpRequest(); 348 w.xhr = xhr; 349 xhr.open("POST", stage.uploadUrl, true); 350 xhr.setRequestHeader("X-CSRFToken", csrf()); 351 if (xhr.upload) { 352 xhr.upload.onprogress = function (e) { 353 if (!e.lengthComputable) return; 354 w.pct = Math.round(e.loaded / e.total * 100); 355 syncChips(); syncModal(); 356 }; 357 } 358 xhr.onload = function () { 359 w.xhr = null; 360 var data = {}; 361 try { data = JSON.parse(xhr.responseText) || {}; } catch (e) {} 362 if (xhr.status >= 200 && xhr.status < 300 && (data.status === "ready" || data.status === "processing")) { 363 // The endpoint returns the EXISTING row when the bytes match a file 364 // already staged, so the same document attached under two names comes 365 // back with one id. Collapse to a single chip (same silent de-dupe 366 // applyFiles does for name+size) so the file count, the "File N" 367 // badges and what the model receives all agree. 368 w.id = data.id; 369 if (idSharedWithOther(w)) { 370 var at = selected.indexOf(w); 371 if (at !== -1) selected.splice(at, 1); 372 commit(); 373 return; 374 } 375 if (data.size) w.size = data.size; 376 if (data.status === "ready") { 377 w.status = "ready"; w.chars = data.chars || 0; 378 } else { 379 w.status = "processing"; w.pct = 0; w.stage = "OCR"; 380 pollStage(w); 381 } 382 } else { 383 w.status = "error"; w.error = data.error_message || "Upload failed. Please try again."; 384 } 385 commit(); 386 if (w.status === "error") openModal(); 387 }; 388 xhr.onerror = function () { 389 w.xhr = null; w.status = "error"; 390 w.error = "Upload failed â check your connection and try again."; 391 commit(); openModal(); 392 }; 393 xhr.send(fd); 394 } 395 function pollStage(w) { 396 var tries = 0; 397 (function loop() { 398 if (!w || w.status !== "processing") return; 399 tries += 1; 400 fetch(stage.statusUrl + "?ids=" + encodeURIComponent(w.id), { credentials: "same-origin" }) 401 .then(function (r) { return r.json(); }) 402 .then(function (d) { 403 if (w.status !== "processing") return; 404 var it = ((d && d.items) || []).filter(function (x) { return x.id === w.id; })[0]; 405 if (!it || it.status === "processing") { 406 if (it) { w.pct = it.pct || 0; w.stage = it.stage || "OCR"; syncChips(); syncModal(); } 407 if (tries < 600) { setTimeout(loop, 2000); } 408 else { w.status = "error"; w.error = "Timed out reading this document."; commit(); } 409 return; 410 } 411 if (it.status === "ready") { w.status = "ready"; } 412 else if (it.status === "other_language") { 413 w.status = "other_language"; w.reocr_url = it.reocr_url || (stage && stage.reocrUrl); w.error = it.error_message || ""; 414 openModal(); 415 } else { w.status = "error"; w.error = it.error_message || "Could not read this file."; } 416 commit(); 417 }) 418 .catch(function () { if (tries < 600) { setTimeout(loop, 3000); } else { w.status = "error"; commit(); } }); 419 })(); 420 } 421 function reocrItem(w) { 422 if (!w || !w.id || !stage || !stage.reocrUrl) return; 423 var body = new FormData(); body.append("id", w.id); 424 fetch(stage.reocrUrl, { method: "POST", credentials: "same-origin", headers: { "X-CSRFToken": csrf() }, body: body }) 425 .then(function (r) { return r.json(); }) 426 .then(function (d) { 427 if (d && d.status === "upgrade") { 428 if (window.LexUpgrade) window.LexUpgrade.show({ 429 message: "AI OCR is an Enterprise feature. Upgrade to re-read scanned or non-Latin documents with AI vision.", 430 upgradeUrl: d.upgrade_url, canUpgrade: !!d.can_upgrade }); 431 return; 432 } 433 if (d && d.status === "processing") { w.status = "processing"; w.pct = 0; w.stage = "AI OCR"; commit(); pollStage(w); } 434 else { w.status = "error"; w.error = (d && d.error_message) || "Could not start AI OCR."; commit(); } 435 }) 436 .catch(function () { w.status = "error"; w.error = "Could not start AI OCR."; commit(); }); 437 } 438 439 // ----- intake ----- 440 function applyFiles(list) { 441 // Append to the current selection, de-duping by name+size, then open the 442 // manager if the batch now exceeds either cap. 443 var seen = {}; 444 selected.forEach(function (w) { seen[w.name + "::" + w.size] = 1; }); 445 var fresh = []; 446 for (var i = 0; i < list.length; i++) { 447 var f = list[i], k = f.name + "::" + f.size; 448 if (seen[k]) continue; 449 seen[k] = 1; 450 var w = { file: f, name: f.name, size: f.size || 0, status: stage ? "uploading" : "ready", 451 pct: 0, stage: "", id: null, error: "", xhr: null }; 452 selected.push(w); 453 fresh.push(w); 454 } 455 commit();
456 if (stage) fresh.forEach(function (w) { stageFile(w); }); 457 if (isOver()) openModal(); 458 // Tell the composer an upload was added, so the exclusive "Entire matter 459 // documents" scope (attach-menu.js) switches itself off. 460 if (fresh.length) { try { form.dispatchEvent(new CustomEvent("lex:upload-added")); } catch (e) {} } 461 } 462 463 // Clear all selected uploads (aborting in-flight uploads + deleting any 464 // staged blobs). Fired when the user switches to "Entire matter documents", 465 // which is exclusive of uploaded files. 466 function clearAll() { 467 for (var i = selected.length - 1; i >= 0; i--) removeAt(i); 468 } 469 form.addEventListener("lex:matterrag-on", clearAll); 470 471 if (fileInput) fileInput.addEventListener("change", function () { 472 applyFiles(fileInput.files); 473 if (stage) fileInput.value = ""; // let the same file be re-picked later 474 }); 475 if (attachBtn && fileInput) attachBtn.addEventListener("click", function () { fileInput.click(); }); 476 if (manageBtn) manageBtn.addEventListener("click", openModal); 477 478 // On Send: hold while the batch has issues (over a cap, or â in staging mode 479 // â still uploading / OCR'ing / errored / other-language). Capture phase so 480 // it runs BEFORE a deferred quota-guard's own document-capture submit handler 481 // and pre-empts it. 482 document.addEventListener("submit", function (e) { 483 if (e.target === form && hasIssues()) { 484 e.preventDefault(); 485 e.stopImmediatePropagation(); 486 openModal(); 487 // A plan-cap breach (vs. a still-uploading file) also opens the shared, 488 // educative limit modal with the upgrade / contact-admin path. Fetching 489 // the canonical payload lets app.js's interceptor render it. 490 var s = overState(); 491 var key = (s.overCount || s.overBytes) ? "ai_inference_files_per_request" : ""; 492 if (key) { 493 try { 494 fetch("/quota/notice/?key=" + encodeURIComponent(key), { 495 credentials: "same-origin", 496 headers: { "X-Requested-With": "XMLHttpRequest" }, 497 }); 498 } catch (_) {} 499 } 500 } 501 }, true); 502 503 // Drag a file anywhere onto the composer to attach it. 504 ["dragenter", "dragover"].forEach(function (ev) { 505 form.addEventListener(ev, function (e) { e.preventDefault(); form.classList.add("is-dropping"); }); 506 }); 507 ["dragleave", "drop"].forEach(function (ev) { 508 form.addEventListener(ev, function (e) { 509 e.preventDefault(); 510 if (ev === "dragleave" && form.contains(e.relatedTarget)) return; 511 form.classList.remove("is-dropping"); 512 }); 513 }); 514 form.addEventListener("drop", function (e) { 515 if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) { 516 applyFiles(e.dataTransfer.files); 517 } 518 }); 519 520 // Initial paint (empty) so the manage button / chips start consistent. 521 commit(); 522 523 // Small API for the page (e.g. clearing after submit, reading the count). 524 return { 525 openModal: openModal, 526 isOver: isOver, 527 hasIssues: hasIssues, 528 count: function () { return selected.length; }, 529 clear: clearAll, 530 state: overState, 531 // Char counts of ready (text-extracted) uploads â feeds the token meter. 532 attachmentChars: function () { 533 return selected 534 .filter(function (w) { return w.status === "ready"; }) 535 .map(function (w) { return w.chars || 0; }); 536 }, 537 }; 538 } 539 540 window.LexComposeFiles = { init: init }; 541})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.