PageSourceSearch

https://app.lexlegis.ai/static/js/compose-files.dd1b62c76182.js

js lexlegis.ai collected 2026-09-29 05:15:14 UTC 25,612 bytes, 541 lines download raw bytes

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">&times;</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">&times;</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.